DEV Community

Cover image for Making sure you're using the correct query
Tim Deschryver
Tim Deschryver

Posted on • Originally published at timdeschryver.dev

Making sure you're using the correct query

Follow me on Twitter at @tim_deschryver | Subscribe to the Newsletter | Originally published on timdeschryver.dev.


One of the main focus points of Testing Library is accessibility.
We want to make sure tests are easy to write while keeping the user's experience in the first place.
Instead of selecting DOM elements via their id attributes or classes, Testing Library is using user-friendly queries.

In the latest versions of the Dom Testing Library, and thus also the Angular Testing Library (version 9), there have been improvements to these queries. One query in particular pops out, the (get|find|query)ByRole query.

From the docs:

This can be used to query every element that is exposed in the accessibility tree. With the name option you can filter the returned elements by their accessible name. This should be your top preference for just about everything. There's not much you can't get with this (if you can't, it's possible your UI is inaccessible). Most often, this will be used with the name option like so: getByRole('button', {name: /submit/i}). Check the list of roles.

The short answer to "which query should I use", is most of the time the *ByRole query. There are some cases that this query will not be able to find the element, luckily Testing Library provides a couple of ways to find the best "fallback" query to use.
Before we take a look at how to find alternatives, let's take a look at other benefits, besides accessibility, that *ByRole solves.

*ByRole provides a solution to

Querying broken text

The *ByText and *ByLabelText can't find elements that are broken into multiple elements.
For example, given the following HTML, it wasn't possible to query for the text "Hello World".

<h3>Hello <span>World</span></h3>

This can now be solved with the *ByRole query as:

// Before
screen.getByText(/hello world/i)
// |>Error: TestingLibraryElementError: Unable to find an element with the text: /hello world/i. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

// In version 9
screen.getByRole('heading', { name: /hello world/i })
// |> HTMLHeadingElement

Finding multiple elements

There were times that the query I used (mostly GetByText) resulted in more than one element.
This was causing some of our tests to be brittle, which is what we want to prevent.

<h3>Login now</h3>
<button>Login</button>

For example, to find the login button in the above HTML I would mainly see the following.

const logins = screen.getAllByText(/login/i)
const loginButton = logins[1]

This test would fail when a new "login" text would be added to the screen. Not good, as Testing Library aims to write maintainable tests.

A better approach to this problem was to use the selector option.

const loginButton = screen.getByText(/login/i, { selector: 'button' })

For this simple case, this approach is OK. But this could cause problems if CSS classes are used as the selector.
Because the *ByRole query is more explicit, it reduces the chance of finding multiple elements.

Also, if we take a look at the selector option it resembles the *ByRole query, it's just a poor man's implementation of it. The *ByRole query is also more robust as it isn't too specific, e.g. it's possible to find heading tags with 'heading' instead of the h1 to h6 tags.

Tips to find the correct query

Sadly the *ByRole isn't a silver bullet.
There are scenarios where the *ByRole query cannot be used. An example of this is query elements without a role, e.g. a password field. But you don't have to worry, the original queries can still be used and these are also making sure the application is accessible.

To find the query you need, there are several resources to use.

The docs

The Testing Library website has its own page to guide you which query to use. If you haven't read this, I would recommend it to read it. It's a short summary, but it will definitely help you to write better tests.

Testing Library Playground

Last week, Stephan Meijer created testing-playground in order to help finding the "best" available query. To use it, copy and paste the markup into the editor and click on the rendered elements or write the queries yourself.

Testing playground suggesting to use another query

A browser extension is also on the roadmap!

Testing Library configure method

I recently refactored the examples of Angular Testing Library to make use of the *ByRole query.
I did this by using the newly added option throwSuggestions to the configure method.
By default this is turned off and it's possible to turn it on on a global level, or on a per-query level.
As the name implies, it will throw an error if there's a better/safer query is available.

As an example, we can take the following HTML and as a test, a click on the increment button.

<button (click)="value = value - 1">Decrement</button>
<span data-testid="value">{{ value }}</span>
<button (click)="value = value + 1">Increment</button>
import { render, screen, fireEvent, configure } from '@testing-library/angular'
import { CounterComponent } from './counter.component'

beforeEach(() => {
  configure({
    throwSuggestions: true,
  })
})

test('renders the current value and can increment', async () => {
  await render(CounterComponent)

  const incrementControl = screen.getByText('Increment')
  fireEvent.click(incrementControl)
})

Because the throwSuggestions flag is turned on, we get the following error while we run the test.

TestingLibraryElementError: A better query is available, try this:
getByRole("button", {name: /increment/i})

Testing Library gives us a better query that we can copy and paste in the test to replace the "bad" query usage.

test('renders the current value and can increment', async () => {
  await render(CounterComponent)

  const incrementControl = screen.getByRole('button', { name: /increment/i })
  fireEvent.click(incrementControl)
})

Simple right?
To turn it on or off for a specific query we can do the following, which will have the same result.

test('renders the current value and can increment', async () => {
  await render(CounterComponent)

  const incrementControl = screen.getByText('Increment', { suggest: true })
  fireEvent.click(incrementControl)
})

Testing Library Messages

Testing Library provides a useful message when it doesn't find an element for the given *ByRole query.
See the example below, where every accessible element is logged with its corresponding selector.

TestingLibraryElementError: Unable to find an accessible element with the role "textbox" and name `/increment/i`
Here are the accessible roles:

    button:

    Name "Decrement":
    <button />

    Name "Increment":
    <button />

    Name "Login":
    <button />

    --------------------------------------------------
    heading:

    Name "Login now":
    <h3 />

    --------------------------------------------------

More tips for Angular Testing Library

screen

In previous versions of Angular Testing Library, it was only possible to query elements via the queries returned by the render method. In version 9 of Angular Testing Library, it exports a screen object which has all the queries available.
This has as benefit that your tests will become less bloated. A second benefit is that it also looks elements outside the component's HTML, this can in particular be helpful if you're using Angular Material because they will add overlays to the body of the document and outside the component tree.

import { screen } from '@testing-library/angular'

test('renders the current value and can increment', async () => {
-  const { getByRole } = await render(CounterComponent)
+  await render(CounterComponent)

-  const incrementControl = getByRole('button', { name: /increment/i })
+  const incrementControl = screen.getByRole('button', { name: /increment/i })
})

fireEvent

Just like screen, to fire events it's recommended to use the newly added fireEvent object instead of using the events returned by the render method. The events fired by fireEvent will also run a change detection cycle, just like before.

import { fireEvent } from '@testing-library/angular'

test('renders the current value and can increment', async () => {
-  const { click } = await render(CounterComponent)
+  await render(CounterComponent)

-  click(incrementControl)
+  fireEvent.click(incrementControl)
})

Using find queries

Angular Testing Library version 9, also patches the find queries and will invoke a change detection cycle before invoking the query. This can be helpful for asynchronous code that impacts the DOM. For example, if a text input is debounced and will render something afterward. Whereas before you had to manually call detectChanges after a given time.

- test('shows the load button after text input', fakeAsync(async () => {
+ test('shows the load button after text input', async () => {
  const { detectChanges } = await render(FixtureComponent, {
    imports: [ReactiveFormsModule],
  });

  userEvent.type(screen.getByRole('textbox'), 'What a great day!');

-  tick(200);
-  detectChanges();

-  screen.getByRole('button');
+  screen.findByRole('button');
-}));
+});

More tips

Kent C. Dodds wrote Common mistakes with React Testing Library a couple of weeks ago. Because the tests written for any popular framework or library resemble each other with Testing Library, most of the tips also apply to Angular Testing Library. So, give it a read for more useful tips for writing your tests with Testing Library!


Follow me on Twitter at @tim_deschryver | Subscribe to the Newsletter | Originally published on timdeschryver.dev.

Top comments (0)