├── .eslintrc
├── .gitignore
├── .travis.yml
├── index.html
├── package.json
├── readme.md
├── server.js
├── src
├── actions
│ └── index.js
├── components
│ ├── App.js
│ ├── Todo.js
│ └── TodoList.js
├── configureStore.js
├── index.js
├── reducers
│ ├── index.js
│ └── todos.js
├── sagas
│ └── index.js
├── services
│ └── api.js
└── utils
│ └── call-api.js
├── test
├── .eslintrc
├── action.spec.js
├── components
│ ├── Todo.spec.js
│ └── TodoList.spec.js
├── helpers
│ └── setup.js
├── reducers
│ └── todos.spec.js
├── sagas
│ └── watchToggleTodo.spec.js
├── selectors.spec.js
├── services
│ └── api.spec.js
└── utils
│ └── call-api.spec.js
└── webpack.config.js
/.eslintrc:
--------------------------------------------------------------------------------
1 | root: true
2 | extends: airbnb
3 | parserOptions:
4 | ecmaFeatures:
5 | experimentalObjectRestSpread: true
6 | globals:
7 | DEV: false
8 | rules:
9 | no-underscore-dangle: 0
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | *.log
4 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - stable
4 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Unit Testing React & Redux Codebase
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "scripts": {
4 | "start": "node server.js",
5 | "test": "cross-env NODE_PATH=src ava"
6 | },
7 | "babel": {
8 | "presets": [
9 | "es2015",
10 | "react"
11 | ],
12 | "plugins": [
13 | "transform-runtime",
14 | "transform-object-rest-spread"
15 | ]
16 | },
17 | "ava": {
18 | "babel": "inherit",
19 | "require": [
20 | "babel-register",
21 | "ignore-styles",
22 | "./test/helpers/setup"
23 | ]
24 | },
25 | "devDependencies": {
26 | "ava": "^0.15.2",
27 | "babel-loader": "^6.2.4",
28 | "babel-plugin-transform-object-rest-spread": "^6.8.0",
29 | "babel-plugin-transform-runtime": "^6.9.0",
30 | "babel-preset-es2015": "^6.9.0",
31 | "babel-preset-react": "^6.5.0",
32 | "babel-register": "^6.9.0",
33 | "cross-env": "^1.0.8",
34 | "enzyme": "^2.3.0",
35 | "eslint": "^2.11.1",
36 | "eslint-config-airbnb": "^9.0.1",
37 | "eslint-import-resolver-node": "^0.2.0",
38 | "eslint-plugin-ava": "^2.4.0",
39 | "eslint-plugin-import": "^1.8.1",
40 | "eslint-plugin-jsx-a11y": "^1.3.0",
41 | "eslint-plugin-react": "^5.1.1",
42 | "express": "^4.13.4",
43 | "ignore-styles": "^3.0.0",
44 | "json-loader": "^0.5.4",
45 | "nock": "^8.0.0",
46 | "react-addons-test-utils": "^15.1.0",
47 | "redux-logger": "^2.6.1",
48 | "sinon": "^1.17.4",
49 | "webpack": "^1.13.1",
50 | "webpack-dev-middleware": "^1.6.1"
51 | },
52 | "dependencies": {
53 | "babel-runtime": "^6.9.2",
54 | "humps": "^1.1.0",
55 | "isomorphic-fetch": "^2.2.1",
56 | "react": "^15.1.0",
57 | "react-dom": "^15.1.0",
58 | "react-redux": "^4.4.5",
59 | "redux": "^3.5.2",
60 | "redux-actions": "^0.10.0",
61 | "redux-saga": "^0.10.5"
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Testing a React & Redux Codebase [](https://travis-ci.org/silvenon/testing-react-and-redux)
2 |
3 | All of the code in my [blog series](http://silvenon.com/testing-react-and-redux).
4 |
5 | ## Commands
6 |
7 | ```sh
8 | $ npm start
9 | $ npm test
10 | ```
11 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | const webpack = require('webpack');
3 | const webpackDevMiddleware = require('webpack-dev-middleware');
4 | const config = require('./webpack.config');
5 | const Express = require('express');
6 |
7 | const app = new Express();
8 | const port = 3000;
9 | const compiler = webpack(config);
10 |
11 | app.use(webpackDevMiddleware(compiler, { noInfo: true, publicPath: config.output.publicPath }));
12 | app.use((req, res) => {
13 | res.sendFile(`${__dirname}/index.html`);
14 | });
15 | app.listen(port, (error) => {
16 | if (error) {
17 | console.error(error);
18 | } else {
19 | console.info('==> 🌎 Listening on port %s. Open up http://localhost:%s/ in your browser.', port, port);
20 | }
21 | });
22 |
--------------------------------------------------------------------------------
/src/actions/index.js:
--------------------------------------------------------------------------------
1 | export function action(type, payload) {
2 | if (typeof payload === 'undefined') {
3 | return { type };
4 | }
5 | return { type, payload };
6 | }
7 |
8 | function createAction(type) {
9 | return payload => action(type, payload);
10 | }
11 |
12 | export const TOGGLE_TODO = 'TOGGLE_TODO';
13 | export const toggleTodo = createAction(TOGGLE_TODO);
14 |
--------------------------------------------------------------------------------
/src/components/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import TodoList from './TodoList';
3 |
4 | const App = () => (
5 |
6 |
7 |
8 | );
9 |
10 | export default App;
11 |
--------------------------------------------------------------------------------
/src/components/Todo.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 |
3 | const Todo = props => (
4 |
10 | {props.text}
11 |
12 | );
13 |
14 | Todo.propTypes = {
15 | text: PropTypes.string.isRequired,
16 | completed: PropTypes.bool.isRequired,
17 | onClick: PropTypes.func.isRequired,
18 | };
19 |
20 | export default Todo;
21 |
--------------------------------------------------------------------------------
/src/components/TodoList.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import { connect } from 'react-redux';
3 | import Todo from './Todo';
4 | import { toggleTodo } from '../actions';
5 | import { getTodos } from '../reducers';
6 |
7 | export const TodoList = props => (
8 |
9 | {props.todos.map(todo => (
10 | props.toggleTodo(todo.id)}
14 | />
15 | ))}
16 |
17 | );
18 |
19 | TodoList.propTypes = {
20 | todos: PropTypes.array.isRequired,
21 | toggleTodo: PropTypes.func.isRequired,
22 | };
23 |
24 | const mapStateToProps = state => ({
25 | todos: getTodos(state),
26 | });
27 |
28 | const mapDispatchToProps = {
29 | toggleTodo,
30 | };
31 |
32 | export default connect(
33 | mapStateToProps,
34 | mapDispatchToProps
35 | )(TodoList);
36 |
--------------------------------------------------------------------------------
/src/configureStore.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware } from 'redux';
2 | import createSagaMiddleware from 'redux-saga';
3 | import rootSaga from './sagas';
4 | import createLogger from 'redux-logger';
5 | import rootReducer from './reducers';
6 |
7 | export default function configureStore(initialState) {
8 | const sagaMiddleware = createSagaMiddleware();
9 | const middleware = [
10 | sagaMiddleware,
11 | ].concat(DEV ? createLogger() : []);
12 | const store = createStore(
13 | rootReducer,
14 | initialState,
15 | applyMiddleware(...middleware)
16 | );
17 |
18 | sagaMiddleware.run(rootSaga);
19 |
20 | return store;
21 | }
22 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from 'react-dom';
3 | import { Provider } from 'react-redux';
4 | import configureStore from './configureStore';
5 | import App from './components/App';
6 |
7 | const store = configureStore({
8 | todos: [
9 | { id: 1, text: 'best', completed: false },
10 | { id: 2, text: 'todo', completed: false },
11 | { id: 3, text: 'app', completed: false },
12 | { id: 4, text: 'ever', completed: false },
13 | ],
14 | });
15 | const rootEl = document.getElementById('root');
16 |
17 | render(
18 |
19 |
20 | ,
21 | rootEl
22 | );
23 |
--------------------------------------------------------------------------------
/src/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import todos from './todos';
3 |
4 | export default combineReducers({
5 | todos,
6 | });
7 |
8 | export const getTodos = state => state.todos;
9 |
--------------------------------------------------------------------------------
/src/reducers/todos.js:
--------------------------------------------------------------------------------
1 | import { TOGGLE_TODO } from '../actions';
2 |
3 | const todo = (state, action) => {
4 | switch (action.type) {
5 | case TOGGLE_TODO:
6 | if (state.id !== action.payload) {
7 | return state;
8 | }
9 | return {
10 | ...state,
11 | completed: !state.completed,
12 | };
13 | default:
14 | return state;
15 | }
16 | };
17 |
18 | const todos = (state = [], action) => {
19 | switch (action.type) {
20 | case TOGGLE_TODO:
21 | return state.map(t => todo(t, action));
22 | default:
23 | return state;
24 | }
25 | };
26 |
27 | export default todos;
28 |
--------------------------------------------------------------------------------
/src/sagas/index.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-constant-condition */
2 | import { take, fork } from 'redux-saga/effects';
3 | import { TOGGLE_TODO } from '../actions';
4 | import * as api from '../services/api';
5 |
6 | export function *watchToggleTodo() {
7 | while (true) { // endless loops are perfectly normal in generators
8 | const { payload } = yield take(TOGGLE_TODO); // extracting the action's payload
9 | yield fork(api.toggleTodo, payload); // making a non-blocking API call
10 | }
11 | }
12 |
13 | export default function *rootSaga() {
14 | yield [
15 | fork(watchToggleTodo),
16 | ];
17 | }
18 |
--------------------------------------------------------------------------------
/src/services/api.js:
--------------------------------------------------------------------------------
1 | import callApi from '../utils/call-api';
2 |
3 | export const toggleTodo = id => callApi(`todos/${id}/toggle`, 'post');
4 |
--------------------------------------------------------------------------------
/src/utils/call-api.js:
--------------------------------------------------------------------------------
1 | import { camelizeKeys, decamelizeKeys } from 'humps';
2 | import fetch from 'isomorphic-fetch';
3 |
4 | // you can't call that an app if it doesn't have .io
5 | export const API_URL = 'https://api.myapp.io';
6 |
7 | export default function callApi(endpoint, method = 'get', body) {
8 | return fetch(`${API_URL}/${endpoint}`, { // power of template strings
9 | headers: { 'content-type': 'application/json' }, // I forget to add this EVERY TIME
10 | method, // object shorthand
11 | body: JSON.stringify(decamelizeKeys(body)), // this handles undefined body as well
12 | })
13 | // a clever way to bundle together both the response object and the JSON response
14 | .then(response => response.json().then(json => ({ json, response })))
15 | .then(({ json, response }) => {
16 | const camelizedJson = camelizeKeys(json);
17 |
18 | if (!response.ok) {
19 | return Promise.reject(camelizedJson);
20 | }
21 |
22 | return camelizedJson;
23 | })
24 | // we could also skip this step and use try...catch blocks instead,
25 | // but that way errors can easily bleed into wrong catch blocks
26 | .then(
27 | response => ({ response }),
28 | error => ({ error })
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/test/.eslintrc:
--------------------------------------------------------------------------------
1 | extends: plugin:ava/recommended
2 | plugins:
3 | - ava
4 | env:
5 | browser: false
6 | settings:
7 | import/resolver:
8 | node:
9 | moduleDirectory:
10 | - node_modules
11 | - bower_components
12 | - src
13 |
--------------------------------------------------------------------------------
/test/action.spec.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import { action } from 'actions';
3 |
4 | // does the result action have the given payload?
5 | test('returns payload', t => {
6 | t.deepEqual(
7 | action('FOO', 'bar'),
8 | { type: 'FOO', payload: 'bar' }
9 | );
10 | });
11 |
12 | // we don't want to set an unefined payload,
13 | // we'd rather skip it in that case
14 | test('skips payload if it\'s not defined', t => {
15 | t.deepEqual(
16 | action('FOO'),
17 | { type: 'FOO' }
18 | );
19 | });
20 |
21 | // but we do want it to return other falsy values, like 0 or false
22 | test('doesn\'t skip a falsy, but defined payload', t => {
23 | t.deepEqual(
24 | action('FOO', false),
25 | { type: 'FOO', payload: false }
26 | );
27 | });
28 |
--------------------------------------------------------------------------------
/test/components/Todo.spec.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import sinon from 'sinon';
3 | import React from 'react';
4 | import Todo from 'components/Todo';
5 | import { shallow } from 'enzyme';
6 |
7 | test('outputs the text', t => {
8 | const wrapper = shallow(
9 | // we're passing an empty function just to avoid warnings,
10 | // because we specified onClick as a required prop
11 | {}} />
12 | );
13 | // we assert that the textual part of our component contains todo's text
14 | t.regex(wrapper.render().text(), /foo/);
15 | });
16 |
17 | test('crosses out when completed', t => {
18 | const wrapper = shallow(
19 | {}} />
20 | );
21 | // this is possible because we're using inline styles
22 | t.is(wrapper.prop('style').textDecoration, 'line-through');
23 | // with CSS you'd be better of asserting the class name
24 | });
25 |
26 | test('calls onClick', t => {
27 | const onClick = sinon.spy(); // this spy knows everything!
28 | const wrapper = shallow(
29 |
30 | );
31 | // we simulate the click on our component,
32 | // i.e. the containing element
33 | wrapper.simulate('click');
34 | // we assert that the click handler has been called once
35 | t.true(onClick.calledOnce);
36 | });
37 |
--------------------------------------------------------------------------------
/test/components/TodoList.spec.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import sinon from 'sinon';
3 | import React from 'react';
4 | import { shallow } from 'enzyme';
5 | import { TodoList } from 'components/TodoList';
6 |
7 | test('lists todos', t => {
8 | const todos = [
9 | { id: 1, text: 'foo', completed: false },
10 | { id: 2, text: 'bar', completed: false },
11 | { id: 3, text: 'baz', completed: false },
12 | ];
13 | const wrapper = shallow(
14 | {}} />
15 | );
16 | // there are million ways to test this,
17 | // but I think counting components should be enough
18 | t.is(wrapper.find('Todo').length, 3);
19 | });
20 |
21 | test('toggles the todo', t => {
22 | const toggleTodo = sinon.spy();
23 | const todos = [
24 | { id: 1, text: 'foo', completed: false }, // only one is needed
25 | ];
26 | const wrapper = shallow(
27 |
28 | );
29 | wrapper.find('Todo').simulate('click');
30 | // now we want to be more specific with our spy assertion,
31 | // we are testing if the action is called with the expected argument
32 | t.true(toggleTodo.calledWith(1));
33 | });
34 |
--------------------------------------------------------------------------------
/test/helpers/setup.js:
--------------------------------------------------------------------------------
1 | global.__DEV__ = false;
2 |
--------------------------------------------------------------------------------
/test/reducers/todos.spec.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import reducer from 'reducers/todos';
3 | import { toggleTodo } from 'actions';
4 |
5 | test('toggles the todo', t => {
6 | const prevState = [
7 | { id: 1, text: 'foo', completed: false },
8 | { id: 2, text: 'bar', completed: false },
9 | { id: 3, text: 'baz', completed: false },
10 | ];
11 | const nextState = reducer(prevState, toggleTodo(2));
12 | t.deepEqual(nextState, [
13 | { id: 1, text: 'foo', completed: false },
14 | { id: 2, text: 'bar', completed: true },
15 | { id: 3, text: 'baz', completed: false },
16 | ]);
17 | });
18 |
--------------------------------------------------------------------------------
/test/sagas/watchToggleTodo.spec.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import { take, fork } from 'redux-saga/effects';
3 | import { TOGGLE_TODO } from 'actions';
4 | import * as api from 'services/api';
5 | import { watchToggleTodo } from 'sagas';
6 |
7 | test('calls the API function with the payload', t => {
8 | // first we create the generator, it won't start until we call next()
9 | const gen = watchToggleTodo();
10 | // we assert that the yield block indeed has the expected value
11 | t.deepEqual(
12 | gen.next().value,
13 | take(TOGGLE_TODO)
14 | );
15 | t.deepEqual(
16 | // we resolve the previous yield block with the action
17 | gen.next({ type: TOGGLE_TODO, payload: 3 }).value,
18 | // then we assert that the API call has been called with the ID
19 | fork(api.toggleTodo, 3)
20 | );
21 | // finally, we assert that the generator keeps looping,
22 | // which ensures that the it receives TOGGLE_TODO indefinitely
23 | t.false(gen.next().done);
24 | });
25 |
--------------------------------------------------------------------------------
/test/selectors.spec.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import { getTodos } from 'reducers';
3 |
4 | test('getTodos', t => {
5 | const todos = [
6 | { id: 1, text: 'foo', completed: true },
7 | { id: 2, text: 'bar', completed: false },
8 | { id: 3, text: 'baz', completed: true },
9 | ];
10 | t.deepEqual(getTodos({ todos }), todos);
11 | });
12 |
--------------------------------------------------------------------------------
/test/services/api.spec.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import nock from 'nock';
3 | import { API_URL } from 'utils/call-api';
4 | import * as api from 'services/api';
5 |
6 | test('toggleTodo', t => {
7 | const reply = { foo: 'bar' };
8 | nock(API_URL)
9 | .post('/todos/3/toggle')
10 | .reply(200, reply);
11 | return api.toggleTodo(3).then(({ response, error }) => {
12 | t.ifError(error);
13 | t.deepEqual(response, reply);
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/test/utils/call-api.spec.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import callApi, { API_URL } from 'utils/call-api';
3 | import nock from 'nock';
4 |
5 | test('method defaults to GET', t => {
6 | const reply = { foo: 'bar' };
7 | // we are intercepting https://api.myapp.io/foo
8 | nock(API_URL)
9 | .get('/foo')
10 | .reply(200, reply);
11 | // AVA will know to wait for the promise if you return it,
12 | // alternatively you can use async/await
13 | return callApi('foo').then(({ response, error }) => {
14 | // if there is an error, this assertion will fail
15 | // and it will nicely print out the stack trace
16 | t.ifError(error);
17 | // we assert that the response body matches
18 | t.deepEqual(response, reply);
19 | });
20 | });
21 |
22 | test('sends the body', t => {
23 | const body = { id: 5 };
24 | const reply = { foo: 'bar' };
25 | nock(API_URL)
26 | .post('/foo', body) // if the request is missing this body, nock will throw
27 | .reply(200, reply);
28 | return callApi('foo', 'post', body).then(({ response, error }) => {
29 | t.ifError(error);
30 | t.deepEqual(response, reply);
31 | });
32 | });
33 |
34 | test('decamelizes the body', t => {
35 | const reply = { foo: 'bar' };
36 | nock(API_URL)
37 | .post('/foo', { snake_case: 'sssss...' }) // what we expect
38 | .reply(200, reply);
39 | // what we send ↓
40 | return callApi('foo', 'post', { snakeCase: 'sssss...' })
41 | .then(({ response, error }) => {
42 | t.ifError(error);
43 | t.deepEqual(response, reply);
44 | });
45 | });
46 |
47 | test('camelizes the response', t => {
48 | nock(API_URL)
49 | .get('/foo')
50 | .reply(200, { camel_case: 'mmmh...' });
51 | // they apparently use camel sounds in Doom when demons die,
52 | // I can see why: https://youtu.be/Nn4vJbHOMPo
53 | return callApi('foo').then(({ response, error }) => {
54 | t.ifError(error);
55 | t.deepEqual(response, { camelCase: 'mmmh...' });
56 | });
57 | });
58 |
59 | // not really necessary because it's implied by previous tests
60 | // test('returns a promise', t => {
61 | // });
62 |
63 | test('returns the error', t => {
64 | const reply = { message: 'Camels are too creepy, sorry!' };
65 | nock(API_URL)
66 | .get('/camel_sounds')
67 | .reply(500, reply);
68 | return callApi('camel_sounds').then(({ error }) => {
69 | t.deepEqual(error, reply);
70 | });
71 | });
72 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack');
2 | const resolve = require('path').resolve;
3 |
4 | module.exports = {
5 | devtool: 'cheap-module-eval-source-map',
6 | entry: './src',
7 | output: {
8 | path: resolve('./dist'),
9 | filename: 'bundle.js',
10 | publicPath: '/',
11 | },
12 | plugins: [
13 | new webpack.DefinePlugin({
14 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
15 | DEV: JSON.stringify(true),
16 | }),
17 | new webpack.NoErrorsPlugin(),
18 | ],
19 | module: {
20 | loaders: [
21 | { test: /\.js$/, loader: 'babel', exclude: /node_modules/ },
22 | { test: /\.json$/, loader: 'json' },
23 | ],
24 | },
25 | };
26 |
--------------------------------------------------------------------------------