🔬 React Testing - How to Test React Components?

Unit testing is an essential part of any software development process. It allows developers to test individual components of the application and catch any bugs before they make it into production. React.js, being one of the most popular front-end frameworks, has a lot of resources available for unit testing.


Why You Should Write Unit Tests

Unit testing is a crucial step in the software development process, where developers meticulously examine the smallest functional components, known as units, to ensure their proper functioning. This process involves thorough testing conducted by software developers, and occasionally by QA personnel, as an integral part of the development lifecycle. Unit testing helps us have faith and strong faith at that, that our software works well and in the most bizarre use cases. Sometimes, it's difficult to test and cover all use cases before the software is deployed, but unit testing helps us test different independent units of our software. Making sure that they are working correctly and will stand the test of time.

What Should You Test

As the name suggests we should be testing a unit, not more, not less. If it is a function we should be testing only that function and not its dependencies. If it's a react component it should only be that react component.

What to Test

In general, your tests should cover the following aspects of your code:

  • If a component renders with or without props
  • How a component renders with state changes
  • How a component reacts to user interactions

What Not to Test

Testing most of your code is important, but here are some things you do not need to test:

  • Actual Implementation: You do not need to test the actual implementation of a functionality. Just test if the component is behaving correctly.
  • Third-Party libraries: If you are using any third-party libraries like Material UI, no need to test those – they should already be tried and tested.

Tools for React Unit Testing

React Testing Library

The React Testing Library has a set of packages that help you test UI components in a user-centric way. This means it tests based on how the user interacts with the various elements displayed on the page.

Vitest

Vitest is a next-generation testing framework powered by Vite. You can check out the Comparisons section on vitest website for more details on how Vitest differs from other similar tools.

How to Write Unit Tests

We are not going to deep dive into how to set up, configure and the file paths of tests. we’ll check the general idea and the strategy of writing unit tests. and try to talk through that.

Test Rendering Components

Let's write a component that renders "Hello World" and test that the component renders the popular greeting:

HelloWorld.jsx
const HelloWorld = () => {
  return <div>Hello World</div>
}
export default HelloWorld

Now, we will create a test file:

HelloWorld.test.jsx
import { render } from '@testing-library/react'
import { expect, it } from 'vitest'
import HelloWorld from './HelloWorld'
 
it('should render "Hello World" text', () => {
  const { getByText } = render(<HelloWorld />)
  const helloWorldElement = getByText('Hello World')
  expect(helloWorldElement).toBeInTheDocument()
})

In this test case, we render the HelloWorld component using the render function provided by React Testing Library, The render function is used to render the component in a DOM, this is similar to the DOM in the browser. Once this is done we can then test the component using DOM API-like functions provided by the React Testing Library. We then use the getByText function to retrieve the element that contains the "Hello World" text. Finally, we use the toBeInTheDocument matcher to check if the element is present in the rendered component.

We can assign a test-id to elements in our component so we can pinpoint them directly by using the getTestById function provided by React Testing Library.

HelloWorld.jsx
const HelloWorld = () => {
  return <div data-testid="hello-world">Hello World</div>
}
export default HelloWorld

See the use of data-testid property in the div element there. To get the div element, we will call the getByTestId function passing in the value of the data-testid to the function. This returns the HTMLElement instance of the div element and then we can test the Hello World text node using the textContent DOM property.

HelloWorld.test.jsx
import { render } from '@testing-library/react'
import { expect, it } from 'vitest'
import HelloWorld from './HelloWorld'
 
it('should render "Hello World" text', () => {
  const { getByTestId } = render(<HelloWorld />)
  const helloWorldElement = getByTestId('hello-world')
  expect(helloWorldElement).toBeInTheDocument()
  expect(helloWorldElement.textContent).toBe('Hello World')
})

Test Firing Events

Let's say we have a Counter application that updates the DOM with the click of a button:

Counter.jsx
import { useState } from 'react'
const Counter = ({ count }) => {
  const [increment, setIncrement] = useState(0)
  const handleIncrement = () => {
    setIncrement(increment + 1)
  }
  return (
    <div>
      <p>Increment: {increment}</p>
      <button onClick={handleIncrement}>Increment</button>
    </div>
  )
}
export default Counter

Now, we have a state increment that holds the state of the application, and it is displayed in the DOM. The Increment button when clicked increases the state increment by one. So let's write a test for this component to make sure that the increment state is increased when the Increment button is clicked.

Counter.test.jsx
import { render, fireEvent } from '@testing-library/react'
import { expect, it } from 'vitest'
import Counter from './Counter'
 
it('should increment count on button click', () => {
  const { getByText } = render(<Counter />)
  const incrementElement = getByText('Increment: 0')
  const buttonElement = getByText('Increment')
  fireEvent.click(buttonElement)
  expect(incrementElement.textContent).toBe('Increment: 1')
})

See that we got the DOM instance of the button by calling this getByText('Increment'), then we called the click method on its instance, this will fire the click event on the button causing the increment state to be increased by one, then we will thereafter to see if the state was really updated. See in the last line, we got the text node of the div element and expect it to be Increment: 1.

State and Props of the Components

Counter.jsx
import { useState } from 'react'
const Counter = ({ initialCount }) => {
  const [count, setCount] = useState(initialCount)
  const increment = () => {
    setCount(count + 1)
  }
  return (
    <div>
      <p>
        Count: <span data-testid="count">{count}</span>
      </p>
      <button data-testid="button" onClick={increment}>
        Increment
      </button>
    </div>
  )
}
export default Counter

We have a state count, its initial value is from props and it is displayed in the DOM in the p element. The Increment button when clicked increments the state of the count by 1 and this in turn makes the component re-render and displays the updated value of the count state.

Counter.test.jsx
import { render, fireEvent } from '@testing-library/react'
import { expect, it } from 'vitest'
import Counter from './Counter'
 
it('should render initial count and increment count on button click', () => {
  const { getByTestId } = render(<Counter initialCount={3} />)
  const countElement = getByTestId('count')
  const buttonElement = getByTestId('button')
  expect(countElement.textContent).toBe('3')
  fireEvent.click(buttonElement)
  expect(countElement.textContent).toBe('4')
})

Mocking Function Calls

During testing, we might not really want an actual function to be called based on some factors. For example, the function might have a number of calls set on it. The only way to go about this is to mock that function, ie, to create a dumb version function of that actual function.

Foobar.jsx
const Foobar = ({ done }) => {
  return (
    <div>
      <button onClick={done}>Call DONE</button>
    </div>
  )
}

We have a simple component here, it accepts a function in its props object via the done property. This done props function is called when the Call DONE button is clicked.

Foobar.test.jsx
import { render } from '@testing-library/react'
import { expect, it, vi } from 'vitest'
import Foobar from './Foobar'
 
it('test mock function props is called', () => {
  const fn = vi.fn()
  const { getByText } = render(<Foobar done={fn} />)
  const button = getByText('Call DONE')
  fireEvent.click(button)
  expect(fn).toHaveBeenCalledTimes(1)
  fireEvent.click(button)
  expect(fn).toHaveBeenCalledTimes(2)
})

Testing React Hooks

useCounter.js
import { useState } from 'react'
const useCounter = () => {
  const [count, setCount] = useState(0)
  const increment = () => {
    setCount((prevCount) => prevCount + 1)
  }
  const decrement = () => {
    setCount((prevCount) => prevCount - 1)
  }
  return { count, increment, decrement }
}
export default useCounter

Now, let's write a test case for this custom hook:

useCounter.test.js
import { renderHook, act } from '@testing-library/react-hooks'
import { expect, it } from 'vitest'
import useCounter from './useCounter'
 
it('should increment and decrement counter correctly', () => {
  const { result } = renderHook(() => useCounter())
  const { count, increment, decrement } = result.current
  expect(count).toBe(0)
  act(() => {
    increment()
  })
  expect(count).toBe(1)
  act(() => {
    decrement()
  })
  expect(count).toBe(0)
})

Testing Asynchronous Operations

Let's see a component that on render makes an HTTP call to an endpoint and then renders the result of the fetch.

AsyncComponent.jsx
import { useState, useEffect } from 'react'
const AsyncComponent = () => {
  const [data, setData] = useState(null)
  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch('https://api.example.com/data')
      const result = await response.json()
      setData(result)
    }
    fetchData()
  }, [])
  return <div>{data ? data.message : 'Loading...'}</div>
}
export default AsyncComponent

This component fetches data from https://api.example.com/data and renders it. Now, we want to test this component but we don't want the component to make an actual HTTP call to the endpoint. We will mock the fetch call.

AsyncComponent.test.js
import { render, waitFor } from '@testing-library/react'
import { expect, it } from 'vitest'
import AsyncComponent from './AsyncComponent'
 
it('should render fetched data after async call', async () => {
  const mockData = { message: 'Test Message' }
  // Mock the fetch API
  vi.stubGlobal('fetch', () =>
    Promise.resolve({
      json: () => Promise.resolve(mockData),
    }),
  )
  const { getByText } = render(<AsyncComponent />)
  // Assert that "Loading..." is initially rendered
  expect(getByText('Loading...')).toBeInTheDocument()
  // Wait for the async operation to complete
  await waitFor(() => {
    expect(getByText(mockData.message)).toBeInTheDocument()
  })
  // Restore the original fetch implementation
  vi.unstubAllGlobals()
})

You can also use waitForNextUpdate here for data fetching.

...
it('should render fetched data after async call', async () => {
  ...
  const { getByText, waitForNextUpdate } = render(<AsyncComponent />)
  expect(getByText('Loading...')).toBeInTheDocument()
  await waitForNextUpdate()
  expect(getByText(mockData.message)).toBeInTheDocument()
  ...
})

Snapshot Testing

Snapshot testing is quite different from what we have seen above. This type of testing is classified as output comparison testing. In the case of React component snapshot testing, the UI of the component is taken first and saved, then on subsequent testing, a current snapshot of the component is taken and compared with the previous snapshot to check for changes that may cause breaks.

Button.jsx
const Button = ({ text, onClick }) => {
  return (
    <button onClick={onClick} className="button">
      {text}
    </button>
  )
}
export default Button

To create a snapshot test for this component, you can write a test case using the toMatchSnapshot matcher:

Button.test.jsx
import { render } from '@testing-library/react'
import { expect, it } from 'vitest'
import Button from './Button'
 
it('should Button component match snapshot', () => {
  const { asFragment } = render(<Button text="Click me" onClick={() => {}} />)
  expect(asFragment()).toMatchSnapshot()
})

If you intentionally make changes to the component's output and want to update the snapshot, you can update the snapshot file using the following command:

vitest -u

Summary

In summary, I’d say the most important thing is writing testable code. That allows you to write better unit tests and write them fast. Key facts in writing testable code are:

  • Separation of UI and logic
  • Passing dependencies to function (or dependency injection)
  • Make sure to test a single unit of code