Lessons I learned as a Jest and React Testing Library beginner

Peter Jacxsens - Aug 23 '22 - - Dev Community

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.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player