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.
Testing 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 with react-testing-library
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 the email is valid', () => {
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()
})
})
describe('when the email is not valid', () => {
it('should disable the submit button', () => {
const input = screen.getByRole('textbox', { name: /email/i })
userEvent.type(input, 'not an email')
expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled()
})
})
})
There are 2 main issues with this approach to writing a test:
- The selector is repeated several times, which weighs down the code and distracts from the behaviour you want to test.
- 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 with react-testing-library
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 the email is valid', () => {
it('should enable the submit button', () => {
const input = getEmailTextbox()
userEvent.type(input, 'valid@email.com')
expect(getSubmitButton()).toBeEnabled()
})
})
describe('when the email is not valid', () => {
it('should disable the submit button', () => {
const input = getEmailTextbox()
userEvent.type(input, 'not an email')
expect(getSubmitButton()).toBeDisabled()
})
})
})
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 the email is valid', () => {
it('should enable the submit button', () => {
const input = screen.getByRole(...emailTextboxSelector)
userEvent.type(input, 'valid@email.com')
expect(screen.getByRole(...submitButtonSelector)).toBeEnabled()
})
})
describe('when the email is not valid', () => {
it('should disable the submit button', () => {
const input = screen.getByRole(...emailTextboxSelector)
userEvent.type(input, 'not an email')
expect(screen.getByRole(...submitButtonSelector)).toBeDisabled()
})
})
})
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 the email is valid', () => {
it('should enable the submit button', () => {
const input = ui.emailTextbox.get()
userEvent.type(input, 'valid@email.com')
expect(ui.submitButton.get()).toBeEnabled()
})
})
describe('when the email is not valid', () => {
it('should disable the submit button', () => {
const input = ui.emailTextbox.get()
userEvent.type(input, 'not an email')
expect(ui.submitButton.get()).toBeDisabled()
})
})
})
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
with testing-library-selectors?
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 front end that eliminates this issue altogether!
Les articles en vedette
Readability of Tests, DSL and Refactoring
Automated Testing... Slower Than What Exactly?
Testing React: Avoid Nesting
Soyez les premiers au courant des derniers articles publiés
Abonnez-vous à l’infolettre pour ne jamais rater une nouvelle publication de notre blogue et toutes nos nouvelles.