├── .gitignore ├── .babelrc ├── api.js ├── index.js ├── saga.js ├── package.json ├── README.md ├── __tests__ ├── redux-saga-tester.spec.js ├── redux-saga-tester-error.spec.js ├── redux-saga-testing.spec.js ├── redux-saga-native.spec.js ├── redux-saga-test.spec.js ├── redux-saga-test-engine.spec.js └── redux-saga-test-plan.spec.js └── state.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"] 3 | } 4 | -------------------------------------------------------------------------------- /api.js: -------------------------------------------------------------------------------- 1 | export const getUser = (username, context) => { 2 | return { 3 | username, 4 | context, 5 | admin: false 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import createSagaMiddleware from 'redux-saga'; 3 | import reducer from './state'; 4 | import saga from './saga'; 5 | 6 | const sagaMiddleware = createSagaMiddleware(); 7 | const store = createStore(reducer, applyMiddleware(sagaMiddleware)); 8 | 9 | sagaMiddleware.run(saga); 10 | -------------------------------------------------------------------------------- /saga.js: -------------------------------------------------------------------------------- 1 | import { call, put, select, takeEvery } from 'redux-saga/effects'; 2 | import { getUser } from './api'; 3 | import { getContext, LOAD_USER, loadUserSuccess, loadUserFailure } from './state'; 4 | 5 | export function* loadUserSaga(action) { 6 | try { 7 | const context = yield select(getContext); 8 | const user = yield call(getUser, action.payload, context); 9 | yield put(loadUserSuccess(user)); 10 | } catch (error) { 11 | yield put(loadUserFailure(error)); 12 | } 13 | } 14 | 15 | function* watcher() { 16 | yield takeEvery(LOAD_USER, loadUserSaga); 17 | } 18 | 19 | export default watcher; 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-saga-test-analysis", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "babel-cli": "^6.26.0", 13 | "babel-core": "^6.26.0", 14 | "babel-jest": "^22.0.4", 15 | "babel-preset-env": "^1.6.1", 16 | "jest": "^22.0.4", 17 | "redux": "^3.7.2", 18 | "redux-saga": "^0.16.0", 19 | "redux-saga-test": "^1.0.1", 20 | "redux-saga-test-engine": "^2.1.0", 21 | "redux-saga-test-plan": "^3.3.1", 22 | "redux-saga-tester": "^1.0.378", 23 | "redux-saga-testing": "^1.0.5", 24 | "regenerator-runtime": "^0.11.1" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redux-Saga Test Analysis 2 | 3 | This repository is the backing code for a [blog post](http://samhogy.co.uk/2018/01/evaluating-redux-saga-test-libraries.html) which is comparing and contrasting various libraries for testing redux sagas. 4 | 5 | It a trivial saga and a reducer, with tests written using the following libraries: 6 | 7 | * "Native" testing (i.e without a helper library) 8 | * [redux-saga-tester](https://github.com/wix/redux-saga-tester) 9 | * [redux-saga-test](https://github.com/stoeffel/redux-saga-test) 10 | * [redux-saga-testing](https://github.com/antoinejaussoin/redux-saga-testing/) 11 | * [redux-saga-test-plan](https://github.com/jfairbank/redux-saga-test-plan) 12 | * [redux-saga-test-engine](https://github.com/DNAinfo/redux-saga-test-engine) 13 | 14 | The tests are written using Jest, which provides deep-equals equality checking by default. Where possible, the tests are written using the `cloneableGenerator` utility from Redux Saga, so that we can demonstrate the possibility of minimizing duplication in branching logic. 15 | -------------------------------------------------------------------------------- /__tests__/redux-saga-tester.spec.js: -------------------------------------------------------------------------------- 1 | jest.mock('../api', () => ({ 2 | getUser: (username, context) => ({ username, isAdmin: true, context }) 3 | })); 4 | 5 | import SagaTester from 'redux-saga-tester'; 6 | import { call, put } from 'redux-saga/effects'; 7 | import { getUser } from '../api'; 8 | import { loadUserSaga, somethingElse } from '../saga'; 9 | import reducer, { defaultState, loadUser, LOAD_USER_SUCCESS } from '../state'; 10 | 11 | describe('with redux-saga-tester', () => { 12 | it('tests successful API request', () => { 13 | const user = { username: 'sam', isAdmin: true, context: 'test_app' }; 14 | 15 | const sagaTester = new SagaTester({ 16 | initialState: defaultState, 17 | reducers: reducer 18 | }); 19 | 20 | sagaTester.start(loadUserSaga, loadUser('sam')); 21 | 22 | expect(sagaTester.wasCalled(LOAD_USER_SUCCESS)).toEqual(true); 23 | expect(sagaTester.getState()).toEqual({ 24 | loading: false, 25 | result: user, 26 | error: null, 27 | context: 'test_app' 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /__tests__/redux-saga-tester-error.spec.js: -------------------------------------------------------------------------------- 1 | jest.mock('../api', () => ({ 2 | getUser: (username, context) => { throw new Error('oh no'); } 3 | })); 4 | 5 | import SagaTester from 'redux-saga-tester'; 6 | import { call, put } from 'redux-saga/effects'; 7 | import { getUser } from '../api'; 8 | import { loadUserSaga, somethingElse } from '../saga'; 9 | import reducer, { defaultState, loadUser, LOAD_USER_FAILURE } from '../state'; 10 | 11 | describe('with redux-saga-tester', () => { 12 | it('tests unsuccessful API request', () => { 13 | const user = { username: 'sam', isAdmin: true, context: 'test_app' }; 14 | 15 | const sagaTester = new SagaTester({ 16 | initialState: defaultState, 17 | reducers: reducer 18 | }); 19 | 20 | sagaTester.start(loadUserSaga, loadUser('sam')); 21 | 22 | expect(sagaTester.wasCalled(LOAD_USER_FAILURE)).toEqual(true); 23 | expect(sagaTester.getState()).toEqual({ 24 | loading: false, 25 | result: null, 26 | error: new Error('oh no'), 27 | context: 'test_app' 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /state.js: -------------------------------------------------------------------------------- 1 | export const LOAD_USER = 'LOAD_USER'; 2 | export const LOAD_USER_SUCCESS = 'LOAD_USER_SUCCESS'; 3 | export const LOAD_USER_FAILURE = 'LOAD_USER_FAILURE'; 4 | 5 | export const loadUser = username => ({ 6 | type: LOAD_USER, 7 | payload: username 8 | }); 9 | 10 | export const loadUserSuccess = user => ({ 11 | type: LOAD_USER_SUCCESS, 12 | payload: user 13 | }); 14 | 15 | export const loadUserFailure = error => ({ 16 | type: LOAD_USER_FAILURE, 17 | payload: error 18 | }); 19 | 20 | export const getContext = state => state.context; 21 | 22 | export const defaultState = Object.freeze({ 23 | loading: false, 24 | result: null, 25 | error: null, 26 | context: 'test_app' 27 | }); 28 | 29 | function reducer(state = defaultState, action) { 30 | switch(action.type) { 31 | case LOAD_USER: 32 | return { 33 | loading: true, 34 | result: null, 35 | error: null, 36 | context: state.context 37 | }; 38 | case LOAD_USER_SUCCESS: 39 | return { 40 | loading: false, 41 | result: action.payload, 42 | error: null, 43 | context: state.context 44 | }; 45 | case LOAD_USER_FAILURE: 46 | return { 47 | loading: false, 48 | result: null, 49 | error: action.payload, 50 | context: state.context 51 | }; 52 | default: 53 | return state; 54 | } 55 | } 56 | 57 | export default reducer; 58 | -------------------------------------------------------------------------------- /__tests__/redux-saga-testing.spec.js: -------------------------------------------------------------------------------- 1 | import sagaHelper from 'redux-saga-testing'; 2 | import { call, put, select } from 'redux-saga/effects'; 3 | import { getUser } from '../api'; 4 | import { loadUserSaga } from '../saga'; 5 | import { getContext, loadUser, loadUserSuccess, loadUserFailure } from '../state'; 6 | 7 | describe('with redux-saga-testing', () => { 8 | const user = { username: 'sam', isAdmin: true }; 9 | 10 | describe('and the request is successful', () => { 11 | const it = sagaHelper(loadUserSaga(loadUser('sam'))); 12 | 13 | it('gets the execution context', result => { 14 | expect(result).toEqual(select(getContext)); 15 | return 'test_app'; 16 | }); 17 | 18 | it('calls the API', result => { 19 | expect(result).toEqual(call(getUser, 'sam', 'test_app')); 20 | return user; 21 | }); 22 | 23 | it('raises success action', result => { 24 | expect(result).toEqual(put(loadUserSuccess(user))); 25 | }); 26 | 27 | it('performs no further work', result => { 28 | expect(result).not.toBeDefined(); 29 | }); 30 | }); 31 | 32 | describe('and the request fails', () => { 33 | const it = sagaHelper(loadUserSaga(loadUser('sam'))); 34 | const error = new Error("404 Not Found"); 35 | 36 | it('gets the execution context', result => { 37 | expect(result).toEqual(select(getContext)); 38 | return 'test_app'; 39 | }); 40 | 41 | it('calls the API', result => { 42 | expect(result).toEqual(call(getUser, 'sam', 'test_app')); 43 | return error; 44 | }); 45 | 46 | it('raises failed action', result => { 47 | expect(result).toEqual(put(loadUserFailure(error))); 48 | }); 49 | 50 | it('performs no further work', result => { 51 | expect(result).not.toBeDefined(); 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /__tests__/redux-saga-native.spec.js: -------------------------------------------------------------------------------- 1 | import { call, put, select } from 'redux-saga/effects'; 2 | import { cloneableGenerator } from 'redux-saga/utils'; 3 | import { getUser } from '../api'; 4 | import { loadUserSaga } from '../saga'; 5 | import { getContext, loadUser, loadUserSuccess, loadUserFailure } from '../state'; 6 | 7 | describe('with redux-saga native testing', () => { 8 | const generator = cloneableGenerator(loadUserSaga)(loadUser('sam')); 9 | const user = { username: 'sam', isAdmin: true }; 10 | 11 | it('gets the execution context', () => { 12 | const result = generator.next().value; 13 | expect(result).toEqual(select(getContext)); 14 | }); 15 | 16 | it('calls the API', () => { 17 | const result = generator.next('tests').value; 18 | expect(result).toEqual(call(getUser, 'sam', 'tests')); 19 | }); 20 | 21 | describe('and the request is successful', () => { 22 | let clone; 23 | 24 | beforeAll(() => { 25 | clone = generator.clone(); 26 | }); 27 | 28 | it('raises success action', () => { 29 | const result = clone.next(user).value; 30 | expect(result).toEqual(put(loadUserSuccess(user))); 31 | }); 32 | 33 | it('performs no further work', () => { 34 | const result = clone.next().done; 35 | expect(result).toBe(true); 36 | }); 37 | }); 38 | 39 | describe('and the request fails', () => { 40 | let clone; 41 | 42 | beforeAll(() => { 43 | clone = generator.clone(); 44 | }); 45 | 46 | it('raises failed action', () => { 47 | const error = new Error("404 Not Found"); 48 | const result = clone.throw(error).value; 49 | expect(result).toEqual(put(loadUserFailure(error))); 50 | }); 51 | 52 | it('performs no further work', () => { 53 | const result = clone.next().done; 54 | expect(result).toBe(true); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /__tests__/redux-saga-test.spec.js: -------------------------------------------------------------------------------- 1 | import fromGenerator from 'redux-saga-test'; 2 | import { cloneableGenerator } from 'redux-saga/utils'; 3 | import { call, put, select } from 'redux-saga/effects'; 4 | import { getUser } from '../api'; 5 | import { loadUserSaga } from '../saga'; 6 | import { getContext, loadUser, loadUserSuccess, loadUserFailure } from '../state'; 7 | 8 | // Bind Jest's deep equals function to a form the library expects 9 | const assertions = { 10 | deepEqual: (a, b) => expect(a).toEqual(b) 11 | }; 12 | 13 | describe('with redux-saga-test', () => { 14 | const generator = cloneableGenerator(loadUserSaga)(loadUser('sam')); 15 | const expect = fromGenerator(assertions, generator); 16 | const user = { username: 'sam', isAdmin: true }; 17 | 18 | it('gets the execution context', () => { 19 | expect.next().select(getContext); 20 | }); 21 | 22 | it('gets the user', () => { 23 | expect.next('test_app').call(getUser, 'sam', 'test_app'); 24 | }); 25 | 26 | describe('and the request is successful', () => { 27 | let expect; 28 | 29 | beforeAll(() => { 30 | expect = fromGenerator(assertions, generator.clone()); 31 | }); 32 | 33 | it('raises the success action', () => { 34 | expect.next(user).put(loadUserSuccess(user)); 35 | }); 36 | 37 | it('performs no further work', () => { 38 | expect.next().returns(); 39 | }); 40 | }); 41 | 42 | describe('and the request fails', () => { 43 | let expect; 44 | const error = new Error("404 Not Found"); 45 | 46 | beforeAll(() => { 47 | expect = fromGenerator(assertions, generator.clone()); 48 | }); 49 | 50 | it('raises the failed action', () => { 51 | expect.throwNext(error).put(loadUserFailure(error)); 52 | }); 53 | 54 | it('performs no further work', () => { 55 | expect.next().returns(); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /__tests__/redux-saga-test-engine.spec.js: -------------------------------------------------------------------------------- 1 | import { createSagaTestEngine, throwError } from 'redux-saga-test-engine'; 2 | import { call, put, select } from 'redux-saga/effects'; 3 | import { getUser } from '../api'; 4 | import { loadUserSaga } from '../saga'; 5 | import { getContext, loadUser, loadUserSuccess, loadUserFailure } from '../state'; 6 | 7 | describe('with redux-saga-test-engine', () => { 8 | describe('and a successful API request', () => { 9 | const user = { username: 'sam', isAdmin: true }; 10 | const collectEffects = createSagaTestEngine(['PUT', 'CALL']); 11 | const actualEffects = collectEffects( 12 | loadUserSaga, 13 | [ 14 | [select(getContext), 'test_app'], 15 | [call(getUser, 'sam', 'test_app'), user] 16 | ], 17 | loadUser('sam') 18 | ); 19 | 20 | it('gets the user', () => { 21 | expect(actualEffects[0]).toEqual(call(getUser, 'sam', 'test_app')); 22 | }); 23 | 24 | it('raises the success action', () => { 25 | expect(actualEffects[1]).toEqual(put(loadUserSuccess(user))); 26 | }); 27 | 28 | it('performs no further work', () => { 29 | expect(actualEffects.length).toEqual(2); 30 | }); 31 | }); 32 | 33 | describe('and a successful API request', () => { 34 | const error = new Error("404 Not Found"); 35 | const collectEffects = createSagaTestEngine(['PUT', 'CALL']); 36 | const actualEffects = collectEffects( 37 | loadUserSaga, 38 | [ 39 | [select(getContext), 'test_app'], 40 | [call(getUser, 'sam', 'test_app'), throwError(error)] 41 | ], 42 | loadUser('sam') 43 | ); 44 | 45 | it('gets the user', () => { 46 | expect(actualEffects[0]).toEqual(call(getUser, 'sam', 'test_app')); 47 | }); 48 | 49 | it('raises the failure action', () => { 50 | expect(actualEffects[1]).toEqual(put(loadUserFailure(error))); 51 | }); 52 | 53 | it('performs no further work', () => { 54 | expect(actualEffects.length).toEqual(2); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /__tests__/redux-saga-test-plan.spec.js: -------------------------------------------------------------------------------- 1 | import { expectSaga, testSaga } from 'redux-saga-test-plan'; 2 | import { throwError } from 'redux-saga-test-plan/providers'; 3 | import { call, put, select } from 'redux-saga/effects'; 4 | import { getUser } from '../api'; 5 | import { loadUserSaga } from '../saga'; 6 | import reducer, { getContext, loadUser, loadUserSuccess, loadUserFailure } from '../state'; 7 | 8 | describe('with redux-saga-test-plan', () => { 9 | const user = { username: 'sam', isAdmin: true, context: 'test_app' }; 10 | const error = new Error("404 Not Found"); 11 | 12 | describe('with a successful API request', () => { 13 | it('works as a unit test', () => { 14 | testSaga(loadUserSaga, loadUser('sam')) 15 | .next() 16 | .select(getContext) 17 | .next('tests') 18 | .call(getUser, 'sam', 'tests') 19 | .next(user) 20 | .put(loadUserSuccess(user)) 21 | .next() 22 | .isDone(); 23 | }); 24 | 25 | it('works as an integration test', () => { 26 | return expectSaga(loadUserSaga, loadUser('sam')) 27 | .provide([ 28 | [select(getContext), 'test_app'], 29 | [call(getUser, 'sam', 'test_app'), user] 30 | ]) 31 | .put(loadUserSuccess(user)) 32 | .run(); 33 | }); 34 | 35 | it('works as an integration test with reducer', () => { 36 | return expectSaga(loadUserSaga, loadUser('sam')) 37 | .withReducer(reducer) 38 | .provide([ 39 | [call(getUser, 'sam', 'test_app'), user] 40 | ]) 41 | .hasFinalState({ 42 | loading: false, 43 | result: user, 44 | error: null, 45 | context: 'test_app' 46 | }) 47 | .run(); 48 | }); 49 | }); 50 | 51 | describe('with an unsuccessful API request', () => { 52 | it('works as a unit test', () => { 53 | testSaga(loadUserSaga, loadUser('sam')) 54 | .next() 55 | .select(getContext) 56 | .next('tests') 57 | .call(getUser, 'sam', 'tests') 58 | .throw(error) 59 | .put(loadUserFailure(error)) 60 | .next() 61 | .isDone(); 62 | }); 63 | 64 | it('works as an integration test', () => { 65 | return expectSaga(loadUserSaga, loadUser('sam')) 66 | .provide([ 67 | [select(getContext), 'test_app'], 68 | [call(getUser, 'sam', 'test_app'), throwError(error)] 69 | ]) 70 | .put(loadUserFailure(error)) 71 | .run(); 72 | }); 73 | 74 | it('works as an integration test with reducer', () => { 75 | return expectSaga(loadUserSaga, loadUser('sam')) 76 | .withReducer(reducer) 77 | .provide([ 78 | [call(getUser, 'sam', 'test_app'), throwError(error)] 79 | ]) 80 | .hasFinalState({ 81 | loading: false, 82 | result: null, 83 | error: error, 84 | context: 'test_app' 85 | }) 86 | .run(); 87 | }); 88 | }); 89 | }); 90 | --------------------------------------------------------------------------------