Using Redux? React Testing Library Doesn't Care!
Why your state management doesn't matter to your test suite
If you're using Redux for state management, you might be wondering how to use React Testing Library to test your React code. The beauty of React Testing Library is that it doesn't care about implementation details! Even if you're using jQuery in your useEffect
, it won't judge you. You render your component and make assertions on the resulting DOM all the same.
Don't believe me? Let's take a look at testing a component using Redux versus one using React state.
Our Redux App Under Test
In Within Reach: Testing Lists with React Testing Library, we tested a simple component that manages a list of characters from The Office. We're going to use this as our system under test again because we all could use a little more Dwight Schrute in our lives.
I've refactored it to use Redux for state management to see how testing it might change. I'm using createSlice
from @reduxjs/toolkit
to build a reducer and actions for managing the character list.
import { createSlice } from '@reduxjs/toolkit';
const charactersSlice = createSlice({
name: 'characters',
initialState: [],
reducers: {
add(state, action) {
state.unshift(action.payload);
},
remove(state, action) {
state.splice(state.indexOf(action.payload), 1);
}
}
});
There's a buildStore
function that configures the store and loads it with a few characters.
function buildStore() {
const store = configureStore({
reducer: charactersSlice.reducer,
preloadedState: [
'Michael Scott',
'Dwight Schrute',
'Jim Halpert'
]
});
return store;
}
The OfficeCharacters
component pulls the characters from the store with useSelector
and renders them. It uses dispatch
to add or remove characters using the actions from our charactersSlice
.
function OfficeCharacters() {
const dispatch = useDispatch();
const characters = useSelector((state) => state);
const [newCharacter, setNewCharacter] = useState('');
function add(e) {
e.preventDefault();
dispatch(actions.add(newCharacter));
setNewCharacter('');
}
function deleteCharacter(character) {
dispatch(actions.remove(character));
}
return (
<>
<form onSubmit={add}>
<label htmlFor="newCharacter">New Character</label>
<input
type="text"
id="newCharacter"
value={newCharacter}
onChange={(e) => setNewCharacter(e.target.value)}
/>
<button>Add</button>
</form>
<ul>
{characters.map((character, i) => (
<li key={i} data-testid="character">
<span data-testid="name">{character}</span>{' '}
<button
type="button"
onClick={() => deleteCharacter(character)}
>
Delete
</button>
</li>
))}
</ul>
</>
);
}
Setting Up the Test Suite with a Redux Store
The first thing we want to do is take a look at our test render function. We're wrapping our component in a Redux Provider
, and that's really the only thing that needs to change.
It's a good thing we're using a test render function here because otherwise, we would have had to do this for every single test. Also, if you have multiple context providers in your tests other than just Redux, take a look at how to setup a GlobalTestProvider
to make this simpler across your whole app.
function renderOfficeCharacters() {
const store = buildStore();
render(
<Provider store={store}>
<OfficeCharacters />
</Provider>
);
return {
newCharacter: screen.getByLabelText('New Character'),
addButton: screen.getByText('Add'),
getCharacters() {
return screen.getAllByTestId('character').map((item) => ({
name: within(item).getByTestId('name').textContent,
deleteButton: within(item).getByText('Delete')
}));
}
};
}
One important note here is that we're building the store inside the test render function. We want a fresh store with every test. If not, we would be forced to run the tests in the same order every time, and life's too short for that frustration.
Writing the Tests
If you read the testing lists article, guess what? The tests are the exact same! There's not even a hint they're testing a Redux application (and that's the way it should be).
it('should add a character', () => {
const {
newCharacterInput,
addButton,
getCharacters
} = renderOfficeCharacters();
const pam = 'Pam Beesly';
// verify pam is NOT in the initial list
expect(
getCharacters().find(
(character) => character.name === pam
)
).toBeFalsy();
// add pam
fireEvent.change(
newCharacterInput,
{ target: { value: pam } }
);
fireEvent.click(addButton);
// verify pam is first in the list
expect(
getCharacters().findIndex(
(character) => character.name === pam
)
).toBe(0);
});
it('should delete a character', () => {
const { getCharacters } = renderOfficeCharacters();
const jim = 'Jim Halpert';
const deleteJim = getCharacters().find(
(character) => character.name === jim
).deleteButton;
// delete character
fireEvent.click(deleteJim);
// verify Jim is NOT in list
expect(
getCharacters().find(
(character) => character.name === jim
)
).toBeFalsy();
});
You can use Redux or plain-old React state, and largely, the tests don't even have to change.
Here's the CodeSandbox to view the full solution:
Summary
- React Testing Library doesn't care about implementation details, so testing with Redux is no different than just using React state.
- Setup a Redux store in your test render function, and you're all good.