Reactive Stack

Testing React Components With Accessibility Roles

Cover Image for Testing React Components With Accessibility Roles
Benoit Tremblay
Benoit Tremblay

Good tests are about building scenarios that are as close as possible to the user and asserting if what the user see is what the scenario expect. One problem I see every day is people writing tests that don't actually check what the user expect, but rather if the code is executing in an exact way that nobody outside the developer understand.

Good tests are based on the behavior, not the implementation!

Accessibility Roles to the Rescue!

You might not realize it, but accessbility roles are a huge asset to target the content in your page like the user. It also has the added benefit of making you aware of the accessibility of your app.

React Testing Library is making it super easy to target those by using screen.getByRole('<role>'). You might not be aware, but you already have a bunch of roles in your page that you can use without any change!

Let's start with a simple component and we will work our way up on how to do it.

Our Test Component

We are going to build a simple counter with an increase and a decrease button. It will includes three elements:

  • The count in a input box.
  • The increase button
  • The decrease button

We will start with the count initial value. Let's do it by writing our tests first TDD style!

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

describe('Counter text', () => {
  it('has initial value as 0', () => {
    render(<Counter />);

    const currentCount = screen.getByRole('textfield');
    expect(currentCount).toBeInTheDocument();
    expect(currentCount.value).toBe(0);
  });
});
// Counter.jsx
export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <>
      <label htmlFor="count">Current count</label>
      <input id="count" name="count" value={count} readOnly />
    </>
  );
}

In this example, we are using the textfield role to find the current count and we make sure the input has the correct initial value.

Testing a Button

Now it's time to add the increase button. We will click the increase button and we will make sure that the count is increasing at 1. This time, we will also simulate a click on the button.

// Counter.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';

describe('Increase button', () => {
  it('increase the value when clicked', () => {
    render(<Counter />);

    // Find the increase button
    const increaseButton = screen.getByRole('button');
    fireEvent.click(increaseButton);

    // Check if the count is now 1
    const currentCount = screen.getByRole('textfield');
    expect(currentCount).toBeInTheDocument();
    expect(currentCount.value).toBe(1);
  });
});
// Counter.jsx
export default function Counter() {
  const [count, setCount] = useState(0);

  const onIncrease = () => {
    setCount(count + 1);
  };

  return (
    <>
      <label htmlFor="count">Current count</label>
      <input id="count" name="count" value={count} readOnly />
      <button type="button" onClick={onIncrease}>
        Increase
      </button>
    </>
  );
}

All we did again is find the button by using the button role and click it. Don't worry it will get harder and harder.

A Second Button

Adding the decrase button will be a little bit more tricky. We don't have a way to find the correct one just with the role name because it will not know which one to pick. This is why you should not only search for your element using the role, but also the name associated with the role. Let's do the decrease button.

// Counter.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';

describe('Decrease button', () => {
  it('decrease the value when clicked', () => {
    render(<Counter />);

    const decreaseButton = screen.getByRole('button', { name: 'Decrease' });
    fireEvent.click(decreaseButton);

    const currentCount = screen.getByRole('textfield', {
      name: 'Current count',
    });
    expect(currentCount).toBeInTheDocument();
    expect(currentCount.value).toBe(-1);
  });
});
// Counter.jsx
export default function Counter() {
  const [count, setCount] = useState(0);

  const onIncrease = () => {
    setCount(count + 1);
  };

  const onDecrease = () => {
    setCount(count - 1);
  };

  return (
    <>
      <label htmlFor="count">Current count</label>
      <input id="count" name="count" value={count} readOnly />
      <button type="button" onClick={onIncrease}>
        Increase
      </button>
      <button type="button" onClick={onDecrease}>
        Decrease
      </button>
    </button>
  );
}

The Most Common Roles

The most common roles I've been using are the following:

  • link: A link with the text as the name.
  • img: An image.
  • form: A form to fill.
  • button: A regular or submit button with the text of the button as name.
  • textfield: Simply an input box with the label as the name. If the input is a password, you will have to use the label because the role will not be available in that special case.
  • checkbox: A simple checkbox with the label as name.
  • navigation: A navigation using the <nav /> tag or by specifying the role. You can specify the name by using aria-label property.
  • list / listitem: <ul /> and <ol /> and the <li />.
  • menu / menuitem: A menu like on a desktop app.
  • dialog: A modal on top of the app.

For a full list, you can consult all the WAI-ARIA roles on the MDN Web Docs.

What If I Can't Use The Role?

You cannot target every element in a page by using only the accessibility roles and his name. You should always start with that, but when you can't there is a few alternative.

Your first reflect when there is no role is to use a label. If your element does not have a label on it yet, it's a good thing for blind people to add it anyway. All you need to do is use aria-label.

const Component = () => <div aria-label="Complex thing"></div>;
const complexThing = screen.getByLabelText('Complex thing');

For an image, you can use the alternative text:

const Component = () => <img src="/test.png" alt="My image" />;
const image = screen.getByAltText('My image');

Sometimes, you also want to find text in your page and you really cannot target it by any role and adding a label would be useless because... well it's already text and they represent nothing else. In those case you can find the element by using the text, but only if it does not have a role or a label:

const Component = () => <div>One notification pending</div>;
const notificationText = screen.getByText('One notification pending');

// If you don't want the full text to match, you can use a regex
const natificationTextWithRegex = screen.getByText(/notification pending/);

If you have gone through all of that and you still cannot target the element that you need, you really need to ask yourself why this element is not accessible. Only as a last resort when nothing else would work, you can add a data-testid attribute and find the element using it.

const Component = () => (
  <div data-testid="stuff">Really not accessible stuff</div>
);
const stuff = screen.getByTestId('stuff');

Accessbility Is There To Help You

Accessibility is your friend, use it! If you are already building apps by using the rich HTML5 components, chances are they are already there for you. Testing is a great opportunity to help you learn more about accessibility and at the same time make the life of some people a lot easier.

If you want to learn more, you can read my Top 5 Trends in 2021 for React Developers.

Want More Content About React.js?
Subscribe to Our Newsletter

* indicates required

We will never spam you.