Testing events attached to HTML Document

andrewmiroshnichenko

Andrii Miroshnychenko

Posted on August 21, 2022

Testing events attached to HTML Document

Component

Recently I was working on tests for one of our React components, which implements dropdown functionality. With appropriate prop enabled, it allows dropdown items to be collapsed on "outside" (anywhere on the page) click. Code for this part was similar to

useEffect(() => {
  const onOutsideClick = () => setIsOpen(false)

  document.addEventListener("click", onOutsideClick)

  return () => {
    document.removeEventListener("click", onOutsideClick)
  }
}, [])
Enter fullscreen mode Exit fullscreen mode

As you can see, event listener is attached directly to document object, which is a reference to HTML Document. It is not a DOM node, which is important. Overall, addition of event listener to document is a wide used practice when you want to ensure your callback be fired on any place on the page.

Test setup

We are using React Testing Library to test our components. It encourages to work with components/features as user would've work with them. As a consequence, it's better to avoid doing things which aren't natural to your app user flow.
Unfortunately while dealing with document it's necessary to break this rule. Main (and only) RTL's method of rendering test markup is render which build whole tree starting from dedicated DOM node, document.body by default.


render(<AppComponent />)
// Which equals to
render(<AppComponent/>, { baseElement: document.body })

// Code below won't work because document isn't a node
render(<AppComponent/>, { baseElement: document })

Enter fullscreen mode Exit fullscreen mode

You can alter it by adjusting baseElement option of render method, but still it has to be DOM node. It's not possible to build a tree on top of document object itself.
This means opened dropdown won't be closed on click, because document object isn't simulated by testing library and no events will be issued.

Solution

First idea that I was thinking of was to hide dropdown by clicking on its anchor (our implementation allows to do so).

    // Initially options are not mounted in the tree
    expect(getByText('Option three').not.toBeInTheDocument()
    // This is dropdown anchor, click on it shows options to choose from
    fireEvent.click(getByText('Selected: Option one'))
    // Third option is now visible (and mounted)
    expect(getByText('Option three').toBeVisible()
    // "Technical" click, which won't be conducted by user under normal circumstances
    fireEvent.click(getByText('Selected: Option one'))
    // Third option is now unmounted again
    expect(getByText('Option three').not.toBeInTheDocument()

Enter fullscreen mode Exit fullscreen mode

Despite being a working solution, this adds some unnatural flow to test and can potentially influence dropdown state in unpredictable manner.

Most preferable workaround (from those left on the table) is to simulate click event on the document object. This will cause callbacks to be triggered and won't require any additional user-related actions.

    // Initially options are not mounted in the tree
    expect(getByText('Option three').not.toBeInTheDocument()
    // This is dropdown anchor, click on it shows options to choose from
    fireEvent.click(getByText('Selected: Option one'))
    // Third option is now visible (and mounted)
    expect(getByText('Option three').toBeVisible()
    act(() => {
      // Direct call on document API
      document.dispatchEvent(new Event('click'))
    })
    // Third option is now unmounted again
    expect(getByText('Option three').not.toBeInTheDocument()

Enter fullscreen mode Exit fullscreen mode

I hope this small investigation will help you in your daily work with RTL :)

💖 💪 🙅 🚩
andrewmiroshnichenko
Andrii Miroshnychenko

Posted on August 21, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related