Testing events attached to HTML Document
Andrii Miroshnychenko
Posted on August 21, 2022
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)
}
}, [])
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 Main (and only) RTL's method of rendering test markup is document
it's necessary to break this rule.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 })
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()
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()
I hope this small investigation will help you in your daily work with RTL :)
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
January 27, 2024