Too often, I join a new React project where unit tests are lacking, both in amount and quality. There are a few causes, but the one I want to discuss today is the poor test environment.
Indeed, testing requires skills, thoroughness, and is definitely time-consuming (even if thats worth it!). If testing is more painful than necessary, it becomes a signal to avoid writing tests altogether.
Environment
With React, the tools I recommend are Jest and React Testing Library. Nothing fancy here; they are the de-facto standard in the community.
Example
To demonstrate how to write great tests, in a good environment, we need a component to test, right? Lets use a common functionality: a counter.
Ill start with the following component, inside its own Counter.tsx
file.
import { useState } from 'react';
const Counter = () => {
const [counter, setCounter] = useState(0);
const decrement = () => setCounter((count) => count - 1);
const increment = () => setCounter((count) => count + 1);
return (
<div>
<h1>Counter: {counter}</h1>
<button onClick={decrement}>-</button>
<button onClick={increment}>+</button>
</div>
);
};
export default Counter;
I would like to specify my application entrypoint is still App.tsx. It will be modified later in this article to demonstrate our configuration!
import Counter from './Counter';
function App() {
return <Counter />;
}
export default App;
Tests
A complete repository can be found on GitHub.
React testing library
After following the introduction and adding jest-dom, I set up the first version of my test, as shown below:
import { fireEvent, render, screen } from '@testing-library/react';
import Counter from '../Counter';
describe('Counter', () => {
it('Should display a default value', () => {
render(<Counter />);
expect(screen.queryByText('Counter: 0')).toBeInTheDocument();
});
it('Should increment', () => {
render(<Counter />);
fireEvent.click(screen.getByText('+'));
expect(screen.queryByText('Counter: 1')).toBeInTheDocument();
});
it('Should decrement', () => {
render(<Counter />);
fireEvent.click(screen.getByText('-'));
expect(screen.queryByText('Counter: -1')).toBeInTheDocument();
});
});
We can find three basic tests. They help us verify weve displayed a default value, which increments or decrements when the corresponding button is clicked.
With the current configuration, Im able to run my test successfully:
But issues arise when working with a bigger codebase, more functionalities, and dependencies. In this article, I would like to demonstrate how we handle Redux and GraphQL which are fairly common.
Adding Redux
Im following RTK Quick Start, which conveniently shows an example with a counter app. I end up with the following slice:
import { createSlice } from '@reduxjs/toolkit';
export interface CounterState {
value: number;
}
const initialState: CounterState = {
value: 0,
};
export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
},
});
export const { increment, decrement } = counterSlice.actions;
export default counterSlice.reducer;
The following store:
import { combineReducers, configureStore } from '@reduxjs/toolkit';
import counterReducer from './counter.slice';
export const rootReducer = combineReducers({
counter: counterReducer,
});
export const store = configureStore({
reducer: rootReducer,
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
And an updated Counter.tsx
component:
import { useDispatch, useSelector } from 'react-redux';
import { decrement, increment } from './store/counter.slice';
import { RootState } from './store/store';
const Counter = () => {
const counter = useSelector((state: RootState) => state.counter.value);
const dispatch = useDispatch();
return (
<div>
<h1>Counter: {counter}</h1>
<button onClick={() => dispatch(decrement())}>-</button>
<button onClick={() => dispatch(increment())}>+</button>
</div>
);
};
export default Counter;
The behavior of my counter didnt change, but tests are failing with the following error message:
Could not find react-redux context value; please ensure the component is wrapped in a .
Its quite straightforward. We are trying to test a component in isolation, but it needs a react-redux provider to work. I added it to my App.tsx
, but from my test perspective, its nowhere to be seen.
Now, for every test, we need to declare a new store and render our component with the Provider from react-redux. Technically, it would work with the following code:
import { fireEvent, render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import Counter from '../Counter';
import { rootReducer } from '../store/store';
describe('Counter', () => {
it('Should display a default value', () => {
const store = configureStore({ reducer: rootReducer });
render(
<Provider store={store}>
<Counter />
</Provider>
);
expect(screen.queryByText('Counter: 0')).toBeInTheDocument();
});
it('Should increment', () => {
const store = configureStore({ reducer: rootReducer });
render(
<Provider store={store}>
<Counter />
</Provider>
);
fireEvent.click(screen.getByText('+'));
expect(screen.queryByText('Counter: 1')).toBeInTheDocument();
});
it('Should decrement', () => {
const store = configureStore({ reducer: rootReducer });
render(
<Provider store={store}>
<Counter />
</Provider>
);
fireEvent.click(screen.getByText('-'));
expect(screen.queryByText('Counter: -1')).toBeInTheDocument();
});
});
But, is that the right solution? Thats a lot of unneeded boilerplate code. If you ever have more dependencies, your tests will grow exponentially. It makes them hard to write and maintain.
Instead, React Testing Library explains how to set up a Custom Render.
Setup improvement
After following the Custom Render section, I end up creating a tests/ directory with a testing.tsx
file:
import React, { FC, ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { Provider } from 'react-redux';
import { rootReducer } from '../src/store/store';
import { configureStore } from '@reduxjs/toolkit';
const AllTheProviders: FC<{ children: React.ReactNode }> = ({ children }) => {
const store = configureStore({ reducer: rootReducer });
return <Provider store={store}>{children}</Provider>;
};
const customRender = (ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) => {
return render(ui, { wrapper: AllTheProviders, ...options });
}
export * from '@testing-library/react';
export { customRender as render };
I add an index.ts
file that re-exports everything from my tests/ directory Then, instead of importing my Redux boilerplate code and utilities from @testing-library/react
, I import utilities from this directory:
import { fireEvent, render, screen } from '../../tests';
import Counter from '../Counter';
describe('Counter', () => {
it('Should display a default value', () => {
render(<Counter />);
expect(screen.queryByText('Counter: 0')).toBeInTheDocument();
});
it('Should increment', () => {
render(<Counter />);
fireEvent.click(screen.getByText('+'));
expect(screen.queryByText('Counter: 1')).toBeInTheDocument();
});
it('Should decrement', () => {
render(<Counter />);
fireEvent.click(screen.getByText('-'));
expect(screen.queryByText('Counter: -1')).toBeInTheDocument();
});
});
Thats much better! But, what if we need to trigger some change to our Redux store during our test? Also, when our app grows, adding dozens of providers inside our testing.tsx
will make it harder to read and maintain.
First, I want to make my render more customizable. If you look at the customRender
method, you can see it takes some options related to React Testing Library.
We can use those options to customize our providers. Ill allow a new property, providers, which is an object with the data related to our providers. For now, it takes the following:
export type ProvidersRenderOptions = {
store?: Store;
};
export type CustomRenderOptions = {
providers?: ProvidersRenderOptions;
};
The implementation looks like this:
import { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { Provider } from 'react-redux';
import { rootReducer } from '../src/store/store';
import { configureStore, Store } from '@reduxjs/toolkit';
export type ProvidersRenderOptions = {
store?: Store;
};
export type CustomRenderOptions = {
providers?: ProvidersRenderOptions;
};
const AllTheProviders = (options: ProvidersRenderOptions = {}) => ({ children }: { children: React.ReactNode }) => {
const store = options.store ?? configureStore({ reducer: rootReducer });
return <Provider store={store}>{children}</Provider>;
};
const customRender = (ui: ReactElement, options: CustomRenderOptions & Omit<RenderOptions, 'wrapper'> = {}) => {
const { providers, ...others } = options;
render(ui, { wrapper: AllTheProviders(providers), ...others });
};
export * from '@testing-library/react';
export { customRender as render };
Looks good, Ill even throw in a helper function to build a store:
export const generateStore = (preloadedState: PreloadedState<typeof rootReducer>) => {
return configureStore({
preloadedState,
reducer: rootReducer,
});
};
This way, Im able to write a more advanced test:
it('Should display a default value', () => {
const store = generateStore({ counter: { value: 5 } });
render(<Counter />, { providers: { store } });
expect(screen.queryByText('Counter: 5')).toBeInTheDocument();
});
That looks quite good! But we still have one issue: the testing.tsx
file will grow to become unmaintainable with the current state of things.
Instead, I would like to move the declaration for providers to different files, and build the function allTheProviders
on the fly. Also, there is a great pattern to build it, function composition!
Let me explain. Instead of having one huge function, I create one for each provider I want to add. These new functions take options, a React node, and return a React node (with potentially a new provider).
This function, for Redux, would look like this:
export const withRedux = (children: ReactElement, options: ProvidersRenderOptions) => {
return <Provider store={options.store ?? configureStore({ reducer: rootReducer })}>{children}</Provider>;
};
If they have this structure, I can chain them, or in other words, compose them, to build a component with multiple providers. If I split my list of providers, the function dedicated to composing them, and the function AllTheProviders
, it looks like the following:
type ComposableProvider = (children: ReactElement, options: ProvidersRenderOptions) => ReactElement;
const providers: ComposableProvider[] = [withRedux];
const composeProviders = (children: ReactElement, options: ProvidersRenderOptions) => {
return providers.reduce((component, provider) => {
return provider(component, options);
}, children);
};
const AllTheProviders = (options: ProvidersRenderOptions = {}) => {
return ({ children }: { children: ReactElement }) => {
return composeProviders(children, options);
};
};
And thats it! This way, we can declare new (composable) provider functions, in a different file, and add them to our list.
Adding GraphQL
Lets improve our demonstration by adding GraphQL. Well add functionalities to load and save the current counter.
My schema and resolvers look like the following:
type Query {
counter: Int
}
type Mutation {
setCounter(counter: Int): Int
}
let savedCounter = 0;
export default {
Query: {
counter() {
return savedCounter;
},
},
Mutation: {
setCounter(_: any, { counter }: { counter: number }) {
savedCounter = counter;
return savedCounter;
},
},
};
After setting up Apollo on the server and frontend, I added two queries to my counter:
export const GET_COUNTER = gql`
query counter {
counter
}
`;
export const SET_COUNTER = gql`
mutation setCounter($counter: Int) {
setCounter(counter: $counter)
}
`;
Then, I updated my Redux slice, and added two buttons in order to save and load the current counter:
export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
load: (state, action: PayloadAction<number>) => {
state.value = action.payload;
},
},
});
export const { increment, decrement, load } = counterSlice.actions;
const Counter = () => {
const counter = useSelector((state: RootState) => state.counter.value);
const dispatch = useDispatch();
const [getCount, query] = useLazyQuery(GET_COUNTER);
const [mutate, mutation] = useMutation(SET_COUNTER, { variables: { counter } });
useEffect(() => {
if (query.data) dispatch(load(query.data.counter));
}, [query.data]);
return (
<div>
<h1>Counter: {counter}</h1>
<button onClick={() => dispatch(decrement())}>-</button>
<button onClick={() => dispatch(increment())}>+</button>
<button disabled={mutation.loading} onClick={() => mutate()}>
save
</button>
<button disabled={mutation.loading} onClick={() => getCount()}>
load
</button>
</div>
);
};
But now, just like for Redux, our tests throw an error:
Invariant Violation: Could not find client in the context or passed in as an option. Wrap the root component in an , or pass an ApolloClient instance in via options.
Lets follow the testing section from Apollo, and integrate it into our custom render. We can start by adding an option for GraphQL mocks and create a composable test provider for apollo:
export type ProvidersRenderOptions = {
store?: Store;
graphql?: MockedResponse[];
};
import { ReactElement } from 'react';
import { MockedProvider } from '@apollo/client/testing';
import { ProvidersRenderOptions } from '../testing';
export function withApollo(children: ReactElement, options: ProvidersRenderOptions) {
return (
<MockedProvider mocks={options.graphql ?? []} addTypename={false}>
{children}
</MockedProvider>
);
}
Then, we can add this composable provider to our providers
. Keep in mind the order of providers
will have an impact on how our providers are added.
This is related to how function composition works. The most left in the list will be the inner providers
, while the most right will be the outer providers
.
const providers: ComposableProvider[] = [withRedux, withApollo];
Then, Im able to write the following test:
it('Should load counter', async () => {
const query = { request: { query: GET_COUNTER }, result: { data: { counter: 5 } } };
render(<Counter />, { providers: { graphql: [query] } });
fireEvent.click(screen.getByText('load'));
await waitFor(() => {
expect(screen.queryByText('Counter: 5')).toBeInTheDocument();
});
});
it('Should save counter', async () => {
const query = {
request: { query: SET_COUNTER, variables: { counter: 1 } },
result: jest.fn().mockReturnValue({ data: { setCounter: 1 } }),
};
render(<Counter />, { providers: { graphql: [query] } });
fireEvent.click(screen.getByText('+'));
fireEvent.click(screen.getByText('save'));
await waitFor(() => {
expect(query.result).toHaveBeenCalled();
});
});
And thats it! You are now able to write proper tests in a healthy environment. Moreover, you wont have any issues when your app gets bigger, as long as you continue to create composable test providers.
Dont forget a complete repository is available on GitHub.
Cover photo by sharonmccutcheon on Unsplash
Top comments (0)