DEV Community

loading...
Cover image for Don't use getByTestId πŸ™

Don't use getByTestId πŸ™

jacques_blom profile image Jacques Blom Originally published at jacquesblom.com ・7 min read

Building interfaces that are accessible to everyone has always been a bit of a black box to me. I do know, however, that not enough apps on the web are built in an accessible way.

Thankfully web standards include a lot of ways that you can make apps accessible. It can be complicated, though. And you can't always tell whether or not you've built something accessible.

One method that has changed how I build my interfaces is using getByRole from React Testing Library instead of getByTestId.

Note: getByRole actually comes from DOM Testing Library, meaning it's available in many of the Testing Libraries. This article will use React Testing Library as an example though.

There are also a few more accessible queries exposed by DOM Testing Library, but we'll focus on getByRole.

If you use getByRole as much as possible, over queries like getByTestId, you will write more accessible code.

Our non-accessible component

In our example, we have a todo list item that you can toggle checked by clicking on the checkbox. Try it out for yourself:

Our Task component is built like this:

If you try to focus on the checkbox with your keyboard to mark the task as completed you'll see that you can't. And it won't work with a screen reader either because we don't have any accessible labels in our UI.

Instead of trying to figure out how to make it accessible by studying the WAI-ARIA spec, let's try and do it using tests!

You can clone the repo to follow along, or just read further.

# Git clone
git clone git@github.com:jacques-blom/accessible-react-tests.git
git checkout tutorial-start

# Install dependencies
yarn

# To start the app
yarn start
Enter fullscreen mode Exit fullscreen mode

Then, run the tests in watch mode:

yarn test --watch
Enter fullscreen mode Exit fullscreen mode

Our current test

Let's first look at our current test:

// src/Task.test.tsx

it("toggles the task checked state", () => {
    render(<Task />)

    // Get the checkbox element
    const checkbox = screen.getByTestId("checkbox")
    const checkIcon = screen.getByTestId("checkIcon")

    // Click it
    userEvent.click(checkbox)

    // Expect the checkbox to be checked
    expect(checkIcon).toHaveStyle("opacity: 1")

    // Click it again
    userEvent.click(checkbox)

    // Expect the checkbox to be unchecked
    expect(checkIcon).toHaveStyle("opacity: 0")
})
Enter fullscreen mode Exit fullscreen mode

Our test doesn't test whether the app is accessible - it just tries to find an element (a div in our case) that has a specific data-testid prop.

Tests using getByTestId don't test accessibility. But we can change our tests to help us build accessible UI.

Step 1: Change our test

We're going to make our app more accessible by taking a TDD approach: first rewriting our test to use getByRole, then changing our code to make the test pass!

Let's rather test our app the way an assistive technology would query our UI. An assistive technology can't just look at our dark circle and determine that it's a checkbox - we actually need to tell it that it's a checkbox.

Instead of querying for the checkbox by testId, we're going to query it by an accessible role:

const checkbox = screen.getByRole("checkbox")
Enter fullscreen mode Exit fullscreen mode

This will try to find an element on the page that has identified itself as a checkbox.

You can find the role that best describes the interactive element you want to test by going through the full list of roles here.

Let's modify our test:

// src/Task.test.tsx

 it("toggles the task checked state", () => {
   render(<Task />);

-  const checkbox = screen.getByTestId("checkbox");
+  const checkbox = screen.getByRole("checkbox");
   const checkIcon = screen.getByTestId("checkIcon");

   // Checked
   userEvent.click(checkbox);
   expect(checkIcon).toHaveStyle("opacity: 1");

   // Not checked
   userEvent.click(checkbox);
   expect(checkIcon).toHaveStyle("opacity: 0");
 });
Enter fullscreen mode Exit fullscreen mode

You'll now see that our test fails. That's because our current element is just a div. DOM Testing Library even gives us a list of possible accessible elements on the page to help us along:

Test failing

Using getByRole forces you to have accessible elements in your UI.

Step 2: Change our code

Let's start by adding a checkbox input element to our Checkbox component.

const Checkbox = ({ checked, onChange }: CheckboxProps) => {
  return (
    <div
      data-testid="checkbox"
      className="checkbox"
      onClick={() => onChange(!checked)}
    >
      <img
        alt="check icon"
        src="/check.svg"
        style={{ opacity: checked ? 1 : 0 }}
        data-testid="checkIcon"
      />
+     <input type="checkbox" />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Use native HTML elements as much as possible to make life easy. You will have to deal with overriding browser styling, but the upside is that you get accessibility out of the box.

Next, instead of relying on the div's onClick event, we'll use the checkbox's onChange event:

const Checkbox = ({ checked, onChange }: CheckboxProps) => {
  return (
    <div
      data-testid="checkbox"
      className="checkbox"
-     onClick={() => onChange(!checked)}
    >
      <img
        alt="check icon"
        src="/check.svg"
        style={{ opacity: checked ? 1 : 0 }}
        data-testid="checkIcon"
      />
-    <input type="checkbox" />
+    <input type="checkbox" onChange={(event) => onChange(event.target.checked)} />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Our test is now passing again!

Test failing 2.png

But we now have an ugly checkbox breaking our design. 😒

Ugly checkbox.gif

So let's add some CSS to fix this.

// src/Task.scss

.checkbox {
  ...
  position: relative;

  > input[type="checkbox"] {
    // Make the input float above the other elements in .checkbox
    position: absolute;
    top: 0;
    left: 0;

    // Make the input cover .checkbox
    width: 100%;
    height: 100%;
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

Now the checkbox (almost) covers our styled checkbox.

Checkbox 1.png

We also need to remove the default margin that comes with the checkbox, and add overflow: hidden to .checkbox so that the checkbox isn't clickable outside our circular design:

// src/Task.scss

.checkbox {
  ...
  // Prevent the input overflowing outside the border-radius
  overflow: hidden;

  > input[type="checkbox"] {
    ...

    // Remove default margin
    margin: 0;
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

Checkbox 2.png

Finally, now that our checkbox input is fully covering our custom checkbox, we can hide it:

// src/Task.scss

.checkbox {
  ...
  > input[type="checkbox"] {
    ...

    // Hide the input
    opacity: 0;
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

Now we're back to our old design and behavior, and our checkbox is (almost) accessible. Try tabbing to it and hitting the spacebar to toggle the checked state:

Back to old behavior.gif

I say it's almost accessible because someone using keyboard navigation instead of a mouse can't see if the checkbox is focused. So let's add a focus state:

// src/Task.scss

.checkbox {
  ...
  // Show an outline when the input is focused
  &:focus-within {
    box-shadow: 0 0 0 1px #fff;
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

We're using :focus-within on .checkbox to apply a style to it if anything inside it is focused:

Focus state.gif

Always make sure your UI can be navigated using a keyboard. Accessible HTML elements help but make sure you include focus states if you override the default browser outline style.

Finally, we want to label our checkbox with something meaningful so that screen readers can tell the user what the checkbox is for.

We can either add a <label> element, or we can use the aria-label prop. Since we don't want our label to be visible, we'll go for the latter:

// src/Task.tsx

<input
    type="checkbox"
    onChange={(event) => onChange(event.target.checked)}
    // Add an aria-label
    aria-label={checked ? "mark unchecked" : "mark checked"}
/>
Enter fullscreen mode Exit fullscreen mode

To make the label as helpful as possible, we're showing a different label depending on whether the task is checked.

We can now modify our test to find a checkbox with that label, to make sure our label is set. To do this we pass a name parameter to our getByRole call:

const checkbox = screen.getByRole("checkbox", { name: "mark as checked" })
Enter fullscreen mode Exit fullscreen mode

Make sure your UI has helpful labels that can be used by screen readers. Query your elements by those labels in your tests to make sure they're there.

But we need to find it by a different label depending on whether the checkbox is checked or not. We can refactor things a bit to make this easy.

Our final test looks like this:

And here is our final, accessible UI:

What did we improve here in our test?

  1. Added a getCheckbox function to fetch our checkbox by the checked or unchecked label to clean things up.
  2. Expect the checkbox to be checked, instead of checking whether our styled check is visible or not. This makes our code more resilient to change...

How getByRole makes your tests resilient to changing code

Because we are now testing our code in a way that it will be used (find a checkbox input), rather than the way it's built (find an element with a specific test ID), our tests are more resilient to refactoring.

If we completely changed how our UI was built, even if we removed all our UI altogether and just kept the checkbox input, our tests will still pass.

I recently refactored a form from React Hook Form to Formik, and all my tests still worked, even though the underlying code was totally different. Plus, because of how I wrote my tests, my form was completely accessible!


What we've learned

  1. Using getByRole in your tests will test whether your UI is accessible.
  2. getByRole makes your code resilient to refactoring.
  3. When refactoring your UI to make it accessible, use a TTD approach. Write failing tests, then get your tests to pass.
  4. UI is more accessible when it can be easily navigated using a keyboard and has meaningful accessible labels.
  5. Use native browser elements to get out-of-the-box accessibility.

Further reading

If you're interested in testing and accessibility, I am planning on releasing a bunch more content about it. Click here to subscribe and be notified when I release new content.

Also feel free to Tweet at me if you have any questions.

If you found this post helpful, and you think others will, too, please consider spreading the love and sharing it.

Discussion (2)

pic
Editor guide
Collapse
jackmellis profile image
Jack • Edited

Really great article. I've been a testing advocate for a long time and only really recently started to rethink my white box approach. My only issue is there is a clear distinction between proper unit tests that should be white boxes, and component testing which are really more like integration tests. If I fundamentally changed my code and my unit tests all passed I'd be concerned that my tests were wrong!

Collapse
jacques_blom profile image
Jacques Blom Author

Thanks, Jack! πŸ™Œ

Definitely agree with that. The approach I've taken with my tests is to mostly write integration tests for my UIs. I like that you're testing the way the user experiences the app. If a test that tests a user's experience breaks (if it's not a false positive), you know a real user is likely going to experience that issue.

I definitely see the value in unit tests, though. I write a bunch of tests for individual functions and hooks in my code that are important to the app functioning, and where that function's behavior isn't easy to test with an integration test. Of course, the downside here is that you're testing implementation details and your code changing will break your tests - but that's kind of the point like you said. 😊