Within Reach: Testing Lists with React Testing Library

When it comes to targeting elements with React Testing Library, it's easy when there's only one. Throw in a getByText or getByRole, and you're off to the races.

Have a list of something? Then you get hit with this:

Found multiple elements with the text of: ${text}

You now have some more decisions to make.

Let's get into some examples of how to test your component that's rendering a list of elements.

The Component Under Test

For demonstrating these concepts, we're going to be testing a simple component that manages a list of characters from The Office.

It only does a few things:

  • shows a list of characters
  • adds characters to the front of the list
  • deletes characters
function OfficeCharacters() {
  const [characters, setCharacters] = useState([
    'Michael Scott',
    'Dwight Schrute',
    'Jim Halpert'
  ]);
  const [newCharacter, setNewCharacter] = useState('');

  function add(e) {
    e.preventDefault();

    setCharacters((prev) => [newCharacter, ...prev]);
    setNewCharacter('');
  }

  function deleteCharacter(character) {
    setCharacters(
      (prev) => prev.filter((c) => c !== 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 Render Function

The testing pattern I'm a big fan of involves setting up a test render function (read more about it in Solving the Maintenance Nightmare). In short, it abstracts the element-targeting logic and keeps the individual tests focused on the scenarios.

Beginning with the Form

The form part of the component will be the easy part. Here's what we have:

<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>

Let's create our test render function and add those elements to the return.

describe("OfficeCharacters", () => {
  function renderOfficeCharacters() {
    render(<OfficeCharacters />);

    return {
      newCharacterInput:
        screen.getByLabelText('New Character'),
      addButton: screen.getByText('Add'),
    };
  }
});

Querying the List with within

For the next part, we tackle the list.

<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>

Now, we could use a getAllBy* query to get all the names and then another query to get all the delete buttons. But then we'd have to stitch them back together based on index. Yeah... Let's not do that.

Instead, let's use a handy function from React Testing Library called within.

We can use it to query within a container. There's a variety of ways we could specify the container for each list item, but I like to use a data-testid to signal that it's only needed for testing.

<li key={i} data-testid="character">
  ...
</li>

In our test render function, we can now loop over the elements with data-testid="character" and get the name and delete button for each one.

return {
  newCharacterInput: 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')
      }));
  }
};

Testing Add

When testing add (or anything really), we need to first verify the initial state is what we expect. If we assume something is or isn't there and eventually that changes, we could end up getting a false positive.

With the test render function in place, everything else becomes straight-forward because we don't have any query logic directly in the test.

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
    )
  ).not.toBeTruthy();

  // 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);
});

Testing Delete

For delete, we just get the delete button for a particular character, click it, verify the character is no longer there, and we're done!

it('should delete a character', () => {
  const { getCharacters } = renderOfficeCharacters();

  const jim = 'Jim Halpert';

  // get the delete button for Jim
  const deleteJim = getCharacters().find(
    (character) => character.name === jim
  ).deleteButton;

  // delete Jim
  fireEvent.click(deleteJim);

  // verify Jim is NOT in list
  expect(
    getCharacters().find(
      (character) => character.name === jim
    )
  ).not.toBeTruthy();
});

Here's the CodeSandbox to view the full solution:

Summary

  • For testing lists, put a data-testid on the repeating container, and use within to query the individual elements.
  • All of your tests can make assertions off of the array property returned from the test render function.

22