React has taken the frontend world by storm, and for a good reason. It provides an intuitive model for building data-driven user interfaces. React enables us to write declarative components describing the user interface and provide the data needed to render these interfaces. It then efficiently updates the DOM when this data changes. Redux, on the other hand, enables managing the data that React needs to render interfaces. It offers a predictable way to structure and update the data in frontend applications and pairs quite nicely with React.
Now that we have an idea of how React and Redux work together, we will explore how to go about writing tests for an existing React and Redux application. We will then configure continuous integration with CircleCI, to automate the testing and ensure that any new code we add does not break the existing functionality.
To get the most out of this article, we assume a basic familiarity with React and Redux. If you’re new to React, the Intro to React tutorial provides a good overview of how React works. The Learn Redux section in the Redux docs provides lots of learning resources and the Redux examples page is also a good resource for exploring sample apps and getting a feel for how Redux works.
With this info out of the way, let’s go ahead and get started.
Getting started
In this article, we are going to use the Reddit API example app in the Redux docs as our example app. We’ll use this application to demonstrate how to go about adding tests to a real app, similar to what you would encounter while building your own apps.
Before adding the tests, we need to explore what the app currently does. The best way to do this is to clone the app and run it locally. Let’s run the following commands to clone the application, install its dependencies, and finally run it:
git clone https://github.com/reactjs/redux.git
cd redux/examples/async
git checkout db5f8d1
npm install
npm start
Note: The git checkout
command checks out the redux
repo to the latest commit at the time of this writing i.e. db5f8d1
. By the time you read this, there might be some more changes made to the repo, so running this command ensures that we all have the same starting point for the purposes of following this article.
Once the app is running, you should see a screen similar to this one:
Walkthrough of the application’s functionality
The main functionality of the app is to display the current headlines in a selected subreddit, by fetching this data from the Reddit API. It enables the user to select the subreddit they would like to see headlines for, which are then loaded and displayed on the screen. It also enables users to update the data displayed for the currently selected subreddit, by clicking the Refresh button.
While this may seem like a simple app, it has all the components we need for most real-world apps, including:
- Fetching data from an API
- User interactions
- Synchronous and asynchronous actions
- Presentational and container components
Keeping track of our progress with Git and GitHub
Since we will be making changes to the app, we will need to track these changes with Git. We will create a new repository, which will help us keep track of our changes separately from the redux
repository. Let’s create a new git repo in the async
folder by running:
git init
This new repository will only track changes in the async
folder, ignoring the rest of the code we pulled when we cloned the redux
repository.
At this point, let’s make an initial commit in our new git repository marking the point where we imported the code from redux
:
git add .
git commit -m "Import async example from redux"
We will also need to create a new repository on GitHub where we will push our code. This will come in handy when we will need to integrate with CircleCI later on. Go ahead and create a new GitHub repo and push the code we have to the repo.
This guide is an excellent resource you can refer to if you’re not yet familiar with GitHub.
This concludes our exploration of the Reddit API app. By now you should have an understanding of what the app does and you should have a copy of the app on your own GitHub account. We will now proceed to add the tests.
Testing React components
Test setup
We are going to need some tools to get started with testing:
If you check the package.json
file, you will notice we already have a test
command configured.
Let’s start with removing the extra flags given in that command, i.e. --env=node --passWithNoTests
, as we are not going to need those going forward.
After removing the flags, the test command should look like this:
"test": "react-scripts test"
react-scripts
comes with jest
installed and configured, hence we won’t need to install it again. However, let’s install enzyme
now, along with its adapter, for our version of React:
npm install --save-dev enzyme enzyme-adapter-react-16
We are also going to need to configure enzyme
to use the adapter. react-scripts
supports configuring testing tools in a src/setupTests.js
file.
So go ahead and create that file and add the following contents:
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({ adapter: new Adapter() });
In our tests, we are going to make use of snapshot testing to track changes to components. This technique enables taking snapshots of our components, and when the component’s rendered output changes, it helps us to easily detect the changes made. The snapshots are also readable, so it’s an easier way of verifying that components render the expected output.
To enable this, we need to install the enzyme-to-json
package to convert our React components to a snapshot during testing:
npm install --save-dev enzyme-to-json
We also need to configure jest
to use this package as the snapshot serializer. We’ll configure this in package.json
by adding:
"jest": {
"snapshotSerializers": [
"enzyme-to-json/serializer"
]
}
Now we are set up to begin the actual testing.
Component tests
Let’s start off by writing tests for the App
component. A good starting point is adding a snapshot test to ensure that the component renders the expected output, given the required props
.
To start off, we need to import the App
component. However, the only export in App.js
is the redux-connected version of the component, i.e.
export default connect(mapStateToProps)(App)
. We are interested in testing the rendering of the component and not its interaction with redux
, therefore, we will need to also export the underlying App
component and we will do this by adding this snippet to App.js
:
export { App };
To recap, this is how the App.js
file should look right now:
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { selectSubreddit, fetchPostsIfNeeded, invalidateSubreddit } from '../actions'
import Picker from '../components/Picker'
import Posts from '../components/Posts'
class App extends Component {
static propTypes = {
selectedSubreddit: PropTypes.string.isRequired,
posts: PropTypes.array.isRequired,
isFetching: PropTypes.bool.isRequired,
lastUpdated: PropTypes.number,
dispatch: PropTypes.func.isRequired
}
componentDidMount() {
const { dispatch, selectedSubreddit } = this.props
dispatch(fetchPostsIfNeeded(selectedSubreddit))
}
componentWillReceiveProps(nextProps) {
if (nextProps.selectedSubreddit !== this.props.selectedSubreddit) {
const { dispatch, selectedSubreddit } = nextProps
dispatch(fetchPostsIfNeeded(selectedSubreddit))
}
}
handleChange = nextSubreddit => {
this.props.dispatch(selectSubreddit(nextSubreddit))
}
handleRefreshClick = e => {
e.preventDefault()
const { dispatch, selectedSubreddit } = this.props
dispatch(invalidateSubreddit(selectedSubreddit))
dispatch(fetchPostsIfNeeded(selectedSubreddit))
}
render() {
const { selectedSubreddit, posts, isFetching, lastUpdated } = this.props
const isEmpty = posts.length === 0
return (
<div>
<Picker value={selectedSubreddit}
onChange={this.handleChange}
options={[ 'reactjs', 'frontend' ]} />
<p>
{lastUpdated &&
<span>
Last updated at {new Date(lastUpdated).toLocaleTimeString()}.
{' '}
</span>
}
{!isFetching &&
<button onClick={this.handleRefreshClick}>
Refresh
</button>
}
</p>
{isEmpty
? (isFetching ? <h2>Loading...</h2> : <h2>Empty.</h2>)
: <div style={{ opacity: isFetching ? 0.5 : 1 }}>
<Posts posts={posts} />
</div>
}
</div>
)
}
}
const mapStateToProps = state => {
const { selectedSubreddit, postsBySubreddit } = state
const {
isFetching,
lastUpdated,
items: posts
} = postsBySubreddit[selectedSubreddit] || {
isFetching: true,
items: []
}
return {
selectedSubreddit,
posts,
isFetching,
lastUpdated
}
}
export default connect(mapStateToProps)(App)
export { App };
By convention, jest
will find test files with names ending in .test.js
under any folder named __tests__
. Therefore, under the containers
folder, let’s create the __tests__
directory and under it, we’ll create an App.test.js
file.
Since App
is exported now, we can now import it as follows in our test file:
import { App } from '../App'
Since we are testing the App
component independently of redux
, anything that is currently provided by redux
, i.e. the component’s props
, will have to be provided explicitly. Let’s add some rendering tests to see how this works in practice. Inside App.test.js
, let’s add our first test:
import React from 'react'
import { shallow } from 'enzyme'
import toJson from 'enzyme-to-json'
import { App } from '../App'
describe('App', () => {
it('renders without crashing given the required props', () => {
const props = {
isFetching: false,
dispatch: jest.fn(),
selectedSubreddit: 'reactjs',
posts: []
}
const wrapper = shallow(<App {...props} />)
expect(toJson(wrapper)).toMatchSnapshot()
})
})
In this test, we want to verify that the App
renders given all the required props
. We provide the props
thus simulating what redux
will do for us in the actual app. jest
provides a mock function which we can use in place of an actual function in our tests. For this case, we use this to mock the dispatch
function. This function will be called in place of the actual dispatch
function in our tests.
You can run the tests with the npm test
command. You will notice the jest
test runner kick in and run the tests and print out a test run summary. You should also see this message:
1 snapshot written from 1 test suite.
If you open up src/containers/__tests__/__snapshots__/App.test.js.snap
you should see a snapshot version of the component that shows the component’s render output.
Let’s add a couple more tests to test the rendering behavior of App
. First, we will add a test to ensure that the selectedSubreddit
prop is always passed to the Picker
component. We will add this test just below our existing tests:
// Add this import
import Picker from '../../components/Picker'
it('sets the selectedSubreddit prop as the `value` prop on the Picker component', () => {
const props = {
isFetching: false,
dispatch: jest.fn(),
selectedSubreddit: 'reactjs',
posts: []
}
const wrapper = shallow(<App {...props} />)
// Query for the Picker component in the rendered output
const PickerComponent = wrapper.find(Picker)
expect(PickerComponent.props().value).toBe(props.selectedSubreddit)
})
This test demonstrates how we can easily use enzyme
to query nested components, i.e. Picker
, and assert that it is rendered with the correct props
. I highly recommend digging into enzyme’s docs to see the various testing utilities it provides.
Next, we will add another test to check for elements that are rendered based on some condition. For our case, we will verify that the Refresh button is rendered when the isFetching
prop is false
:
it('renders the Refresh button when the isFetching prop is false', () => {
const props = {
isFetching: false,
dispatch: jest.fn(),
selectedSubreddit: 'reactjs',
posts: []
}
const wrapper = shallow(<App {...props} />)
expect(wrapper.find('button').length).toBe(1)
})
Finally, let’s add a test that deals with some user interaction. Let’s verify that when the Refresh button is clicked, it dispatches the correct actions:
// Add this import
import * as actions from '../../actions';
it('handleRefreshClick dispatches the correct actions', () => {
const props = {
isFetching: false,
dispatch: jest.fn(),
selectedSubreddit: 'reactjs',
posts: []
}
// Mock event to be passed to the handleRefreshClick function
const mockEvent = {
preventDefault: jest.fn()
}
// Mock the actions we expect to be called
actions.invalidateSubreddit = jest.fn();
actions.fetchPostsIfNeeded = jest.fn();
const wrapper = shallow(<App {...props} />)
// Call the function on the component instance, passing the mock event
wrapper.instance().handleRefreshClick(mockEvent);
expect(mockEvent.preventDefault).toHaveBeenCalled();
expect(props.dispatch.mock.calls.length).toBe(3);
expect(actions.invalidateSubreddit.mock.calls.length).toBe(1);
expect(actions.fetchPostsIfNeeded.mock.calls.length).toBe(2);
})
We need to start off with importing actions
. This is needed because we will mock some of the functions it provides. In the test, we provide the usual props
and then a mockEvent
object, which we will use to simulate a click event sent by the browser when the button is clicked. The mocked event needs to contain a preventDefault
property, which should be a function as it will be called inside the handleRefreshClick
function. If this is not provided, we would get an error informing us of the missing property, i.e. e.preventDefault is not a function
.
Once we render the component using shallow
, we then manually call handleRefreshClick
, passing in the mock event to simulate what will happen when the function is called in our app. We assert the following properties of our app:
event.preventDefault
should have been called once.props.dispatch
should have been called 3 times.- Once on
componentDidMount
since lifecycle hooks are executed by the shallow rendering API - Twice in the
handleRefreshClick
function
- Once on
actions.invalidateSubreddit
should have been called once.actions.fetchPostsIfNeeded
should have been called twice.- The first call happens in
componentDidMount
- The second call happens inside
handleRefreshClick
- The first call happens in
To make sure our expectations of the componentDidMount
function calls are correct, we can include these assertions right before the handleRefreshClick
function call.
const wrapper = shallow(<App {...props} />)
// The next assertions are for functions called in componentDidMount
expect(props.dispatch.mock.calls.length).toBe(1);
expect(actions.fetchPostsIfNeeded.mock.calls.length).toBe(1);
wrapper.instance().handleRefreshClick(mockEvent);
//... rest of test omitted for brevity
At this point, we’ve tested the most challenging parts of the code and these tests should provide a good starting point to comfortably add tests for any other component functionality.
Some potential tests we could add include:
- The different rendering paths, e.g. when
isFetching
is true or whenlastUpdated
is provided - What should happen when the
onChange
function is called - What happens when
App
receives a newselectedSubreddit
prop
What are the relevant tests for the Posts
and Picker
components? I encourage you to explore this some more and try to write out some of those tests.
Testing Redux functionality
In this section, we will add some tests for the redux
related parts of our application, specifically the actions and the reducers.
Testing action creators
We will start off with the action creators. In this app, we have synchronous action creators, which return plain objects, and asynchronous action creators, which are used in conjunction with redux-thunk
to enable async operations that don’t immediately produce a result, like fetching data. We will cover how to test both.
For reference, this is how our src/actions/index.js
file looks:
export const REQUEST_POSTS = 'REQUEST_POSTS'
export const RECEIVE_POSTS = 'RECEIVE_POSTS'
export const SELECT_SUBREDDIT = 'SELECT_SUBREDDIT'
export const INVALIDATE_SUBREDDIT = 'INVALIDATE_SUBREDDIT'
export const selectSubreddit = subreddit => ({
type: SELECT_SUBREDDIT,
subreddit
})
export const invalidateSubreddit = subreddit => ({
type: INVALIDATE_SUBREDDIT,
subreddit
})
export const requestPosts = subreddit => ({
type: REQUEST_POSTS,
subreddit
})
export const transformResponseBody = (json) => {
return json.data.children.map(child => child.data);
}
export const receivePosts = (subreddit, json) => ({
type: RECEIVE_POSTS,
subreddit,
posts: transformResponseBody(json),
receivedAt: Date.now()
})
const fetchPosts = subreddit => dispatch => {
dispatch(requestPosts(subreddit))
return fetch(`https://www.reddit.com/r/${subreddit}.json`)
.then(response => response.json())
.then(json => dispatch(receivePosts(subreddit, json)))
}
const shouldFetchPosts = (state, subreddit) => {
const posts = state.postsBySubreddit[subreddit]
if (!posts) {
return true
}
if (posts.isFetching) {
return false
}
return posts.didInvalidate
}
export const fetchPostsIfNeeded = subreddit => (dispatch, getState) => {
if (shouldFetchPosts(getState(), subreddit)) {
return dispatch(fetchPosts(subreddit))
}
}
Let’s start by creating the necessary files to enable testing. Inside src/actions/
let’s create a folder named __tests__
and inside that, let’s create a file called actions.test.js
.
We will get started with the synchronous action creators, which are simply pure functions which take some data and return an action object. We should check that given the necessary arguments, the action creator returns the correct action. Let’s demonstrate this with a test for the selectSubreddit
action creator which accepts a subreddit
as an argument then returns an action.
import * as actions from '../index'
describe('actions', () => {
const subreddit = 'reactjs'
describe('selectSubreddit', () => {
it('should create an action with a given subreddit', () => {
const expectedAction = {
type: actions.SELECT_SUBREDDIT,
subreddit
}
expect(actions.selectSubreddit(subreddit)).toEqual(expectedAction)
})
})
})
For most synchronous action creators, that’s all we need to do.
Let’s also add a test for the receivePosts
action creator too, since it will make our work easier once we proceed to test the async action creators. This is how the function looks currently:
export const receivePosts = (subreddit, json) => ({
type: RECEIVE_POSTS,
subreddit,
posts: json.data.children.map(child => child.data),
receivedAt: Date.now()
})
In the returned action, we have a transformation happening in the posts
property. Let’s extract this into a new function call that takes the json
argument and does the transformation we need. So our new version of the receivePosts
function will be as follows. Also, take note that we have to export the new helper function so that we can access it in the tests later on.
export const transformResponseBody = (json) => {
return json.data.children.map(child => child.data);
}
export const receivePosts = (subreddit, json) => ({
type: RECEIVE_POSTS,
subreddit,
posts: transformResponseBody(json),
receivedAt: Date.now()
})
In addition, you’ll notice that in the returned action, there’s a receivedAt
property, which returns Date.now()
. In our test, we will skip testing this property since it changes each time the function is called. There are ways to test this, for example, mocking the Date.now
function and making it return a specific return value, but for the purposes of this article, we’ll just skip matching that property.
Now that we’ve selected the scope of what we need to do, let’s add the test for the receivePosts
action creator now:
describe('actions', () => {
const subreddit = 'reactjs'
// Add the mockJSON response
const mockJSON = {
data: {
children: [{ data: { title: "Post 1" } }, { data: { title: "Post 2" } }]
}
};
// ... other tests...
describe('receivePosts', () => {
it('should create the expected action', () => {
const expectedAction = {
type: actions.RECEIVE_POSTS,
subreddit,
posts: actions.transformResponseBody(mockJSON),
}
expect(actions.receivePosts(subreddit, mockJSON)).toMatchObject(expectedAction);
})
})
})
Take note that we’re using toMatchObject
to match just a subset of the returned action object, which excludes matching the receivedAt
key.
The tests for the rest of the synchronous action creators will follow the same process where given some data, we test that the correct action is returned.
Let’s proceed to test async action creators, and specifically, the fetchPosts
action creator.
The first thing we need to do is to export the function, and we’ll do this by adding export
to the function so it becomes:
export const fetchPosts = subreddit => dispatch => {
dispatch(requestPosts(subreddit))
return fetch(`https://www.reddit.com/r/${subreddit}.json`)
.then(response => response.json())
.then(json => dispatch(receivePosts(subreddit, json)))
}
We’ll also need to install a few new packages:
npm install --save-dev fetch-mock redux-mock-store
We’ll use fetch-mock
to mock HTTP requests made using fetch
and we’ll use redux-mock-store
to help us create a mock store to use in the tests. Let’s add the tests as follows:
// Add the new imports
import thunk from 'redux-thunk'
import fetchMock from 'fetch-mock'
import configureMockStore from 'redux-mock-store'
const middlewares = [thunk]
const mockStore = configureMockStore(middlewares)
describe('actions', () => {
const subreddit = 'reactjs'
const mockJSON = {
data: {
children: [{ data: { title: "Post 1" } }, { data: { title: "Post 2" } }]
}
};
// ... other tests...
describe("fetchPosts", () => {
afterEach(() => {
// restore fetch() to its native implementation
fetchMock.restore()
})
it("creates REQUEST_POSTS and RECEIVE_POSTS when fetching posts", () => {
// Mock the returned data when we call the Reddit API
fetchMock.getOnce(`https://www.reddit.com/r/${subreddit}.json`, {
body: mockJSON
})
// The sequence of actions we expect to be dispatched
const expectedActions = [
{ type: actions.REQUEST_POSTS },
{
type: actions.RECEIVE_POSTS,
subreddit,
posts: actions.transformResponseBody(mockJSON)
}
]
// Create a store with the provided object as the initial state
const store = mockStore({})
return store.dispatch(actions.fetchPostsIfNeeded(subreddit)).then(() => {
expect(store.getActions()).toMatchObject(expectedActions)
})
})
})
})
We start with all the necessary imports, including redux-thunk
. For this case, we need to configure an actual store, and this means that we’ll also apply the middleware to the mock store as well.
Moving on, we have an afterEach
function which runs after each test and ensures that we restore the original fetch
implementation so that our mock implementation isn’t used in other tests.
In the test, we start by mocking the request we expect to be made and provide a mock body
that will be returned as the response body.
We then define the sequence of actions we expect to be taken when we call fetchPosts
.
This sequence implies that when fetchPosts
is dispatched, it should generate a REQUEST_POSTS
action, then RECEIVE_POSTS
with the posts for the requested subreddit. For this, we’ll also exclude the receivedAt
property in the RECEIVE_POSTS
action, as in the previous test, and also add the transformed response body in the posts
key, as we did earlier.
Next, we create the store, giving it some initial state then dispatch the fetchPosts
.
Finally, we assert that the list of actions applied to the store should match the sequence in our expectedActions
array.
Re-running our tests at this point should confirm that everything passes. This concludes our action creators testing and we’re going to look at how to test reducers next.
Testing reducers The reducers are at the heart of redux since they are how we update the state of our whole application. The reducer tests should help us verify that each of our dispatched actions updates the state as expected.
Here are the contents of the reducers/index.js
file that we’re going to test:
import { combineReducers } from 'redux'
import {
SELECT_SUBREDDIT, INVALIDATE_SUBREDDIT,
REQUEST_POSTS, RECEIVE_POSTS
} from '../actions'
const selectedSubreddit = (state = 'reactjs', action) => {
switch (action.type) {
case SELECT_SUBREDDIT:
return action.subreddit
default:
return state
}
}
const posts = (state = {
isFetching: false,
didInvalidate: false,
items: []
}, action) => {
switch (action.type) {
case INVALIDATE_SUBREDDIT:
return {
...state,
didInvalidate: true
}
case REQUEST_POSTS:
return {
...state,
isFetching: true,
didInvalidate: false
}
case RECEIVE_POSTS:
return {
...state,
isFetching: false,
didInvalidate: false,
items: action.posts,
lastUpdated: action.receivedAt
}
default:
return state
}
}
const postsBySubreddit = (state = { }, action) => {
switch (action.type) {
case INVALIDATE_SUBREDDIT:
case RECEIVE_POSTS:
case REQUEST_POSTS:
return {
...state,
[action.subreddit]: posts(state[action.subreddit], action)
}
default:
return state
}
}
const rootReducer = combineReducers({
postsBySubreddit,
selectedSubreddit
})
export default rootReducer
In our reducer file, we have two reducers, each managing its own part of the state, which will eventually be merged into a single root reducer using combineReducers
.
We’re going to export the individual reducer functions to make testing more convenient, by adding this snippet to reducers/index.js
export { postsBySubreddit, selectedSubreddit }
Let’s go ahead and create a __tests__
directory under reducers
then create a reducers.test.js
file inside that directory, which is where our tests will go.
Let’s test the selectedSubreddit
reducer first as it’s the simpler of the two.
import {
SELECT_SUBREDDIT, INVALIDATE_SUBREDDIT,
REQUEST_POSTS, RECEIVE_POSTS
} from '../../actions'
import { postsBySubreddit, selectedSubreddit } from '../index'
describe('app reducer', () => {
describe('selectedSubreddit', () => {
it('should return the default state', () => {
expect(selectedSubreddit(undefined, {})).toBe('reactjs')
})
it('should update the selectedSubreddit', () => {
const subreddit = 'frontend'
const action = {
type: SELECT_SUBREDDIT,
subreddit
}
expect(selectedSubreddit(undefined, action)).toBe(subreddit)
})
})
})
Our first test checks that the selectedSubreddit
reducer correctly initialises the state. When given an undefined
state, or an empty action, it should return the default value, which is set to reactjs
.
The next check verifies that when the reducer receives a valid action object, it correctly updates the state.
Let’s proceed to the postsBySubreddit
reducer.
describe('postsBySubreddit', () => {
const subreddit = 'frontend'
it('should return the default state', () => {
expect(postsBySubreddit(undefined, {})).toEqual({})
})
it('should handle INVALIDATE_SUBREDDIT', () => {
const action = {
type: INVALIDATE_SUBREDDIT,
subreddit
}
expect(postsBySubreddit({}, action)).toEqual({
[subreddit]: {
isFetching: false,
didInvalidate: true,
items: []
}
})
})
it('should handle REQUEST_POSTS', () => {
const action = {
type: REQUEST_POSTS,
subreddit
}
expect(postsBySubreddit({}, action)).toEqual({
[subreddit]: {
isFetching: true,
didInvalidate: false,
items: []
}
})
})
it('should handle RECEIVE_POSTS', () => {
const posts = ['post 1', 'post 2']
const receivedAt = Date.now()
const action = {
type: RECEIVE_POSTS,
subreddit,
posts,
receivedAt
}
expect(postsBySubreddit({}, action)).toEqual({
[subreddit]: {
isFetching: false,
didInvalidate: false,
items: posts,
lastUpdated: receivedAt
}
})
})
})
We should start by testing that it initializes the state correctly, and in this case, the default state is an empty object, as you can see in the first test.
The tests for the rest of the actions are quite similar, where we verify that given an action, the reducer returns the expected state update. The subreddit
should be set as the key of the returned object and the nested object should be updated according to the rules we have in the reducer.
You will notice that the common theme with reducers is that given a particular set of inputs, that is, the initial state and an action, a new state should be returned. We do all of our assertions on the returned state to ensure it is what we expect.
With these tests, we have covered the various parts of a React and redux application that you would need to test in a typical app.
Continuous integration with GitHub and CircleCI
At this point, we’ve covered adding tests for the various parts of a React and Redux application. Let’s now see how to add continuous integration with CircleCI. Continuous integration helps us ensure that any changes we make to the code don’t break any existing functionality. Our tests will be run any time we push new code, whether by adding new commits to an existing branch or by opening a pull request to merge a new branch into the main branch. This helps in catching bugs early in the development process.
CircleCI configuration
The first thing we need to add is a configuration file that will tell CircleCI how to test our application. The config file needs to be in a .circleci
directory in our root folder and should be named config.yml
.
For our application, here’s the config file we’ll use:
version: 2
jobs:
build:
working_directory: ~/redux-async
docker:
- image: circleci/node:8
steps:
- checkout
- restore_cache:
key: npm-cache-v1-{{ checksum "package-lock.json" }}
- run:
name: Install Dependencies
command: npm ci
- save_cache:
key: npm-cache-v1-{{ checksum "package-lock.json" }}
paths:
- /home/circleci/.npm
- run:
name: Run Tests
command: npm test
Let’s go over some of the concepts covered here:
- The docker image specifies the base docker container we’re going to use. In our case, it will be a container pre-installed with Node.js version 8. All the commands that follow will be run in an instance of this container image.
- The checkout step checks out the source code to the working directory.
- CircleCI supports caching dependencies so we’re taking advantage of this to cache our
npm
dependencies. - The
restore_cache
step restores any cache that is available from a previous build. - In the
run
step, we usenpm ci
to install our project dependencies. - The
save_cache
step is where we save our cache of thenpm
dependencies, specifically the/home/circleci/.npm
folder, which is where thenpm
cache is stored when we usenpm ci
to install the dependencies. - We create a cache that uses a checksum of the contents of the
package-lock.json
file, so if this file changes, a new cache will be created. - It’s also worth noting that caches are immutable in CircleCI, meaning that once a cache is created, there’s no changing it afterwards. To change a cache, you need to create a new one entirely. The
v1
part of our cache key helps us to invalidate the cache. So in this case, if we needed to manually force the cache to be re-created, we could change that tov2
. - The final command is the actual test command which runs our tests.
To get a much better overview of the CircleCI config format and all the options available for a JavaScript project, you can refer to /docs/2.0/language-javascript/.
Integrating CircleCI and GitHub
At this point, let’s make sure we’ve pushed all our changes to the GitHub repository we created earlier. We are now going to integrate CircleCI for continuous testing of our code when we make any new changes.
Here’s how to add the project to CircleCI:
- Create a new account on CircleCI if you have not already created one.
-
Once you are logged in, ensure your account is selected on the top left corner.
- Click Add Projects.
-
On the next screen, search for the name of your GitHub repository then click Set Up Project next to it.
-
On the next page, scroll down to Next Steps and click Start Building.
- CircleCI will now run our test steps and in a short while, you should see a successful build. 🎉
With our CI process set up, any new commit that is pushed in the repository will trigger a test run to ensure we don’t introduce changes that break the build. In cases where we might introduce changes that make the tests fail, we will be notified and can track exactly which commit causes the tests to fail.
Conclusion
This concludes our exploration of adding tests to a real-world React and Redux application. It is my hope that the concepts covered here will be helpful and will set you up for success when working on similar applications in the future.
The following resources were very helpful and will definitely help out in providing more information on testing React and redux:
Kevin Ndung’u is a software developer and open source enthusiast currently working as a software engineer at Andela. He is passionate about sharing his knowledge through blog posts and open source code. When not building web applications, you can find him watching a game of soccer.