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.
  • 26

    This website collects cookies to deliver better user experience

    Within Reach: Testing Lists with React Testing Library