diff --git a/.eslintrc b/.eslintrc index 7189f1e..1369e79 100644 --- a/.eslintrc +++ b/.eslintrc @@ -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 }] } } diff --git a/__tests__/.eslintrc b/__tests__/.eslintrc deleted file mode 100644 index 7f0265a..0000000 --- a/__tests__/.eslintrc +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "../.eslintrc", - "env": { - "jest": true - }, - "rules": { - "quotes": [ - "error", - "single", - // Required for exports in generated jest snapshots - { "allowTemplateLiterals": true } - ] - } -} diff --git a/package.json b/package.json index 28002b3..2044750 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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": [ diff --git a/src/state/ducks/auth/selectors.js b/src/state/ducks/auth/selectors.js new file mode 100644 index 0000000..0e51b2a --- /dev/null +++ b/src/state/ducks/auth/selectors.js @@ -0,0 +1,3 @@ +export function isAuthenticated(state) { + return state.auth.authenticated; +} diff --git a/src/state/ducks/auth/test.js b/src/state/ducks/auth/test.js new file mode 100644 index 0000000..9f2c696 --- /dev/null +++ b/src/state/ducks/auth/test.js @@ -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); +}); diff --git a/src/state/ducks/patreon/test.js b/src/state/ducks/patreon/test.js new file mode 100644 index 0000000..3e67ed2 --- /dev/null +++ b/src/state/ducks/patreon/test.js @@ -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); + }); + }); +}); diff --git a/src/state/store.js b/src/state/store.js index aa0b45d..a6db902 100644 --- a/src/state/store.js +++ b/src/state/store.js @@ -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); diff --git a/yarn.lock b/yarn.lock index 0a993e1..098dbf6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" @@ -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"