About a year ago, I switched jobs and started working in React for the first time.
I hadn't worked with React before, but after watching this excellent video series by Developer Way on YouTube, I felt like I had the essentials under control. I strongly recommend it. If you take nothing else away from this blog post, it will have been worth it.
The app was tested using Jest and React Testing Library in combination with a test-utils file that automatically wrapped any rendered components with the app's Redux store, Tanstack Query Client and in-memory router; everything that was typically needed to render components in tests.
It looked something like this.
// test-utils.tsx
import { render } from '@testing-library/react';
import { Provider as ReduxStoreProvider } from 'react-redux';
import { QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router';
import { createStore, createQueryClient } from '@/setup';
export function renderWithWrappers(component: ReactNode) {
return render(node, {
wrapper: ({children}) => (
<ReduxStoreProvider store={createStore()}>
<QueryClientProvider client={createQueryClient()}>
<MemoryRouter>
{children}
</MemoryRouter>
</QueryClientProvider>
</ReduxStoreProvider>
),
})
}
This let users not have to deal with all the setup in every test resulting in something like this:
// Example.test.tsx
import { renderWithWrappers } from '@/testing/test-utils';
import { Example } from './Example';
test('renders', () => {
renderWithWrappers(<Example />);
});
So what are the problems that prompted this blog post?
There is no way for me to configure the context values before the component is rendered. Nor can I assert on the values stored within if interactions with the component changed them. This is especially problematic for the state of the Redux store.
This resulted in all sorts of mocks in tests. Let's look at an example.
// Example.tsx
const { useDispatch } from 'react-redux';
export function Example() {
const dispatch = useDispatch();
return (
<button onClick={() => dispatch({ type: 'hi' })}>
Say hi!
</button>
);
}
How would you test this? This is what I found.
// Example.test.tsx
import { renderWithWrappers } from '@/testing/test-utils';
import { screen } from '@testing-library/react';
import { Example } from './Example';
const mockDispatch = jest.fn();
jest.mock('react-redux', () => ({
useDispatch: () => mockDispatch,
}));
test('renders', () => {
renderWithWrappers(<Example />);
screen.getByText('Say hi!').click();
expect(mockDispatch).toHaveBeenCalledWith({ type: 'hi' });
});
The entire 'react-redux' module is mocked. Any action dispatched
through useDispatch
anywhere in the rendered component tree
will be intercepted and never reach the Redux store it was intended for.
This could have any number of unintended consequences, especially as the
size of the component tree under test grows.
There's a similar problem with both the router and the QueryClient.
In the end, I managed to find a solution to these problems, which I
packaged up into a miniscule library which I call
fixat
(Swedish meaning fixed).
It lets users define functions that run before each test is executed, providing some value for the test to interact with. To make the fixtures composible, they can optionally depend on each other, forming a DAG.
/** A fixture without any dependencies. */
export function fixture<Exports>(factory: () => Exports): Fixture<Exports>;
/** A fixture with a set of dependencies. */
export function fixture<Fixtures, Exports>(
dependencies: Fixtures,
factory: (values: ExportsOf<Fixtures>) => Exports
): Fixture<Exports>;
Here is the included React fixture in its entirety.
// 'fixat/react'
import { JSXElementConstructor, ReactNode } from 'react';
import { render, renderHook } from '@testing-library/react';
import { fixture } from "./fixture";
type Wrapper = JSXElementConstructor<{ children: ReactNode }>;
/**
* Wraps `@testing-library/react` in a fixture.
*/
export const react = fixture(() => {
const stack: Wrapper[] = [];
const wrapper: Wrapper = ({ children }) => {
return stack.reduceRight((children, Wrapper) =>
<Wrapper>{children}</Wrapper>, children);
};
return {
/**
* Wraps what the test is about to render in a {@link Wrapper}. Ideal
* for adding Provider components prior to rendering.
*/
wrap(wrapper: Wrapper) {
stack.push(wrapper);
},
/** Renders a `node` in the context of a set of wrappers. */
render(node: ReactNode) {
return render(node, { wrapper });
},
/** Renders a `hook` in the context of a set of wrappers. */
renderHook<Props, Result>(hook: (props: Props) => Result) {
return renderHook(hook, { wrapper });
},
}
});
What's going on here? Essentially, the only real extension to what
React Testing Library already provides is a wrap
function
which adds a single wrapper to a stack. It is however, very powerful.
To get Redux setup, we can define an app specific fixture that creates the app's custom store, provides it to any rendered React components or hooks, and finally returning it for tests to access.
// @/fixtures/redux
import { fixture } from 'fixat';
import { react } from 'fixat/react';
import { Provider } from 'react-redux';
import { createStore } from '@/setup';
export const redux = fixture({ react }, ({ react }) => {
const store = createStore();
react.wrap(({ children }) =>
<Provider store={store}>{children}</Provider>);
return { store };
});
These two fixtures are enough to change the testing story.
The testing
function takes a set of fixtures and returns
an object with values that correspond to the exports of the included
fixtures. These are reset before every test, ensuring a clean slate.
// Example.test.tsx
import { testing } from 'fixat';
import { react } from 'fixat/react';
import { screen } from '@testing-library/react';
import { redux } from '@/fixtures/redux';
import { Example } from './Example';
const state = testing({ react, redux });
test('renders', () => {
const dispatchSpy = jest.spyOn(state.redux.store, 'dispatch');
state.react.render(<Example />);
screen.getByText('Say hi!').click();
expect(dispatchSpy).toHaveBeenCalledWith({ type: 'hi' });
});
In this example we still spy on the testing
function, but
without overriding or mocking it. This means that any other dispatch
calls will work as usual.
There's more I want to say, but I'm falling asleep so I'll have to do so later. Good night for now.