Photo by Florian Olivo on Unsplash
Derek Davis

Wary of the Query: Targeting Conditional Elements with React Testing Library

How one JavaScript feature makes querying conditional elements predictable

man walking on tight rope
Photo by Loic Leray

One problem I frequently run into when testing is that conditionally rendered parts of the UI can be difficult to target with React Testing Library. As I'm planning out my tests, I continually ask myself questions like:

  • When am I able to query this element?
  • Is the query stale?
  • Do I need to query it again?

It all feels like a tight rope act to get it right.

Typically I get the answers to those questions when the Unable to find an element ...  error pops up in the terminal. Then I end up having to sift through debug output to check if React Testing Library is lying to me or not (it never is).

If you've ever found yourself in this situation, I've got a solution you'll find useful.

The Basic Test Setup

We're going to be writing a test for the PersonScreen component. It's just a form with a name field and an add button.

function PersonScreen() {
  const [name, setName] = useState('');

  function add(e) {
    // ...
  }

  return (
    <form onSubmit={add}>
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
        />
      </div>
      <button>Add</button>
    </form>
  );
}

When I write a test suite for a component, the first thing I do is make a render{ComponentName} function at the top of my describe. I call this a test render function. For the PersonScreen component, my render function would look something like this:

import { render, screen } from '@testing-library/react';
import PersonScreen from './PersonScreen';

describe('PersonScreen', () => {
  function renderPersonScreen() {
    render(<PersonScreen />);

    return {
      name: screen.getByLabelText('Name'),
      add: screen.getByText('Add')
    };
  }

  // ... tests ...
});

This way all of the element querying is done in one centralized location, the tests are isolated, and they're easier to read.

But sometimes we can run into a problem with this approach.

Conditionally Rendered UI

Let's change this component so the user can hide and show the form with a toggle button.

function PersonScreen() {
  const [name, setName] = useState('');
  const [show, setShow] = useState(false);

  function add(e) {
    // ...
    // close the form after add
    setShow(false);
  }

  return (
    <section>
      <button onClick={() => setShow((s) => !s)}>
        Toggle Form
      </button>
      {show && (
        <form onSubmit={add}>
          <div>
            <label htmlFor="name">Name</label>
            <input
              id="name"
              type="text"
              value={name}
              onChange={(e) => setName(e.target.value)}
            />
          </div>
          <button>Add</button>
        </form>
      )}
    </section>
  );
}

Since the form is no longer shown when the first getByLabelText runs, it's going to produce an error in the console:

TestingLibraryElementError: Unable to find a label with the text of: Name

queryByLabelText would get rid of the error, but when we try to access name, it'll be null. What we need is a way to query the form elements after they are shown while still keeping their queries centralized.

The Function Approach

One way we can fix this is by having a getForm() function.

function renderPersonScreen() {
  render(<PersonScreen />);

  function getForm() {
    return {
      name: screen.queryByLabelText('Name'),
      add: screen.queryByText('Add')
    };
  }

  return {
    toggleForm: screen.getByText('Toggle Form'),
    getForm
  };
}

We call it every time we want to access the form controls.

it('should close the form after add', () => {
  const { toggleForm, getForm } = renderPersonScreen();

  // open the form
  fireEvent.click(toggleForm);

  // get the form now that it's open
  let form = getForm();

  // fill out the form
  fireEvent.change(form.name, { target: { value: 'Derek' } });

  // click add
  fireEvent.click(form.add);

  // get the form again since it's now hidden
  form = getForm();

  // the form should now be closed
  expect(form.name).toBeNull();
});

This works, but it's annoying to have to call getForm() to access the controls on it, and then after something changes, call it again to get the most up to date results.

We can do better.

Property Getters

Let's make a few tweaks to the render function. Instead of getForm(), we have a form property with name and add property getters.

function renderPersonScreen() {
  render(<PersonScreen />);

  return {
    toggleForm: screen.getByText('Toggle Form'),
    form: {
      get name() {
        return screen.queryByLabelText('Name');
      },
      get add() {
        return screen.queryByText('Add');
      }
    }
  };
}

Now our test is even more simple:

it('should close the form after add', async () => {
  // now we destucture `form`, and we don't
  // have to call getForm() anymore
  const { toggleForm, form } = renderPersonScreen();

  // open the form
  fireEvent.click(toggleForm);

  // fill it out
  fireEvent.change(form.name, { target: { value: "Derek" } });
  
  expect(form.name.value).toBe("Derek");

  // click add
  fireEvent.click(form.add);

  // the form should now be closed
  // no need to requery `form.name`!
  expect(form.name).toBeNull();
});

With property getters, we get to use dot notation, we don't have to call getForm() to access our controls, and we don't have to worry about form being stale. We can have our cake and eat it too.

That's more like it.

Note

One thing to note with this approach is that we can't destructure the properties when using getters. The act of destructuring will call the getters, and then we're back to the problem we had in the first place.

We can fix it by grouping the conditional elements in an object like we did in the above examples or not destructuring at all (and sometimes that's not such a bad thing).

Summary

  • Targeting conditionally shown elements inside a centralized render function can be difficult in React Testing Library.
  • Use JavaScript property getters to ensure your element queries aren't stale and improve the testing experience.
← Back to home

You've got mail JavaScript.

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