Photo by Florian Olivo on Unsplash
Derek Davis

Using Redux? React Testing Library Doesn't Care!

Why your state management doesn't matter to your test suite

ducks

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.
← Back to home

You've got mail JavaScript.

Sign up to get notified when I put out new content!