Follow me on Twitter, happy to take your suggestions on topics or improvements /Chris
React Testing Library is a different testing library in that it tests the surface of your component rather than the internals. You can change your components as much as you want as long as they render the data the same way or the React in the same way if you after interactions such as filling in data or pressing a button for example.
This is what the author the library Kent C. Dodds says about it:
Simple and complete React DOM testing utilities that encourage good testing practices
It's a lightweight solution for testing React components. It provides utility functions on top of react-dom
. Your tests work on DOM nodes as opposed to React component instances.
In this article we will cover the following:
It's easy to get started, you only need to install react-testing-library
:
yarn add react-testing-library
Let's look at a real scenario and see what we mean. We will create:
Todos.js
a component that allows you to render a list of Todos
and select a specific Todo item
Todos.test.js
, our test fileOur component code looks like this:
// Todos.js
import React from 'react';
import './Todos.css';
const Todos = ({ todos, select, selected }) => (
<React.Fragment>
{todos.map(todo => (
<React.Fragment key={todo.title}>
<h3 data-testid="item" className={ selected && selected.title === todo.title ? 'selected' :'' }>{todo.title}</h3>
<div>{todo.description}</div>
<button onClick={() => select(todo)}>Select</button>
</React.Fragment>
))}
</React.Fragment>
);
class TodosContainer extends React.Component {
state = {
todo: void 0,
}
select = (todo) => {
this.setState({
todo,
})
}
render() {
return (
<Todos { …this.props } select={this.select} selected={this.state.todo} />
);
}
}
export default TodosContainer;
Now to the test:
// Todos.test.js
import {render, fireEvent, wait} from 'react-testing-library';
import 'react-testing-library/cleanup-after-each';
import React from 'react';
import 'jest-dom/extend-expect';
import Todos from '../Todos';
const todos = [
{
title: 'todo1'
},
{
title: 'todo2'
}];
describe('Todos', () => {
it('finds title', () => {
const {getByText, getByTestId, container} = render(<Todos todos={todos} />);
})
});
We can see from the above code that we are using some helpers from
react-testing-library
:
Looking at the test itself we see that when we call render we get an object back, and that we destructure 3 values from it:
const {getByText, getByTestId, container} = render(<Todos todos={todos} />)
and we end up with the following helpers:
data-testid
, so if you have an attribute on your element like so data-testid="saved"
you would be querying it like so getByTestId('saved')
Let's fill in that test:
// Todos.test.js
import {render, fireEvent, wait} from 'react-testing-library';
import React from 'react';
import 'jest-dom/extend-expect';
import 'react-testing-library/cleanup-after-each';
import Todos from '../Todos';
const todos = [
{
title: 'todo1'
},
{
title: 'todo2'
}];
describe('Todos', () => {
it('finds title', () => {
const {getByText, getByTestId, container} = render(<Todos todos={todos} />);
const elem = getByTestId('item');
expect(elem.innerHTML).toBe('todo1');
})
});
As we can see above, we are able to render our component and query for an h3 element by using the container and the querySelector. Finally, we assert on the text inside the element.
Let's have a look at our component again. Or rather let's look at an excerpt of it:
// excerpt of Todos.js
const Todos = ({ todos, select, selected }) => (
<React.Fragment>
{todos.map(todo => (
<React.Fragment key={todo.title}>
<h3 className={ selected && selected.title === todo.title ? 'selected' :'' }>{todo.title}</h3>
<div>{todo.description}</div>
<button onClick={() => select(todo)}>Select</button>
</React.Fragment>
))}
</React.Fragment>
);
We see above that we try to set the CSS class to selected
if a todo is selected. The way to get a todo
selected is to click on it, we can see how we invoke the select method when we click on the button that is rendered, one per item. Let's try to test this out by adding a test:
import {render, fireEvent, wait} from 'react-testing-library'
import React from 'react';
import 'jest-dom/extend-expect'
import 'react-testing-library/cleanup-after-each'
import Todos from '../Todos';
const todos = [
{
title: 'todo1'
},
{
title: 'todo2'
}
];
describe('Todos', () => {
it('finds title', () => {
const {getByText, getByTestId, container} = render(<Todos todos={todos} />);
const elem = getByTestId('item');
expect(elem.innerHTML).toBe('todo1');
})
it('select todo', () => {
const {getByText, getByTestId, container} = render(<Todos todos={todos} />);
fireEvent.click(getByText('Select'));
const elem = getByTestId('item');
expect(elem.classList[0]).toBe('selected');
})
});
Our last newly added test is using the fireEvent
helper to perform a click
and we can see that we are using the getByText
helper to find the button. We again use the container to find and assert on the selected CSS class.
We have so far shown you how to render a component, find the resulting elements, and assert on them. We have also shown how you can carry out things like a click on a button. In this section we will show two things:
We will build the following:
Let's have a look at the component:
// Note.js
import React from 'react';
class Note extends React.Component {
state = {
content: '',
saved: '',
};
onChange = (evt) => {
this.setState({
content: evt.target.value,
});
console.log('updating content');
}
save = () => {
this.setState({
saved: `Saved: ${this.state.content}`,
});
}
load = () => {
var me = this;
setTimeout(() => {
me.setState({
data: [{ title: 'test' }, { title: 'test2' }]
})
}, 3000);
}
render() {
return (
<React.Fragment>
<label htmlFor="change">Change text</label>
<input id="change" placeholder="change text" onChange={this.onChange} />
<div data-testid="saved">{this.state.saved}</div>
{this.state.data &&
<div data-testid="data">
{this.state.data.map(item => (
<div className="item" >{item.title}</div>
))}
</div>
}
<div>
<button onClick={this.save}>Save</button>
<button onClick={this.load}>Load</button>
</div>
</React.Fragment>
);
}
}
export default Note;
To save data in our sample app, we enter text into an input and press the save button.
Let's create a test for that:
// __tests__/Note.js
import {render, fireEvent, wait} from 'react-testing-library'
import React from 'react';
import 'jest-dom/extend-expect'
import 'react-testing-library/cleanup-after-each'
import Select from '../Note';
describe('Note', () => {
it('save text', async() => {
const {getByText, getByTestId, getByPlaceholderText, container, getByLabelText} = render(<Select />);
const input = getByLabelText('Change text');
input.value= 'input text';
fireEvent.change(input);
fireEvent.click(getByText('Save'));
console.log('saved', getByTestId('saved').innerHTML);
expect(getByTestId('saved')).toHaveTextContent('input text')
})
});
We can see above that we use the helper getByLabelText
to get a reference to our input and we simply do input.value = 'input text'
at that point. Then we need to invoke fireEvent.change(input)
for the change to happen. After that we can assert on the results by typing expect(getByTestId('saved')).toHaveTextContent('input text')
We have another piece of functionality in our component namely pressing a Load button that invokes a load()
method, like so:
load = () => {
var me = this;
setTimeout(() => {
me.setState({
data: [{ title: 'test' }, { title: 'test2' }]
})
}, 3000);
}
We can see above that the change doesn't happen straight away, this due to us using a setTimeout(). Having a look at our component we can see that we don't render out the data property unless it is set to a value:
{this.state.data &&
<div data-testid="data">
{this.state.data.map(item => (
<div className="item" >{item.title}</div>
))}
</div>
}
Our test needs to cater to this and wait for the div with attribute data-testid="data"
to be present before it can assert on it. This can be handled through async/await. We import waitForElement
from react-testing-library
which allows us to halt execution while waiting for the element to appear. Let's see what that looks like, by adding a test to our test file:
import {
render,
fireEvent,
wait,
waitForElement,
} from 'react-testing-library'
import 'react-testing-library/cleanup-after-each';
import React from 'react';
import 'jest-dom/extend-expect'
import Select from '../Note';
describe('Note', () => {
it('save text', async () => {
const {getByText, getByTestId, getByPlaceholderText, container, getByLabelText} = render(<Select />);
const input = getByLabelText('Change text');
input.value= 'input text';
fireEvent.change(input);
fireEvent.click(getByText('Save'));
console.log('saved', getByTestId('saved').innerHTML);
expect(getByTestId('saved')).toHaveTextContent('input text')
})
it('load data', async() => {
const {getByText, getByTestId, getByPlaceholderText, container} = render(<Select />);
fireEvent.click(getByText('Load'));
const elem = await waitForElement(() => getByTestId('data'))
const elem = getByTestId('item');
expect(elem).toHaveTextContent('test');
})
});
Above we see the construct await waitForElement(() => getByTestId('data'))
that prevent the test from continuing until the element is present. The waitForElement
returns a promise that doesn't resolve until the element exist on the DOM. Thereafter we assert on the result.
We took a look at react-testing-library and wrote tests covering core use-cases. We learned how to deal with events, asynchronous actions, how to manage user input. We covered most things this library has to offer but more importantly, we learned how to think about testing in a different way.
Maybe we don't have to test the internals but rather the surface of our components?
There is a lot more to this library and you are encouraged to look at the