testing library: side-effects and `waitfor`

2020-10-13

 | 

~3 min read

 | 

444 words

I was recently working on a project when I broke my tests in a rather unexpected way. The code behaved as expected in manual user testing (yay!), but created an infinite loop when I ran my automated tests.

After some debugging, I narrowed the issue down to a single function change and identified the hanging test.

A simplified version of the diff is:

myComponent.tsx
export function MyComponent(props){
    const { onDismiss } = props
    const onDismissCallBack = () => {
+       dispatch({type: 'DISMISS'})
        onDismiss()
    }
    return (
        /* ... */
        <button onClick={onDismissCallBack} data-testid={"dismiss_button"}>Dismiss</button>
    )
}

(Note: This particular component uses a reducer pattern within a Context API. On a dismiss action, regardless of whether or not the consumer of the component wants to have their own behavior, we want to dispatch a “Dismiss” action.)

Meanwhile, the test that failed looked like:

__tests__/myComponent.jsx
const renderComponent = (props: ITreeSelectionWrapper) =>
    render(
        <AContextProvider>
            <MyComponent {...props} />
        </AContextProvider>,
    )

const defaultProps = {
    onDismiss: jest.fn(),
}

test("onDismiss", async () => {
    const { getByTestId } = renderComponent(defaultProps)

    await waitFor(() => {
        const closeButton = getByTestId("dismiss_button")
        userEvent.click(closeButton)
        expect(defaultProps.onDismiss).toHaveBeenCalledTimes(1)
    })
    cleanup()
})

This was a pattern we’d been using elsewhere. It’s even fairly similar to the example in the docs for using waitfor:

// ...
// Wait until the callback does not throw an error. In this case, that means
// it'll wait until the mock function has been called once.
await waitFor(() => expect(mockAPI).toHaveBeenCalledTimes(1))
// ...

There’s just one key distinction: our click event has side effects. The API call doesn’t.

Notice that the Mutation Observer will re-run on any changes in the container or its descendants:

The default mutationObserverOptions is {subtree: true, childList: true, attributes: true, characterData: true} which will detect additions and removals of child elements (including text nodes) in the container and any of its descendants. It will also detect attribute changes. When any of those changes occur, it will re-run the callback.

I believe this is where I ran into trouble and created a loop within the test runner but, notably, not in manual testing.

We can address this with a slight refactor:

refactoredTest.js
test('onDismiss', async () => {
    const { getByTestId } = renderComponent(defaultProps);

-    await waitFor(() => {
-        const closeButton = getByTestId('dismiss_button')
-        userEvent.click(closeButton);
-        expect(defaultProps.onDismiss).toHaveBeenCalledTimes(1);
-    });
+   const closeButton = await waitFor(() => getByTestId('dismiss_button'))
+   userEvent.click(closeButton)
+   expect(defaultProps.onDismiss).toHaveBeenCalledTimes(1);

    cleanup();
});

Finding the problem was not the hard part. It was understanding why it was an issue that proved daunting. Working with colleagues, I was able to resolve this thorny problem and come away with a new heuristic in the process: avoid side-effects within waitFor calls.


Hi there and thanks for reading! My name's Stephen. I live in Chicago with my wife, Kate, and dog, Finn. Want more? See about and get in touch!