Introduction
This is the third tutorial in our series on testing a React and Redux application with AVA.
In this tutorial, we will build the actual UI for our todo application using React. We’ll connect our React components to the Redux store, test them using AVA and Airbnb’s Enzyme, and see how React makes it easy to write both isolated unit tests and full integration tests.
Building the UI
Our application currently has a dataflow set up with Redux, but there is no way for the user to interact with it. Let’s create some basic components using React.
Creating a Todo Item
We’re going to start off by creating a component for a single todo. This component will output the text and, if completed, have a strikethrough. When we click on it, it will execute a callback with its id. We will use this behavior later for dispatching the toggleTodo
action that we created in the previous tutorial. Let’s begin:
// src/Todo.js
import React, { PropTypes, Component } from 'react';
class Todo extends Component {
constructor(props) {
super(props)
this._onClick = this._onClick.bind(this);
}
_onClick() {
this.props.onToggle(this.props.id);
}
render() {
return (
<li
style={{
textDecoration: this.props.completed ? 'line-through' : 'none'
}}
onClick={this._onClick}
>
{this.props.text}
</li>
);
}
}
Todo.propTypes = {
id: PropTypes.number.isRequired,
text: PropTypes.string.isRequired,
completed: PropTypes.bool.isRequired,
onToggle: PropTypes.func.isRequired,
};
export default Todo;
Defining expected prop
types in components makes it easier for us to catch unexpected types faster, because we’ll know exactly which component is passing the unexpected props.
In the component’s constructor, we’re binding the _onClick
method to the component in order to have access to the props, i.e. to the id of the todo.
Todo List
Once we have defined our todo item, we can create another component, which will be a list of todos. This component will be connected to the Redux store because it has to have access to the array of todos. It should also be able to dispatch the toggleTodo
action.
Redux is an independent state management library that can be integrated with any framework, which means that it does not provide React bindings out-of-the-box. So, we need to install react-redux
:
npm install --save react-redux
It exports a component called Provider
. We should use it to wrap our main component App
, and pass it our Redux store:
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from "react-redux";
import App from './App';
import './index.css';
import configureStore from './configureStore';
import { toggleTodo } from './actions';
const store = configureStore({
todos: [
{ id: 0, completed: false, text: 'buy milk' },
{ id: 1, completed: false, text: 'walk the dog' },
{ id: 2, completed: false, text: 'study' }
]
});
store.dispatch(toggleTodo(1));
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
Using Provider
is optional, but recommended if you don’t want to pass the store every time you connect a component to it.
Another thing that react-redux
exports is a function called connect
. We will use it to connect our component to the store. Let’s create the component for listing our todos:
// src/TodoList.js
import React, { PropTypes } from 'react';
import { connect } from 'react-redux';
import Todo from './Todo';
import { toggleTodo } from './actions';
const TodoList = props => (
<ul style={{ textAlign: 'left' }}>
{props.todos.map(todo => (
<Todo
key={todo.id}
{...todo}
onToggle={props.toggleTodo}
/>
))}
</ul>
);
TodoList.propTypes = {
todos: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.number.isRequired,
text: PropTypes.string.isRequired,
completed: PropTypes.bool.isRequired,
})).isRequired,
toggleTodo: PropTypes.func.isRequired
};
const mapStateToProps = state => {
return {
todos: state.todos
};
};
const actionCreators = {
toggleTodo
};
export default connect(
mapStateToProps,
actionCreators
)(TodoList);
Since we don’t need any callbacks or component states in TodoList
, we were able to use a functional component, which is much shorter to type.
Add Todos to the App
Finally, we can include TodoList
in our App
component:
// src/App.js
import React, { Component } from 'react';
import TodoList from './TodoList';
import logo from './logo.svg';
import './App.css';
class App extends Component {
render() {
return (
<div className="App">
<div className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<h2>Welcome to React</h2>
</div>
<TodoList />
</div>
);
}
}
export default App;
Run npm start
to see if the todos have rendered correctly and if clicking on them toggles the strikethrough.
Testing
Now that we have built a basic UI, we can start testing. First, we’ll install a couple of dependencies:
- Enzyme for rendering React components,
- Test Utilities because Enzyme depends on them,
- Sinon.JS for testing callbacks, and
- redux-mock-store for testing dispatched actions.
npm install --save-dev enzyme react-addons-test-utils sinon redux-mock-store
Rendering in Enzyme
We’re using Enzyme because its API is simpler and more powerful than Test Utilities. It exposes three types of rendering React components:
import { shallow } from 'enzyme'; // shallow rendering
import { mount } from 'enzyme'; // full DOM rendering
import { render } from 'enzyme'; // static rendering
Each type renders a React component in a different way. Shallow rendering is ideal for testing components in isolation, full DOM rendering is better for testing integration, and static rendering is great for making assertions about the rendered HTML.
Testing the App
By running npm test
we can see that we broke a test:
3 passed
1 failed
1. App βΊ renders without crashing
failed with "Could not find "store" in either the context or props of
"Connect(TodoList)". Either wrap the root component in a <Provider>, or
explicitly pass "store" as a prop to "Connect(TodoList)"."
This failure is coming from App.test.js
. The error message is telling us that we need to wrap App
in Provider
, so that connect
knows which store to connect to:
// src/App.test.js
import test from 'ava';
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import App from './App';
const mockStore = configureStore();
const initialState = { todos: [] };
test('renders without crashing', t => {
const div = document.createElement('div');
const store = mockStore(initialState);
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
div
);
});
We created a store using redux-mock-store. It’s empty because this is a smoke test that checks if our application is rendering successfully.
Let’s see if we managed to fix the test. After running npm test
, you should see the following output:
4 passed
Testing the Todo Item
We’ll use shallow rendering for testing the Todo
component:
// src/Todo.test.js
import React from 'react';
import test from 'ava';
import sinon from 'sinon';
import { shallow } from 'enzyme';
import Todo from './Todo';
test('outputs given text', t => {
const wrapper = shallow(
<Todo
id={1}
text="buy milk"
completed={false}
onToggle={() => {}}
/>
);
t.regex(wrapper.render().text(), /buy milk/);
});
test('has a strikethrough if completed', t => {
const wrapper = shallow(
<Todo
id={1}
text="buy milk"
completed
onToggle={() => {}}
/>
);
t.is(wrapper.prop('style').textDecoration, 'line-through');
});
test('executed callback when clicked with its id', t => {
const onToggle = sinon.spy();
const wrapper = shallow(
<Todo
id={1}
text="buy milk"
completed={false}
onToggle={onToggle}
/>
);
wrapper.simulate('click');
t.true(onToggle.calledWith(1));
});
The first two times, we’re passing an empty function as onToggle
because the component requires it, but the third time we’re actually testing that callback, so we’re creating a spy with Sinon.JS and checking if it had been called with the expected value.
Testing the Todo List
The TodoList
component is different β it’s connected to the Redux store. By using redux-mock-store we can test if the toggleTodo
action is dispatched when we simulate a click on a Todo
component:
// src/TodoList.test.js
import test from 'ava';
import React from 'react';
import { mount } from 'enzyme';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import TodoList from './TodoList';
import { toggleTodo } from "./actions";
const mockStore = configureStore();
const initialState = {
todos: [
{ id: 0, completed: false, text: 'buy milk' },
{ id: 1, completed: false, text: 'walk the dog' },
{ id: 2, completed: false, text: 'study' }
]
};
test('dispatches toggleTodo action', t => {
const store = mockStore(initialState);
const wrapper = mount(
<Provider store={store}>
<TodoList />
</Provider>
);
wrapper.find('Todo').at(0).simulate('click');
t.deepEqual(store.getActions(), [toggleTodo(0)]);
});
Note that TodoList
exports a connected component. If we wanted to test it in isolation, we could add a named export to provide access to the pure component:
// src/TodoList.js
// ...
export const TodoList = props =>
// ...
This would allow us to access the original component like this:
import { TodoList } from './TodoList';
Run the Tests
To check if our tests pass, let’s run npm test
. If everything went well, you should see the following output:
8 passed
Conclusion
As you can see, we can cover a lot of functionality using only unit tests, which are really fast. There are many ways to test a React application, so you will need to decide what is the best approach to testing specific components. Sometimes a component is too simple to test. Other times, a component might require multiple layers of testing.
Keep in mind that these unit tests are just that, unit tests, so they won’t be able to catch specific cross-browser bugs. You will still need to set up end-to-end testing, but writing unit tests will definitely help you catch some bugs earlier.
If you have any questions or comments, feel free to leave them in the section below.
P.S. On a related note, if you want to speed up CI for your React project, watch this video by LearnCode.academy about setting up a project on Semaphore.