Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Test document listener with React Testing Library

I'm attempting to test a React component similar to the following:

import React, { useState, useEffect, useRef } from "react";

export default function Tooltip({ children }) {
  const [open, setOpen] = useState(false);
  const wrapperRef = useRef(null);

  const handleClickOutside = (event) => {
    if (
      open &&
      wrapperRef.current &&
      !wrapperRef.current.contains(event.target)
    ) {
      setOpen(false);
    }
  };

  useEffect(() => {
    document.addEventListener("click", handleClickOutside);

    return () => {
      document.removeEventListener("click", handleClickOutside);
    };
  });

  const className = `tooltip-wrapper${(open && " open") || ""}`;

  return (
    <span ref={wrapperRef} className={className}>
      <button type="button" onClick={() => setOpen(!open)} />
      <span>{children}</span>
      <br />
      <span>DEBUG: className is {className}</span>
    </span>
  );
}

Clicking on the tooltip button changes the state to open (changing the className), and clicking again outside of the component changes it to closed.

The component works (with appropriate styling), and all of the React Testing Library (with user-event) tests work except for clicking outside.

  it("should close the tooltip on click outside", () => {
    // Arrange
    render(
      <div>
        <p>outside</p>
        <Tooltip>content</Tooltip>
      </div>
    );

    const button = screen.getByRole("button");
    userEvent.click(button);

    // Temporary assertion - passes
    expect(button.parentElement).toHaveClass("open");

    // Act
    const outside = screen.getByText("outside");

    // Gives should be wrapped into act(...) warning otherwise
    act(() => {
      userEvent.click(outside);
    });

    // Assert
    expect(button.parentElement).not.toHaveClass("open"); // FAILS
  });

I don't understand why I had to wrap the click event in act - that's generally not necessary with React Testing Library.

I also don't understand why the final assertion fails. The click handler is called twice, but open is true both times.

There are a bunch of articles about limitations of React synthetic events, but it's not clear to me how to put all of this together.

like image 372
TrueWill Avatar asked Oct 25 '25 05:10

TrueWill


1 Answers

I finally got it working.

  it("should close the tooltip on click outside", async () => {
    // Arrange
    render(
      <div>
        <p data-testid="outside">outside</p>
        <Tooltip>content</Tooltip>
      </div>
    );

    const button = screen.getByRole("button");
    userEvent.click(button);

    // Verify initial state
    expect(button.parentElement).toHaveClass("open");

    const outside = screen.getByTestId("outside");

    // Act
    userEvent.click(outside);

    // Assert
    await waitFor(() => expect(button.parentElement).not.toHaveClass("open"));
  });

The key seems to be to be sure that all activity completes before the test ends.

Say a test triggers a click event that in turn sets state. Setting state typically causes a rerender, and your test will need to wait for that to occur. Normally you do that by waiting for the new state to be displayed.

In this particular case waitFor was appropriate.

like image 108
TrueWill Avatar answered Oct 26 '25 17:10

TrueWill