Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add tests for Redux sample code #20

Merged
merged 1 commit into from
Jan 16, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
{
"extends": "airbnb",
"env": {
"jest": true
},
"rules": {
// Apprently react-native doesn't like .jsx files
"react/jsx-filename-extension": ["error", { "extensions": [".js"] }],
"import/prefer-default-export": "off"
"import/prefer-default-export": "off",
"import/no-extraneous-dependencies": ["error", { devDependencies: true }]
}
}
14 changes: 0 additions & 14 deletions __tests__/.eslintrc

This file was deleted.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"rxjs": "^5.5.6"
},
"devDependencies": {
"axios-mock-adapter": "^1.10.0",
"babel-jest": "21.2.0",
"babel-preset-react-native": "4.0.0",
"eslint": "^4.9.0",
Expand All @@ -36,7 +37,10 @@
"react-test-renderer": "16.0.0-beta.5"
},
"jest": {
"preset": "react-native"
"preset": "react-native",
"transformIgnorePatterns": [
"node_modules/(?!(jest-)?react-native|react-navigation)"
]
},
"comment": "flow is disabled in pre-commit because react-navigation breaks it",
"pre-commit": [
Expand Down
3 changes: 3 additions & 0 deletions src/state/ducks/auth/selectors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function isAuthenticated(state) {
return state.auth.authenticated;
}
26 changes: 26 additions & 0 deletions src/state/ducks/auth/test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import configureStore from '../../store';
import * as actions from './actions';
import * as selectors from './selectors';

let store;

beforeEach(() => {
store = configureStore({ noEpic: true });
});

test('user is initially not logged in', () => {
expect(selectors.isAuthenticated(store.getState())).toBe(false);
});

test('login() logs the user in', () => {
store.dispatch(actions.login());
expect(selectors.isAuthenticated(store.getState())).toBe(true);
});

test('logout() logs the user out', () => {
store.dispatch(actions.login());
expect(selectors.isAuthenticated(store.getState())).toBe(true);

store.dispatch(actions.logout());
expect(selectors.isAuthenticated(store.getState())).toBe(false);
});
112 changes: 112 additions & 0 deletions src/state/ducks/patreon/test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { ActionsObservable } from 'redux-observable';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';

import configureStore from '../../store';
import * as types from './types';
import * as actions from './actions';
import * as selectors from './selectors';
import epic from './epic';

/*
* Reducers are tested by stubbing out the epic with one that does nothing,
* and then asserting things about the synchronous actions only. Later,
* we test that the epics emit those synchronous actions in response
* to the async stuff they do, which closes the loop and effectively
* tests that dispatching the async action that the epic handled
* eventually caused the expected state change.
*
* This is a lot simpler than trying to test the entire store
* with the redux-observable middleware installed, and it gives us
* the same coverage.
*/

describe('patreon reducer', () => {
let store;

beforeEach(() => {
store = configureStore({ noEpic: true });
});

test('patreon is initially not enabled', () => {
expect(selectors.isPatron(store.getState())).toBe(false);
});

test('patreonEnabled() enables patreon', () => {
store.dispatch(actions.patreonEnabled());
expect(selectors.isPatron(store.getState())).toBe(true);
});

test('patreonDisabled() disables patreon', () => {
store.dispatch(actions.patreonEnabled());
expect(selectors.isPatron(store.getState())).toBe(true);

store.dispatch(actions.patreonDisabled());
expect(selectors.isPatron(store.getState())).toBe(false);
});
});


/*
* Direct epic tests are very simple. An epic is just a function
* that accepts an ActionsObservable and returns an Observable.
* A test for an epic therefore just has to:
* 1) Create an ActionObservable that emits the action(s) you want to test
* 2) Assert that the Observable the epic returns emits the expected action(s)
*/

describe('patreon epic', () => {
const baseUrl = 'https://httpbin.org';
let mock;

beforeEach(() => {
mock = new MockAdapter(axios);
});

// These tests are very simple; one action leads to a single other.
// Other tests that result in multiple actions from the epic
// will have to be a little more complicated.

describe('when Patreon API call succeeds', () => {
beforeEach(() => {
mock.onPost(new RegExp(`${baseUrl}/.*`)).reply(200, {});
});

test('enable() leads to patreonEnabled()', () => {
const action$ = ActionsObservable.of(actions.enable());
return expect(epic(action$).toPromise()).resolves.toEqual(actions.patreonEnabled());
});

test('disable() leads to patreonDisabled()', () => {
const action$ = ActionsObservable.of(actions.disable());
return expect(epic(action$).toPromise()).resolves.toEqual(actions.patreonDisabled());
});
});

describe('when Patreon API call fails', () => {
const status = 429;

beforeEach(() => {
mock.onPost(new RegExp(`${baseUrl}/.*`)).reply(status, {});
});

function expectErrorActionWithStatus(action, statusCode) {
expect(action.type).toEqual(types.PATREON_ERROR);
expect(action.error).toBe(true);
expect(action.payload).toBeInstanceOf(Error);
expect(action.payload.response.status).toEqual(statusCode);
}

test('enable() leads to patreonError()', async () => {
const action$ = ActionsObservable.of(actions.enable());
const errorAction = await epic(action$).toPromise();
expectErrorActionWithStatus(errorAction, status);
});

test('disable() leads to patreonError()', async () => {
const action$ = ActionsObservable.of(actions.disable());
const errorAction = await epic(action$).toPromise();
expectErrorActionWithStatus(errorAction, status);
});
});
});
18 changes: 16 additions & 2 deletions src/state/store.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
import { combineReducers, createStore, applyMiddleware } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import { combineEpics, createEpicMiddleware } from 'redux-observable';
import { Observable } from 'rxjs';
import 'rxjs/add/observable/never';

import * as reducers from './ducks';
import epics from './epics';

export default function configureStore() {
/**
* A fake epic that just swallows actions. Useful for synchronous unit tests.
*/
const emptyEpic = () => Observable.never();

/**
* Return a redux store configured with all the reducers and middleware.
*
* If noEpic is passed, use emptyEpic to swallow all async actions.
* This allows testing the store with just synchronous actions,
* not worrying about side effects.
*/
export default function configureStore({ noEpic = false }) {
const reducer = combineReducers(reducers);
const epic = combineEpics(...epics);
const epic = noEpic ? combineEpics(...epics) : emptyEpic;
const epicMiddleware = createEpicMiddleware(epic);
const enhancer = composeWithDevTools(applyMiddleware(epicMiddleware));
return createStore(reducer, enhancer);
Expand Down
10 changes: 10 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,12 @@ aws4@^1.2.1, aws4@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e"

axios-mock-adapter@^1.10.0:
version "1.10.0"
resolved "https://registry.yarnpkg.com/axios-mock-adapter/-/axios-mock-adapter-1.10.0.tgz#3ccee65466439a2c7567e932798fc0377d39209d"
dependencies:
deep-equal "^1.0.1"

axios@^0.17.1:
version "0.17.1"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.17.1.tgz#2d8e3e5d0bdbd7327f91bc814f5c57660f81824d"
Expand Down Expand Up @@ -1369,6 +1375,10 @@ decamelize@^1.0.0, decamelize@^1.1.1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"

deep-equal@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"

deep-extend@~0.4.0:
version "0.4.2"
resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f"
Expand Down