├── .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 [![Build Status](https://travis-ci.org/silvenon/testing-react-and-redux.svg?branch=master)](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 | 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 | --------------------------------------------------------------------------------