Beginner's Guide to Jest Testing in React

dsasse07

Daniel Sasse

Posted on May 13, 2021

Beginner's Guide to Jest Testing in React

In my last post, A Beginner's Guide to Unit-testing with Jest, I walked through getting started with testing in javascript using the Jest testing library. Here, I hope to expand on what was already discussed about matchers and expectations, and the purpose of test implementation with an example of how to write basic tests for React components.

Writing tests for React components with Jest follows the same similar structure of a describe function containing test blocks with expect functions and matchers. However, instead of testing the functionality of individual JS functions, we need to ensure that React components are rendering properly and that user interactions with the component occur as expected. For a detailed guide on the basic setup for Jest testing and it purposes, please see my previous post, A Beginner's Guide to Unit-testing with Jest.

Getting Started

We will walk through the process of setting up a basic React App with interactive elements such as a counter with increment/decrement buttons, and a form to post text to the DOM. I will walk through writing the Jest tests and the rest of the code here, but you can view the repo containing all of the code as well.

Contents

Setting Up The App

Steps:

  • Create a new react app, and cd into that directory.
  • Jest is installed as a dependency to React when using npx-create-react-app, along with the React Testing Library. The React Testing Library provides additional functions to find and interact with DOM nodes of components. No additional installation or setup is needed when beginning your React app this way.
npx create-react-app jest-react-example
cd jest-react-example
Enter fullscreen mode Exit fullscreen mode

Anatomy of the Default Test

When a new React app is created using npx-create-react-app, the App.js file comes pre-filled with placeholder content and a test file is included for this by default - App.test.js. Let's walk through what happening in this test file:

// App.test.js
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom/extend-expect';
import App from './App';

test('renders learn react link', () => {
  render(<App />);
  const linkElement = screen.getByText(/learn react/i);
  expect(linkElement).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode
  1. We begin by importing two crucial functions from the React Testing Library: render and screen.

    • Render is a function that will build the DOM tree in memory that would normally be rendered as a webpage. We will use this to turn our component code into the format that the user would be interacting with.
    • Screen is a an object with a number of querying functions that will allow us to target element(s) in the DOM. For comparison, it functions similarly to querySelector, however the syntax is a bit different since we will not be using an element's tag/class/id.
  2. The next import, userEvent will allow us to simulate a variety of user actions with a targeted element, such as button presses, typing, etc.The full documentation for userEvent can be found here

  3. The third import, @testing-library/jest-dom/extend-expect, provides additional matchers that we can use for targeted elements. The full documentation for Jest-DOM can be found here

  4. Lastly, we need to import the component that we will be testing in this file.

With these imports completed, we see the familiar structure of a Jest test function.

// Copied from above
test('renders learn react link', () => {
  render(<App />);
  const linkElement = screen.getByText(/learn react/i);
  expect(linkElement).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode
  • The test function is invoked with a string argument describing the test, and a callback function with the test content.
  • The callback function first creates the DOM tree for the component by rendering the component.
  • The getByText function of the screen object is invoked with a regular expression argument. The getByText function will return the first element in the DOM that has text matching the regular expression, which will then save to a variable for later use.
  • The callback is completed with the expect and matcher statements. In this case, we are simply stating that we expect that our previous query found an element in the document.

If we start the app on the local machine using npm start we can see that the specified link text is clearly visible, and the default test should pass.

Default React Page

We can confirm that the default test is working before we move on to writing our own by running npm test in the console.

Default App.test.js results

Planning the Tests

Following Test-Driven Development, let's begin by defining what our App should do, write the tests for it, and then implement the code that should pass the tests.

  • There will be two buttons: increment and decrement.

    • When clicked, they should increase/decrease a counter on the page.
    • The counter should never be negative, so the decrement button should be disabled when the counter is less than 1.
  • There should be a form with an input field and a submit button.

    • The user should be able to type into the field, and when submitted, the text from the field will display in a list on the screen.
    • Each list item will have a "remove" button, that when pressed should remove that item from the screen.

Describe the Tests

Since the counter value will just be a number, I wanted to ensure that the query matches the counter value and not another number that is potentially on the page (as may happen with just using getByText()). For this, we can use the dataset attribute data-testid similar to how we use id in HTML. The difference is that data-testid is strictly for testing purposes and not related to CSS or other interactions.

Counter Tests

Test #1:

In this first test, I wrote the expectation statements to match the initial plan for the counter feature. We expect the DOM to include both buttons, the counter label "Counter: ", and the value of the counter. We would also expect that when the page is first loaded, the counter has a default text value of 0, and because of this, our decrement button should be disabled to not allow a negative counter value.

describe( 'App Counter', () => {
  test('Counter Elements should be present', () => {
    render(<App />)
    const incrementButton = screen.getByText(/Increment/i)
    const decrementButton = screen.getByText(/Decrement/i)
    const counterLabel = screen.getByText(/Counter:/i)
    const counterText = screen.getByTestId("counter-value")

    expect(incrementButton).toBeInTheDocument()
    expect(incrementButton).toBeEnabled()
    expect(decrementButton).toBeInTheDocument()
    expect(decrementButton).toBeDisabled()
    expect(counterLabel).toBeInTheDocument()
    expect(counterText).toHaveTextContent(0)
  })
})
Enter fullscreen mode Exit fullscreen mode
Test #2

For the counter, we expect that each time the increment button is pressed, the counter value should increase by 1. When the counter goes above zero, the decrement button should become enabled. To simulate a button press, we use the click() function in the userEvent object we had imported earlier.

// Within the describe block from test #1
  test('Increment increases value by 1 and enables decrement button present', () => {
    render(<App />)
    const incrementButton = screen.getByText(/Increment/i)
    const decrementButton = screen.getByText(/Decrement/i)
    const counterText = screen.getByTestId("counter-value")

    expect(counterText).toHaveTextContent(0)
    userEvent.click(incrementButton)
    expect(counterText).toHaveTextContent(1)
    expect(decrementButton).not.toBeDisabled()
  })
Enter fullscreen mode Exit fullscreen mode


js

Test #3

We expect that when the decrement button is pressed, the counter value should decrease by 1. When the counter reaches zero, the decrement button should become disabled.

// Within the describe block from test #1

  test('Decrement decreases value by 1 and disables decrement button at 0', () => {
    render(<App />)
    const incrementButton = screen.getByText(/Increment/i)
    const decrementButton = screen.getByText(/Decrement/i)
    const counterText = screen.getByTestId("counter-value")

    expect(counterText).toHaveTextContent(0)
    userEvent.click(incrementButton)
    expect(counterText).toHaveTextContent(1)
    expect(decrementButton).not.toBeDisabled()
    userEvent.click(decrementButton)
    expect(counterText).toHaveTextContent(0)
    expect(decrementButton).toBeDisabled()
  })
Enter fullscreen mode Exit fullscreen mode

Form Tests

The second feature of our mini-app, to explore how we can test for user interaction with a form, involves a form that creates list items when submitted.

Test #4

First, we can create the basic test to ensure that the expected elements are rendered to the page, similar to what was done earlier.

describe('App Item List', () => {
  test('List Form Components render', () => {
    render(<App />)
    const listItemInput = screen.getByLabelText(/Create List Item/i)
    const addItemButton = screen.getByTestId("add-item")

    expect(listItemInput).toBeInTheDocument()
    expect(addItemButton).toBeInTheDocument()
  })
Enter fullscreen mode Exit fullscreen mode
Test #6

Now that we have confirmed that the elements exist, we need to ensure that they function as expected:

  • Initially, we would expect the input field to be empty, and that the user should able to type into the field and change the value of the field.
  • With text in the field, we expect that the user should be able to click on the submit button to create a new list item on the page with that text, and it would reset the input field.
  test('User can add item to page', () => {
    render(<App />)
    const listItemInput = screen.getByLabelText(/Create List Item/i)
    const addItemButton = screen.getByTestId("add-item")

    expect(listItemInput).toHaveValue("")
    userEvent.type(listItemInput, "hello")
    expect(listItemInput).toHaveValue("hello")

    userEvent.click(addItemButton)
    expect(screen.getByText("hello")).toBeInTheDocument()
    expect(listItemInput).toHaveValue("")
  })
Enter fullscreen mode Exit fullscreen mode
Test #7

After a list item has been created, the user should be able to click the remove button next to it, to remove it from the page.

  test('User can remove item from page', () => {
    render(<App />)
    const listItemInput = screen.getByLabelText(/Create List Item/i)
    const addItemButton = screen.getByTestId("add-item")

    userEvent.type(listItemInput, "hello")
    userEvent.click(addItemButton)
    const newItem = screen.getByText("hello")
    expect(newItem).toBeInTheDocument()

    const removeButton = screen.getByTestId('remove-item0')
    userEvent.click(removeButton)
    expect(newItem).not.toBeInTheDocument()
  })
Enter fullscreen mode Exit fullscreen mode

Implementing the Component

With the tests in place, we should now build our component, and it should meet the expectations set in our tests. Writing the code for the component is no different than it would be without the tests in place. The only additional thing we must do, is include the data-testid on the elements for which our tests were querying the elements by using getByTestId() such as the list items and buttons. The full code implemented to create the component can be found below the demo.

End Result:
Example App Demo

We can now run the tests using npm test as see the results!

Final Test Results

Below is the code used to create the component demonstrated above, using hooks:

import { useState } from 'react'
import './App.css';

function App() {
  const [counter, setCounter] = useState(0)
  const [listItems, setListItems] = useState([])
  const [newItemText, setNewItemText] = useState("")

  const handleCounterClick = value => {
    setCounter( counter => counter + value )
  }

  const handleNewItemChange = e => {
    setNewItemText(e.target.value)
  }

  const handleAddItem = e => {
    e.preventDefault()
    setListItems([...listItems, {
      text: newItemText,id: listItems.length
      }
    ])
    setNewItemText('')
  }

  const handleRemoveItem = id => {
    const newListItems = listItems.filter( item => item.id !== id)
    setListItems(newListItems)
  }

  const listItemComponents = listItems.map( item => {
    return (
      <li
        data-testid={`item${item.id}`}
        key={item.id}
      >
        {item.text}
        <button
          data-testid={`remove-item${item.id}`}
          onClick={() => handleRemoveItem(item.id)}
        >
          Remove
        </button>
      </li>
    )
  })
  return (
    <div className="App">
      <header className="App-header">
        <p>
          Counter:
          <span data-testid="counter-value">
            {counter}
          </span>
        </p>
        <div>
          <button 
            onClick={() => handleCounterClick(1)}
          >
            Increment
          </button>
          <button 
            onClick={() => handleCounterClick(-1)}
            disabled={counter <= 0}
          >
            Decrement
          </button>
        </div>
        <form onSubmit={handleAddItem}>
          <label
            htmlFor="newItem"
          >
            Create List Item
            <input 
              id="newItem"
              value={newItemText}
              onChange={handleNewItemChange}
            />
          </label>
          <input
            data-testid="add-item"
            type="submit"
            value="Add Item"
          />
        </form>
        <ul>
          {listItemComponents}
        </ul>


      </header>
    </div>
  );
}

export default App;


Enter fullscreen mode Exit fullscreen mode

Conclusion:

While this only scratches the surface of testing React components, I hope this serves as a primer for getting started with developing your own tests for your components.

Resources:

💖 💪 🙅 🚩
dsasse07
Daniel Sasse

Posted on May 13, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related