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 likegetByTestId
, 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
Then, run the tests in watch mode:
yarn test --watch
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")
})
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")
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");
});
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:
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>
);
};
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>
);
};
Our test is now passing again!
But we now have an ugly checkbox breaking our design. π’
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%;
}
...
}
Now the checkbox (almost) covers our styled checkbox.
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;
}
...
}
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;
}
...
}
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:
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;
}
...
}
We're using :focus-within
on .checkbox
to apply a style to it if anything inside it is focused:
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"}
/>
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" })
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?
- Added a
getCheckbox
function to fetch our checkbox by the checked or unchecked label to clean things up. - 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
- Using
getByRole
in your tests will test whether your UI is accessible. -
getByRole
makes your code resilient to refactoring. - When refactoring your UI to make it accessible, use a TTD approach. Write failing tests, then get your tests to pass.
- UI is more accessible when it can be easily navigated using a keyboard and has meaningful accessible labels.
- 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.
Top comments (2)
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!
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. π