DEV Community

Cover image for Lessons I learned as a Jest and React Testing Library beginner
Peter Jacxsens
Peter Jacxsens

Posted on • Edited on

Lessons I learned as a Jest and React Testing Library beginner

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:

  1. queries
  2. matchers
  3. setup functions
  4. 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> ) } 
Enter fullscreen mode Exit fullscreen mode
// 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() }) 
Enter fullscreen mode Exit fullscreen mode

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> ) } 
Enter fullscreen mode Exit fullscreen mode

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() }) 
Enter fullscreen mode Exit fullscreen mode

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() }) 
Enter fullscreen mode Exit fullscreen mode

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> ) } 
Enter fullscreen mode Exit fullscreen mode
// 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() }) 
Enter fullscreen mode Exit fullscreen mode

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 for Jest.

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> ) } 
Enter fullscreen mode Exit fullscreen mode
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') }) 
Enter fullscreen mode Exit fullscreen mode

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> ) } 
Enter fullscreen mode Exit fullscreen mode

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') }) }) 
Enter fullscreen mode Exit fullscreen mode

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, } } 
Enter fullscreen mode Exit fullscreen mode

Let me walk you through this function:

  1. We moved the render() inside our setup function. When setup is called, the component renders.

  2. The render() still returns container so we have access to that element inside our setup function.

  3. We now spread the setup argument (props, an object) into our component: {...props}. This pattern allows to use the same setup function with different props.

    setup({ increment: 1 }) // calls render(<Component5 increment="1">) setup({ increment: 5 }) // calls render(<Component5 increment="5">) 
  4. 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 the setup function is called.

    test('It renders', () => { const { valueEl, subtractButton, addButton } = setup({ increment: 1 }) // do tests with these elements }) 
  5. Lastly, I also placed container on the return object. This gives us access to container inside test() 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') }) }) 
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)