Testing react components the right way with react-testing-library

April 02, 2018 0 Comments

Testing react components the right way with react-testing-library

 

 

A few days ago Kent C. Dodds released a testing package (React-Testing-Library) for testing react components/applications. The package was created on principles that encourage good testing practices.

The more your tests resemble the way your software is used, the more confidence they can give you.

Writing react tests has been complicated and challenging because of the generalised dogma of testing implementation details over workability and user interactions and interfaces. This library is all about testing your applications based on how the user interacts with them, not only on how the functionality was implemented.

Have a look at these two test suites to understand the difference:

Mindset of testing implementation details:

test('todo should be set on state when input changes')
test('a list of todos should be set on state when component mounts')
test('the addTodo function should be called when user clicks createTodo button') 
Mindset of testing how the software truly works:

test('clicking on the add todo button adds a new todo to the list')
test('gets todos when component is mounted and displays them as a list')
test('should show todo successfully created notification for two seconds when todo is created') 

As you can notice with the test suites, this package encourages writing more integration tests, which would greatly improve your confidence when deploying applications.

For example, we are not so interested in how the list of todos is rendered, what we are interested in, is that the user gets to see the list of todos, this is what we are going to test for. We also do not want to worry about how the changes made to the input text field are managed by component state, but we are concerned about what the user experiences, and that's what we are going to test.

Background: The app we'll test:

We'll write a few tests for a todos CRUD application hosted here.

Here's a list of functionality provided by the application:

  • Display a list of todos from an api when component is mounted
  • Adds, edits, and updates todos.
  • Shows notifications for different actions performed.

We'll write tests for:

  • Displays a list of todos from an api when the component is mounted
  • Adds todos

The application was scaffolded using create-react-app.Here are the main files:

App.js file:
import PropTypes from 'prop-types';
import React, { Component } from 'react'; import './App.css';
import logo from './logo.svg';
import ListItem from './ListItem';
import loadingGif from './loading.gif'; class App extends Component { constructor() { super(); this.state = { newTodo: '', editing: false, editingIndex: null, notification: null, todos: [], loading: true }; this.addTodo = this.addTodo.bind(this); this.updateTodo = this.updateTodo.bind(this); this.deleteTodo = this.deleteTodo.bind(this); this.handleChange = this.handleChange.bind(this); this.hideNotification = this.hideNotification.bind(this); } async componentDidMount() { const todos = await this.props.todoService.getTodos(); this.setState({ todos, loading: false }); } handleChange(event) { this.setState({ newTodo: event.target.value }); } async addTodo() { const todo = await this.props.todoService.addTodo(this.state.newTodo); this.setState({ todos: [ ...this.state.todos, todo ], newTodo: '', notification: 'Todo added successfully.' }, () => this.hideNotification()); } editTodo(index) { const todo = this.state.todos[index]; this.setState({ editing: true, newTodo: todo.name, editingIndex: index }); } async updateTodo() { const todo = this.state.todos[this.state.editingIndex]; const updatedTodo = await this.props.todoService.updateTodo(todo.id, this.state.newTodo); const todos = [ ...this.state.todos ]; todos[this.state.editingIndex] = updatedTodo; this.setState({ todos, editing: false, editingIndex: null, newTodo: '', notification: 'Todo updated successfully.' }, () => this.hideNotification()); } hideNotification(notification) { setTimeout(() => { this.setState({ notification: null }); }, 2000); } async deleteTodo(index) { const todo = this.state.todos[index]; await this.props.todoService.deleteTodo(todo.id); this.setState({ todos: [ ...this.state.todos.slice(0, index), ...this.state.todos.slice(index + 1) ], notification: 'Todo deleted successfully.' }, () => this.hideNotification()); } render() { return ( <div className="App"> <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> <h1 className="App-title">CRUD React</h1> </header> <div className="container"> { this.state.notification && <div className="alert mt-3 alert-success"> <p className="text-center">{this.state.notification}</p> </div> } <input type="text" name="todo" className="my-4 form-control" placeholder="Add a new todo" onChange={this.handleChange} value={this.state.newTodo} /> <button onClick={this.state.editing ? this.updateTodo : this.addTodo} className="btn-success mb-3 form-control" disabled={this.state.newTodo.length < 5} > {this.state.editing ? 'Update todo' : 'Add todo'} </button> { this.state.loading && <img src={loadingGif} alt=""/> } { (!this.state.editing || this.state.loading) && <ul className="list-group"> {this.state.todos.map((item, index) => { return <ListItem key={item.id} item={item} editTodo={() => { this.editTodo(index); }} deleteTodo={() => { this.deleteTodo(index); }} />; })} </ul> } </div> </div> ); }
} App.propTypes = { todoService: PropTypes.shape({ getTodos: PropTypes.func.isRequired, addTodo: PropTypes.func.isRequired, updateTodo: PropTypes.func.isRequired, deleteTodo: PropTypes.func.isRequired })
}; export default App; 
ListItem.js file:

import React from 'react';
import PropTypes from 'prop-types'; const ListItem = ({ editTodo, item, deleteTodo }) => { return <li className="list-group-item" > <button className="btn-sm mr-4 btn btn-info" onClick={editTodo} >U</button> {item.name} <button className="btn-sm ml-4 btn btn-danger" onClick={deleteTodo} >X</button> </li>;
}; ListItem.propTypes = { editTodo: PropTypes.func.isRequired, item: PropTypes.shape({ id: PropTypes.number.isRequired, name: PropTypes.string.isRequired }), deleteTodo: PropTypes.func.isRequired
}; export default ListItem; 
index.js file:

import React from 'react';
import axios from 'axios';
import ReactDOM from 'react-dom'; import App from './App';
import { apiUrl } from './config'; import TodoService from './service/Todo'; const client = axios.create({ baseURL: apiUrl,
}); const todoService = new TodoService(client); ReactDOM.render(<App todoService={todoService} />, document.getElementById('root')); 
TodoService.js file:
 /** * A todo service that communicates with the api to perform CRUD on todos. */
export default class TodoService { constructor(client) { this.client = client; } async getTodos() { const { data } = await this.client.get('/todos'); return data; } async addTodo(name) { const { data } = await this.client.post('/todos', { name }); return data; } async updateTodo(id, name) { const { data } = await this.client.put(`/todos/${id}`, { name }); return data; } async deleteTodo (id) { await this.client.delete(`/todos/${id}`); return true; }
} 

Let's start by setting up everything we need to get started with testing. If you're using create-react-app (as I am), then the testing environment is already setup for you. All that's left is to install react-testing-library.


npm i --save-dev react-testing-library 

Test: Displaying a list of todos when component is mounted.

Let's start out by writing a test for the first thing that happens when out component mounts: Todos are fetched from the api and displayed as a list.

App.spec.js file:

import React from 'react'; import { render, Simulate, flushPromises } from 'react-testing-library'; import App from './App';
import FakeTodoService from './service/FakeTodoService'; describe('The App component', () => { test('gets todos when component is mounted and displays them', async () => { });
}); 

First, we imported render from react-testing-library, which is simply a helper function that mounts our component behind the scenes using ReactDOM.render, and returns to us the mounted DOM component and a couple of helper functions for our tests.

Secondly, we imported Simulate, which is exactly the same Simulate from react-dom. It would help us simulate user events in our tests.

Finally, we imported flushPromises, which is a simple utility that's useful when your component is doing some async work, and you need to make sure that async operation resolves (or rejects) before you can continue with your assertions.

At the time of this writing, that's all about the package's api. Pretty neat, right ?

Also notice I imported a FakeTodoService, this is my version of mocking external async functionality in our tests. You might prefer using the real TodoService, and mocking out the axios library, its all up to you. Here's how the Fake todo service looks:

 /** * A fake todo service for mocking the real one. */
export default class FakeTodoService { constructor(todos) { this.todos = todos ? todos : []; } async getTodos() { return this.todos; } async addTodo(name) { return { id: 4, name }; } async deleteTodo(id) { return true; } async updateTodo(id, name) { return { id, name }; }
} 

We want to make sure that as soon as our component is mounted, it fetches the todos from the api, and displays these todos. All we need to do is mount this component (with our fake todo service), and assert that the todos from our fake service are displayed, right ? Have a look:


describe('The App component', () => { const todos = [{ id: 1, name: 'Make hair', }, { id: 2, name: 'Buy some KFC', }]; const todoService = new FakeTodoService(todos); test('gets todos when component is mounted and displays them', async () => { const { container, getByTestId } = render(<App todoService={todoService} />); });
}); 

When we render this component, we de-structure two things out of the result, the container, and the getByTestId. The container is the mounted DOM component, and the getByTestId is a simple helper function that finds an element in the DOM using data attributes. Have a look at this article by Kent C. Dodds to understand why its preferable to use data attributes rather than traditional css selectors like classes and ids. After mounting the component, to make sure the todos are displayed, we would add a testid data attribute to the unordered list element containing our todo elements, and write expectations on its children.


// App.js ... { (!this.state.editing || this.state.loading) && <ul className="list-group" data-testid="todos-ul"> ... 

// App.test.js test('gets todos when component is mounted and displays them', async () => { const { container, getByTestId } = render(<App todoService={todoService} />); const unorderedListOfTodos = getByTestId('todos-ul'); expect(unorderedListOfTodos.children.length).toBe(2); }); 

If we run this test at this point, it fails. Why is that ? Well that's where the flushPromises function comes in. We need to run our assertion only after the getTodos function from the todos service has resolved with the list of todos.To wait for that promise to resolve, we simply await flushPromises().


// App.test.js test('gets todos when component is mounted and displays them', async () => { const { container, getByTestId } = render(<App todoService={todoService} />); await flushPromises(); const unorderedListOfTodos = getByTestId('todos-ul'); expect(unorderedListOfTodos.children.length).toBe(2); }); 

Alright. That takes care of making sure as soon as the component is mounted, I think a good assertion to add would be to make sure that the todoService.getTodos function is called when the component mounts. This increases our confidence in the fact that the todos are actually coming from an external api.


// App.test.js test('gets todos when component is mounted and displays them', async () => { // Spy on getTodos function const getTodosSpy = jest.spyOn(todoService, 'getTodos'); // Mount the component const { container, getByTestId } = render(<App todoService={todoService} />); // Wait for the promise that fetches todos to resolve so that the list of todos can be displayed await flushPromises(); // Find the unordered list of todos const unorderedListOfTodos = getByTestId('todos-ul'); // Expect that it has two children, since our service returns 2 todos. expect(unorderedListOfTodos.children.length).toBe(2); // Expect that the spy was called expect(getTodosSpy).toHaveBeenCalled();
}); 

Test: Adding todos

Let's write tests for the todo creation process. Again, we are interested in what happens when the user interacts with the application.

We'll start by making sure the Add Todo button is disabled if the user has not typed in enough characters into the input box.


// App.js
// Add a data-testid attribute to the input element, and the button element ... <input type="text" name="todo" className="my-4 form-control" placeholder="Add a new todo" onChange={this.handleChange} value={this.state.newTodo} data-testid="todo-input"
/> <button onClick={this.state.editing ? this.updateTodo : this.addTodo} className="btn-success mb-3 form-control" disabled={this.state.newTodo.length < 5} data-testid="todo-button"
> {this.state.editing ? 'Update todo' : 'Add todo'}
</button> ... 

// App.test.js describe('creating todos', () => { test('the add todo button is disabled if user types in a todo with less than 5 characters', async () => { // Mount the component const { container, getByTestId } = render(<App todoService={todoService} />); // Wait for the promise that fetches todos to resolve so that the list of todos can be displayed await flushPromises(); // Find the add-todo button and the todo-input element using their data-testid attributes const addTodoButton = getByTestId('todo-button'); const todoInputElement = getByTestId('todo-input'); });
}); 

We added data-testid attributes to the button and the input elements, and later in our tests we used our getByTestId helper function to find them.


// App.test.js describe('creating todos', () => { test('the add todo button is disabled if user types in a todo with less than 5 characters, and enabled otherwise', async () => { // Mount the component const { container, getByTestId } = render(<App todoService={todoService} />); // Wait for the promise that fetches todos to resolve so that the list of todos can be displayed await flushPromises(); // Find the add-todo button and the todo-input element using their data-testid attributes const addTodoButton = getByTestId('todo-button'); const todoInputElement = getByTestId('todo-input'); // Expect that at this point when the input value is empty, the button is disabled. expect(addTodoButton.disabled).toBe(true); // Change the value of the input to have four characters todoInputElement.value = 'ABCD'; Simulate.change(todoInputElement); // Expect that at this point when the input value has less than 5 characters, the button is still disabled. expect(addTodoButton.disabled).toBe(true); // Change the value of the input to have five characters todoInputElement.value = 'ABCDE'; Simulate.change(todoInputElement); // Expect that at this point when the input value has 5 characters, the button is enabled. expect(addTodoButton.disabled).toBe(false); });
}); 

Our test gives us assurance of how our user interacts with our application, not necessary how that functionality is being implemented.

Let's proceed further to cover the case when the user actually clicks on the Add todo button:


// App.test.js test('clicking the add todo button should save the new todo to the api, and display it on the list', async () => { const NEW_TODO_TEXT = 'OPEN_PULL_REQUEST'; // Spy on getTodos function const addTodoSpy = jest.spyOn(todoService, 'addTodo'); // Mount the component const { container, getByTestId, queryByText } = render(<App todoService={todoService} />); // Wait for the promise that fetches todos to resolve so that the list of todos can be displayed await flushPromises(); // Find the add-todo button and the todo-input element using their data-testid attributes const addTodoButton = getByTestId('todo-button'); const todoInputElement = getByTestId('todo-input'); // Change the value of the input to have more than five characters todoInputElement.value = NEW_TODO_TEXT; Simulate.change(todoInputElement); // Simulate a click on the addTodo button Simulate.click(addTodoButton); // Since we know this makes a call to the api, and waits for a promise to resolve before proceeding, let's flush it. await flushPromises(); // Let's find an element with the text content of the newly created todo const newTodoItem = queryByText(NEW_TODO_TEXT); // Expect that the element was found, and is a list item expect(newTodoItem).not.toBeNull(); expect(newTodoItem).toBeInstanceOf(HTMLLIElement); // Expect that the api call was made expect(addTodoSpy).toHaveBeenCalled();
}); 

We introduced a new helper function, queryByText, which returns null if an element is not found with the specific text passed into it. This function will help us assert if a new todo was actually added to our current list of todos.

Takeaways

You have now seen how to write mostly integration tests for your react components/applications. Here are some key tips to take away:


  • Your tests should be more inclined to how the user interacts with the application, not necessarily to how the functionality was implemented. For example, avoid checking state changes, the user doesn't know about that.

  • For best practice, avoid getting instances of the rendered container, the user does not interact with it, neither should your tests.

  • Always perform full renders, it gives you more confidence that these components actually work correctly in the real world. True story, no component is ever shallow mounted in the real world.

  • This tutorial does not aim at disparaging the importance of unit tests, but at encouraging more integration tests. When writing tests for your application, the testing trophy might be a good guide for you to consider.


Tag cloud