Testing useReducer and useContext

Context is a great tool for preventing burdensome prop drilling in React applications. Reducers are a great way to manage internal state. Combining the two creates a powerful pattern, but it can be a bit tricky to test the implementation via Jest/ React Testing Library. Read on for how I've handled this scenario.
Background
This post assumes some background in the React Context API (now just a hook), as well as the built-in useReducer hook that ships with React.
State Management and Implementation
Using a reducer to manage some shared state is a pattern I've come to use a lot in React applications. It's not applicable to all sitations, but I've found it's a solid way to encapsulate a stateful piece of UI in a readable / testable way. The pattern lends itself to a nice separation of concerns (dumb components that simply render whatever state of the reducer their concerned with). It's also really simply to unit test a reducer function itself as they are pure by definition. Might be worth a post at a different time. I digress.
Below is an example of a simple implementation of this pattern - I'll write this in JavaScript for brevity. I'm also omitting any kind of action creator pattern or constants as this isn't the point of the article.
Let's start with the Provider - any component that wants to read this state or update it will need to be a child of this.
export const defaultState = {
searchTerm: null
}
export function reducer(state, action) {
switch (action.type) {
case "SET_TERM":
return {
...state,
searchTerm: action.payload.searchTerm
}
default:
return {...state}
}
}
export const SearchContext = createContext(null);
export default function SearchProvider({ children }) {
const [state, dispatchToReducer] = useReducer(reducer, defaultState);
return (
<SearchContext.Provider value={{ state, dispatchToReducer }}>{children}</SearchContext.Provider>
);
}
And to use the Provider
import { SearchContext } from './wherever';
export default function Search() {
const {
state: {
searchTerm
},
dispatchToReducer,
} = useContext(SearchContext);
function updateSearchTerm() {
dispatchToReducer({
type: "SET_TERM",
payload: "FOO"
})
}
return (
<>
<p> {searchTerm}</p>
<button onClick={updateSearchTerm}> Update term to Foo </button>
<>
)
}
Great. Now we've got the Search component hooked up to the context/reducer. When a user clicks the button, the search term will be updated to "foo", and the UI should reflect that. How can we test this?
Testing
The React Test Library mantra encourages us to test the way our software is actually used. We want to be able to render out our actual UI, and see a click make the expected updates. We don't want to simply test implementation details. To do that, we will need to make sure that our Reducer and its dispatch function are made available to any consuming components. This can be achieved with a wrapper component that we can pass when we render in our tests.
// test.js
import {screen, fireEvent, waitFor} from "@testing-library/react"
import reducer from 'where/reducer/is'
import {SearchContext} from 'where/context/is'
import Search from 'where/search/is'
const wrapper = ({ children }) => {
const defaultState = {
searchTerm: null
}
const [state, dispatchToReducer] = useReducer(reducer, defaultState);
return (
<SearchContext.Provider value={state, dispatchToReducer}>
{children}
</SearchContext.Provider>
);
};
The wrapper does a lot for us. It:
- Runs our actual reducer (not a mock) to create a piece of state. It also returns a dispatcher function that can update said state.
- Returns any passed in child component wrapped in the Provider, with the state and dispatcher accessible as context.
We can then simulate a user clicking our button, and assert that search term has shown up in the UI. Again, because we are trying to test what users actually experience, we aren't worrying about mocking or spying on the dispatcher function. We are simply asserting that the UI updates as we expect it to.
// test.js
import {screen, fireEvent, waitFor} from "@testing-library/react"
import reducer from 'where/reducer/is'
import {SearchContext} from 'where/context/is'
import Search from 'where/search/is'
const wrapper = ({ children }) => {
const defaultState = {
searchTerm: null
}
const [state, dispatchToReducer] = useReducer(reducer, defaultState);
return (
<SearchContext.Provider value={state, dispatchToReducer}>
{children}
</SearchContext.Provider>
);
};
describe("Search", () => {
it("Updates to foo when the button is clicked", async () => {
render (<Search/>, {wrapper});
const fooButton = screen.getByRole('button', { name: 'Update term to Foo' });
fireEvent.click(fooButton);
await waitFor(() => {
screen.getByText('foo');
})
// Test further as necessary
})
})
TLDR;
Combining a reducer and context is a great pattern for managing React state. By using React Testing Library and a wrapper function, we can test the behavior of our reducer and context as it's actually experienced by users.