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
- Anatomy of the Default React Test
- Planning the Tests
- Describe the Tests
- Implementing the Component
- Conclusion
- Resources
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
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();
});
-
We begin by importing two crucial functions from the React Testing Library:
render
andscreen
.-
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 toquerySelector
, however the syntax is a bit different since we will not be using an element's tag/class/id.
-
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 hereThe 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 hereLastly, 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();
});
- 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 thescreen
object is invoked with a regular expression argument. ThegetByText
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.
We can confirm that the default test is working before we move on to writing our own by running npm test
in the console.
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)
})
})
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()
})
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()
})
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()
})
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("")
})
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()
})
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.
We can now run the tests using npm test
as see the 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;
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.
Top comments (3)
Thank you for this guide. One noteworthy thing: it's best to use the
screen.getByRole
selector instead ofgetByText
whenever possible. Otherwise, you may let semantic issues (e.g. a button as a link) pass.Thank you Alex, I appreciate the feedback as I continue to build on my skill set!
It's very informative. Good work π