Simplify your tests with testing-library-selector

Développement sept. 29, 2021

Disclaimer: This review assumes some familiarity with react-testing-library. If this is not your case, I recommend consulting the official documentation before reading this piece.

At Nexapp, we've been using Testing Library to write our frontend tests for quite some time. It’s a significant improvement over some of the other testing solutions we have used in the past. However, one of the problems we have encountered over time is the repetition of the selectors we use for our tests.

In this blog post, we will explore 3 solutions to this problem, including the testing-library-selector library.


Terminology

Before getting down to business, it’s important to clarify the terminology of some of the concepts related to Testing Library.

  • Query: testing-library offers this feature to get an element from the DOM. This includes all combinations of "get, query, find", "byRole, byText, etc.", and "all" variants.
  • Selector: How a query is used to get a specific DOM element (e.g. getByRole('button', { name: /click me/i }), findByText(/created successfully/i))

The issue

If you use react-testing-library to write your frontend tests, one of the annoyances you may encounter is the need to repeat your selectors several times in the same test. Here is a simple example:

import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
describe('my awesome form', () => {
  describe('when email is provided', () => {
    it('should enable the submit button', () => {
      const input = screen.getByRole('textbox', { name: /email/i })
      userEvent.type(input, 'not an email')
      expect(screen.getByRole('button', { name: /submit/i })).toBeEnabled()
    })
  })
  
  describe('when email is not provided', () => {
    it('should enable the submit button', () => {
      const input = screen.getByRole('textbox', { name: /email/i })
      userEvent.type(input, 'valid@email.com')
      expect(screen.getByRole('button', { name: /submit/i })).toBeEnabled()
    })
  })
})

There are 2 main issues with this approach to writing a test:

  1. The selector is repeated several times, which weighs down the code and distracts from the behaviour you want to test.
  2. If the selector changes, then it must be changed in all places where it’s used. Of course, this process is prone to errors.

There are different ways to solve this problem. For example, we could write simple functions that wrap the selectors, or we could abstract the parameters of the selectors into constants that we then spread when calling the query. Let's look at these two solutions more closely.

Writing functions

This solution is very simple and is usually the first one that comes to mind. Let's look at an example:

import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

function getEmailTextbox() {
  return screen.getByRole('textbox', { name: /email/i })
}

function getSubmitButton() {
  return screen.getByRole('button', { name: /submit/i })
}

describe('my awesome form', () => {
  describe('when data is valid', () => {
    it('should enable the submit button', () => {
      const input = getEmailTextbox()
      userEvent.type(input, 'not an email')
      expect(getSubmitButton()).toBeDisabled()
    })
  })
  
  describe('when the data is valid', () => {
    it('should enable the submit button', () => {
      const input = getEmailTextbox()
      userEvent.type(input, 'valid@email.com')
      expect(getSubmitButton()).toBeEnabled()
    })
  })
})

getBy

At first glance, this solution works very well! The problem is when you have to use different types of queries for the same element (getBy vs. findBy vs. queryBy and their respective "all" variants). We must then either duplicate the functions or add a parameter to specify the desired type of query. Not exactly the end of the world, but a nuisance nonetheless.

Abstract parameters into constants

In this case, we are still using the testing-library functions directly, but we no longer explicitly write the parameters on each call. Again, an example:

import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

const emailTextboxSelector = ['textbox', { name: /email/i }]
const submitButtonSelector = ['button', { name: /email/i }]

describe('my awesome form', () => {
  describe('when data is valid', () => {
    it('should enable the submit button', () => {
      const input = screen.getByRole(...emailTextboxSelector)
      userEvent.type(input, 'not an email')
      expect(screen.getByRole(...submitButtonSelector)).toBeDisabled()
    })
  })
  
  describe('when the data is valid', () => {
    it('should enable the submit button', () => {
      const input = screen.getByRole(...emailTextboxSelector)
      userEvent.type(input, 'valid@email.com')
      expect(screen.getByRole(...submitButtonSelector)).toBeEnabled()
    })
  })
})

This solution is also rather simple, but it still has some drawbacks. In particular, the spread syntax to pass dynamic parameters to a function is a bit more advanced and could confuse novice developers. Also, the selectors’ parameters are completely separated from the queries themselves, making their usefulness a little less obvious. The advantage over the first solution is that it’s easily usable with all types of queries that testing-library provides.

Also, if you use typescript, this solution has the disadvantage that each selector must be explicitly typed, either via a specific type or, more simply, via a const assertion. Otherwise, Typescript will always assume the array type (number[]), rather than the tuple type ([number]) which is required to use the spread without error.

"There's a library for that"

The solutions presented above are very nice, but they both involve some clarity and/or reusability issues. So I would like to propose a third solution: testing-library-selectors. First, an example:

import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { byRole } from 'testing-library-selectors'

const ui = {
  emailTextbox: byRole('textbox', { name: /email/i }),
  submitButton: byRole('button', { name: /submit/i })
}

describe('my awesome form', () => {
  describe('when data is valid', () => {
    it('should enable the submit button', () => {
      const input = ui.emailTextbox.get()
      userEvent.type(input, 'not an email')
      expect(ui.submitButton.get()).toBeDisabled()
    })
  })
  
  describe('when the data is valid', () => {
    it('should enable the submit button', () => {
      const input = ui.emailTextbox.get()
      userEvent.type(input, 'valid@email.com')
      expect(ui.submitButton.get()).toBeEnabled()
    })
  })
})

testing-library-selectors somewhat combines the advantages of the previous two solutions:

  • The code is very simple and clear because everything goes through simple, yet concrete functions
  • Selectors are perfectly reusable because they are not linked to a specific query

Of course, there are some drawbacks that are important to mention.

How to use within?

within is a testing-library utility that allows you to specify a component within which to execute the query (rather than the entire page). For example, it can be used to access components in a specific table row:

const user1Row = screen.getByRole('row', { name: /user 1 name/i})
const statusIcon = within(user1Row).getByRole('img', { name: /valid/i })

Unfortunately, the testing-library-selectors syntax doesn’t allow us to use this utility, but we can still get the same behaviour by defining the component as a parameter of the request.

const user1Row = ui.user1Row.get()
const statusIcon = ui.statusIcon.get(user1Row)

The behaviour is the same, but we lose the within keyword’s clarity at the beginning of the query.

What if my selector is dynamic?

This question is valid for the 3 solutions listed, but the answer is similar in all cases: you just need to add a parameter (and add a function if you don't already have one).

const selector = (value) => byText(value)

it('should render dynamic text', () => {
  const text = getADynamicString()
  render(() => <div>{text}</div>)
  selector(text).get()
})

Is the library compatible with eslint-plugin-testing-library?

eslint-plugin-testing-library is an eslint plugin that adds some rules to ensure proper use of testing-library, especially when it comes to asynchronous requests. Unfortunately, this plugin is not compatible with testing-library-selectors. However, at the time of writing, this plugin has only 3 rules. Therefore it is not a huge loss, but it is indeed something to consider if you use (or would like to use) this plugin.

So which solution should I choose?

The answer to this question will depend on your context and the concessions you and your team are willing to make. I like to use testing-library-selectors, but if you prefer to avoid adding yet another dependency to your project, the first two solutions may also suit your needs.


What do you think? Please let me know if this piece was helpful to you. I'd also be happy to hear about other solutions you may have found to this problem. Perhaps you have a different way of testing your frontend that eliminates this issue altogether!

Mots clés

Étienne Lévesque

Gradué de l'Université de Sherbrooke en 2020, je m'intéresse à l'informatique et la programmation depuis l'âge de 15 ans. Je trippe sur les bonnes pratiques, la prog. fonctionnelle et l'architecture.