Component Testing

Component testing is a type of automated testing, just like unit testing, integration testing, end-to-end testing and so forth.

One of the reasons we write tests is to give us confidence that our code is doing what we expect when certain things happen. When it comes to component testing, that means asserting that the component is fulfilling its two primary responsibilities correctly:

  1. Rendering data into the user interface
  2. Re-rendering the user interface when events occur

These two responsibilities relate to two types of component testing.

1. Interaction testing

Interaction testing ensures the component renders the correct result when events it cares about are fired. A common example is testing data validation on forms. As the user is filling out the form, the user interface should provide feedback about the validity of the input data. Interaction testing tends to include two main steps:

  1. Render the component and assert the initial value of a target element
  2. Fire the event and assert the value of the target element changed to the expected value

The guidance is if a component handles events, we should have a test for each interaction of that event.

2. Structural testing

Structural testing ensures the component renders the data inside the correct UI elements. If we were to write tests to ensure that every piece of data in our application made it into the correct DOM element, our tests would be too brittle, which means they would often fail in ways that don't add value (increase our confidence). However, there are times when we should write some structural tests.

A recommended practice when building components is to not include logic, such as conditionals and loops, within the component's code, but rather put that logic in a separate module and import the functions into the component's module. That way we can use normal unit test (opposed to component tests) to test the logic. This will save time and help our tests run faster. However, sometimes we might need to put a little logic in the component, such as mapping over an array or conditionally showing/hiding a part of the component. In these cases, we should have a component test to confirm that the logic renders the data within the correct elements.

There are two ways to write a structural test: explicitly or using a snapshot. An explicit structural test renders the component with test data, selects the UI element and asserts it has the expected state (text, attribute value, etc). A snapshot test renders the component with test data, converts the rendered result to a string (usually JSON) and saves the result in our repo. During the next test run, the snapshots are compared with the previous results. The test passes if the results are the same and fails if they are different.

Testing tools

We'll use Vitest to find, run and report on our tests. We'll use Testing Library to render our React components and simulate events.

Example tests

In order to get familiar with using these tools, let's write some different types of tests. We'll start with a snapshot test to confirm a component's structure, and then we'll have a look at an interaction test. This is the component we will test.

import React, { useState } from 'react'
function Counter () {
const [count, setCount] = useState(0)
const handleClick = () => {
setCount(count + 1)
}
return (
<>
<p>Counter: {count}</p>
<button onClick={handleClick}>Increase</button>
</>
)
}
export default Counter

A snapshot test

Here is an example of a test that asserts Counter has the expected structure.

import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import Counter from './Counter'
describe('<Counter />', () => {
it('has the expected structure', () => {
render(<Counter />)
expect(screen.container).toMatchSnapshot()
})
})

In this example, we're using @testing-library/react to render our component. The object that render() updates the screen object, which we can use to assert the snapshots match.

An interaction test

Here is an example of a test that asserts the counter is being updated in the browser when the button is clicked.

import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import matchers from '@testing-library/jest-dom/matchers'
expect.extend(matchers)
import Counter from './Counter'
describe('<Counter />', () => {
it('increases the count on button click', async () => {
// 1. Render the component we're testing
render(<Counter />)
const user = userEvent.setup()
// 2. Select the <p> tag and assert its text displays a 0 (zero)
const countDisplay = screen.getByText(/Counter/)
expect(countDisplay).toHaveTextContent('0')
// 3. Select the button and fire its click event
const button = screen.getByRole('button')
await user.click(button)
// 4. Assert the re-rendered <p> tag text has changed as expected
expect(countDisplay).not.toHaveTextContent('0')
expect(countDisplay).toHaveTextContent('1')
})
})

The comments in this code example describe the 4 main things we need to do when testing how a component responds to events. However, there are also some parts that would benefit from a bit of explanation.

When we call expect.extend(matchers) we are extending the usual matchers we see on expect to include some excellent new matchers, such as toHaveTextContent. You can learn more using the link in the resources below.

In step #2, we're passing a Regular Expression (aka regex) to the getByText function. This allows us to match on a substring of the text in the paragraph tag. In testing-library, this parameter is called a TextMatch. Check out the docs for TextMatch to see some examples of how to use it.

Step #3 is using the getByRole function to select the button. The *ByRole functions use the accessible ARIA roles to select elements and this is the recommended way to select elements. We should be ensuring our applications follow accessibility best practice anyway - so it's great that our testing library encourages that.

It should be clear at this point that in order to use testing-library effectively requires an understanding of its query functions.

Query functions

This is a short summary of the query functions provided by testing-library, but the documentation is very helpful and well-written. You would benefit from spending some time perusing the testing-library docs on the types of queries.

  • Use the get* queries when you expect the element to be available and want an error (failed test) otherwise.
  • Use the query* queries when you don't want an error to be thrown if the element isn't found.
  • Use the find* queries when you're waiting on changes from a re-rendering (e.g. after an event).
  • Use the *AllBy* queries when intentionally matching on multiple elements.

Resources