I recently began using Jest
and React Testing Library
(rtl). This article is what I would have wanted to read after I first started. It is not a tutorial but a series of solutions to specific problems you will run into. I structured everything in 4 blocks:
-
queries
matchers
setup functions
-
mocks
This article covers the first 3 blocks. I will write about mocking
in a later series. You can find all the code in this article on github.
1. Queries
React Testing Library
provides queries and guidelines on how to use them. Here are some tips and tricks regarding queries.
1.1 you can still use querySelector
Before you start adding data-testid
to everything, remember that Jest
is just javascript. The expect()
function expects an DOM element to be passed in. So you can use querySelector
. Call querySelector
on container
, returned by the render
function.
// the component
function Component1(){
return(
<div className="Component1">
<h4>Component 1</h4>
<p>Lorum ipsum.</p>
</div>
)
}
// the test
test('Component1 renders', () => {
// destructure container out of render result
const { container } = render(<MyComponent />)
// true
// eslint-disable-next-line
expect(container.querySelector('.Component1')).toBeInTheDocument()
})
querySelector
is good for testing your generic html. But, always use specific rtl queries if possible: f.e. inputs, buttons, images, headings,...
Beware, using querySelector
will make eslint yell at you, hence the // eslint-disable-next-line
.
1.2 How to query multiple similar elements
Let's take a component with 2 buttons, add and subtract. How do you query these buttons? You have 2 options:
// the component
function Component2(){
return(
<div className="Component2">
<h4>Component 2</h4>
<button>add</button>
<button>subtract</button>
</div>
)
}
1.2.1 Use the options parameter on query
Most of the rtl queries have an optional options
parameter. This lets you select the specific element you want. In this case we use name
.
test('Component2 renders', () => {
render(<Component2 />)
// method 1
expect(screen.getByRole('button', { name: 'subtract' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'add' })).toBeInTheDocument()
})
1.2.2 Use the getAll query
React Testing Library
has built-in queries for multiple elements, getAllBy...
. These queries return an array.
test('Component2 renders', () => {
render(<Component2 />)
// method 2
const buttons = screen.getAllByRole('button')
expect(buttons[0]).toBeInTheDocument()
expect(buttons[1]).toBeInTheDocument()
})
1.3 Find the correct role
Some html elements have specific ARIA roles. You can find them on this w3.org page. (Bookmark tip) Some examples:
// the component
function Component3(){
const [ count, setCount ] = useState(0)
return(
<div className="Component3">
<h4>Component 3</h4>
<input type="number" value={count} onChange={(e) => setCount(parseInt(e.target.value))} />
</div>
)
}
// the test
test('Component3 renders', () => {
render(<Component3 />)
// get the heading h3
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument()
// get the number input
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
})
2. matchers
The core of a test is the matcher, the expect()
statement followed by a .toBe...
or .toHave...
. This is Jest
, it's not React Testing Library
. Invest some time in getting to know these matchers (another bookmark tip).
On top of these Jest
matchers, there is an additional library: jest-dom
(yes, more bookmarks).
jest-dom
is a companion library for Testing Library that provides custom DOM element matchers forJest
.
So, jest-dom
provides more matchers and they are quite handy. Let's look at some of them in action. I wrote some tests in jest
followed by the jest-dom
equivalent.
// the component
function Component4(){
const [ value, setValue ] = useState("Wall-E")
return(
<div className="Component4">
<h4>Component 4</h4>
<label htmlFor="movie">Favorite Movie</label>
<input
id="movie"
value={value}
onChange={(e) => setValue(e.target.value)}
className="Component4__movie"
style={{ border: '1px solid blue', borderRadius: '3px' }}
data-value="abc" />
</div>
)
}
test('Component4 renders', () => {
render(<Component4 />)
const input = screen.getByLabelText('Favorite Movie')
const title = screen.getByRole('heading', { level: 4 })
// we already used .toBeInTheDocument(), this is jest-dom matcher
expect(input).toBeInTheDocument()
// test for class with jest
expect(input.classList.contains('Component4__movie')).toBe(true)
// test for class with jest-dom
expect(input).toHaveClass('Component4__movie')
// test for style with jest
expect(input.style.border).toBe('1px solid blue')
expect(input.style.borderRadius).toBe('3px')
// test for style with jest-dom
expect(input).toHaveStyle({
border: '1px solid blue',
borderRadius: '3px',
})
// test h4 value with jest
expect(title.textContent).toBe("Component 4")
// test h4 value with jest-dom
expect(title).toHaveTextContent("Component 4")
// test input data attribute with jest
expect(input.dataset.value).toEqual('abc')
// test input data attribute with jest-dom
expect(input).toHaveAttribute('data-value', 'abc')
})
3. render setups
Writing tests for components can be repetitive and time consuming. Let's take a look at how a setup function can make your code more DRY (don't repeat yourself).
We will be testing a component that displays a value. It has an add and a subtract button and takes an increment (number) as prop. The buttons add or subtract the increment from the value.
// the component
function Component5({ increment }){
const [ value, setValue ] = useState(0)
return(
<div className="Component5">
<h4>Component 5</h4>
<div className="Component5__value">{value}</div>
<div className="Component5__controles">
<button onClick={e => setValue(prevValue => prevValue - increment)}>subtract</button>
<button onClick={e => setValue(prevValue => prevValue + increment)}>add</button>
</div>
</div>
)
}
We will run 3 tests on this component: test if the component renders, test if buttons work, test increment. We will first run not DRY code. After that, we will refactor the tests with a setup function.
// the tests
describe('Component5 (not DRY)', () => {
test('It renders correctly', () => {
const { container } = render(<Component5 increment={1} />)
// get the elements
// eslint-disable-next-line
const valueEl = container.querySelector('.Component5__value')
const subtractButton = screen.getByRole('button', { name: 'subtract' })
const addButton = screen.getByRole('button', { name: 'add' })
// do the tests
// eslint-disable-next-line
expect(container.querySelector('.Component5')).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent('Component 5')
expect(valueEl).toBeInTheDocument()
expect(valueEl).toHaveTextContent('0')
expect(subtractButton).toBeInTheDocument()
expect(addButton).toBeInTheDocument()
})
test('It changes the value when the buttons are clicked', () => {
const { container } = render(<Component5 increment={1} />)
// get the elements
// eslint-disable-next-line
const valueEl = container.querySelector('.Component5__value')
const subtractButton = screen.getByRole('button', { name: 'subtract' })
const addButton = screen.getByRole('button', { name: 'add' })
// test default value
expect(valueEl).toHaveTextContent('0')
// test addbutton
userEvent.click(addButton)
expect(valueEl).toHaveTextContent('1')
// test subtract button
userEvent.click(subtractButton)
expect(valueEl).toHaveTextContent('0')
})
test('It adds or subtract the increment 10', () => {
const { container } = render(<Component5 increment={10} />)
// get the elements
// eslint-disable-next-line
const valueEl = container.querySelector('.Component5__value')
const subtractButton = screen.getByRole('button', { name: 'subtract' })
const addButton = screen.getByRole('button', { name: 'add' })
// test addbutton
userEvent.click(addButton)
expect(valueEl).toHaveTextContent('10')
// test subtract button
userEvent.click(subtractButton)
expect(valueEl).toHaveTextContent('0')
})
})
As you can see, there is a lot of duplication. We make the same render and the same queries in all tests. We will now rewrite these tests. We start by adding this function in root of the file:
function setup(props){
const { container } = render(<Component5 {...props} />)
return{
// eslint-disable-next-line
valueEl: container.querySelector('.Component5__value'),
subtractButton: screen.getByRole('button', { name: 'subtract' }),
addButton: screen.getByRole('button', { name: 'add' }),
container,
}
}
Let me walk you through this function:
We moved the
render()
inside oursetup
function. Whensetup
is called, the component renders.The
render()
still returnscontainer
so we have access to that element inside oursetup
function.-
We now spread the
setup
argument (props, an object) into our component:{...props}
. This pattern allows to use the samesetup
function with different props.
setup({ increment: 1 }) // calls render(<Component5 increment="1">) setup({ increment: 5 }) // calls render(<Component5 increment="5">)
-
From our
setup
function, we return an object with all our frequently used queries (the buttons and the value element). This gives us access to these queries inside the test, where thesetup
function is called.
test('It renders', () => { const { valueEl, subtractButton, addButton } = setup({ increment: 1 }) // do tests with these elements })
-
Lastly, I also placed
container
on the return object. This gives us access to container insidetest()
for queries that for example you only use once.
test('It renders', () => { const { container } = setup({ increment: 1 }) // eslint-disable-next-line expect(container.querySelector('.Component5')).toBeInTheDocument() })
To conclude: the updated test with this setup function:
// the setup function
function setup(props){
const { container } = render(<Component5 {...props} />)
return{
// eslint-disable-next-line
valueEl: container.querySelector('.Component5__value'),
subtractButton: screen.getByRole('button', { name: 'subtract' }),
addButton: screen.getByRole('button', { name: 'add' }),
container,
}
}
// the tests
describe('Component 5 (DRY)', () => {
test('It renders', () => {
const { container, valueEl, subtractButton, addButton } = setup({ increment: 1 })
// do the tests
// eslint-disable-next-line
expect(container.querySelector('.Component5')).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent('Component 5')
expect(valueEl).toBeInTheDocument()
expect(valueEl).toHaveTextContent('0')
expect(subtractButton).toBeInTheDocument()
expect(addButton).toBeInTheDocument()
})
test('It changes the value when the buttons are clicked', () => {
const { valueEl, subtractButton, addButton } = setup({ increment: 1 })
// test default value
expect(valueEl).toHaveTextContent('0')
// test addbutton
userEvent.click(addButton)
expect(valueEl).toHaveTextContent('1')
// test subtract button
userEvent.click(subtractButton)
expect(valueEl).toHaveTextContent('0')
})
test('It adds or subtract the increment 10', () => {
const { valueEl, subtractButton, addButton } = setup({ increment: 10 })
// test addbutton
userEvent.click(addButton)
expect(valueEl).toHaveTextContent('10')
// test subtract button
userEvent.click(subtractButton)
expect(valueEl).toHaveTextContent('0')
})
})
This may still seem like a lot of code but it is a lot cleaner. This pattern will save you a lot of time and avoids repetition.
Conclusion
We looked into testing queries
, matchers
and setup functions
. I offered solutions for problems you may run into. I hope this gives you a better practical knowledge of testing react components.
I wrote a series on mocking React
that is a good follow up on this article.