├── .gitignore ├── .babelrc ├── package.json ├── test ├── TodoList.test.jsx ├── old-tests │ ├── GithubTrending-v0.5.test.jsx │ ├── GithubTrending-v0.4.test.jsx │ ├── test-min-v0.5.test.jsx │ └── test-v0.4.jsx ├── test.jsx ├── Items.test.jsx ├── GithubPages.test.jsx └── GithubTrending.test.jsx ├── src ├── redux-tdd-v0.4.js ├── redux-tdd-v0.5.js └── redux-tdd.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-2", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-tdd", 3 | "version": "1.1.0", 4 | "description": "Dot-chaining syntax for testing the Redux data-flow of React components", 5 | "main": "dist/redux-tdd.js", 6 | "files": [ 7 | "dist/redux-tdd.js" 8 | ], 9 | "repository": "https://github.com/lmatteis/redux-tdd", 10 | "license": "MIT", 11 | "peerDependencies": { 12 | "redux": "3.*", 13 | "redux-observable": "^0.16.0", 14 | "enzyme": "^2.9.1", 15 | "jest": "^20.0.4" 16 | }, 17 | "devDependencies": { 18 | "babel-cli": "^6.26.0", 19 | "babel-jest": "^20.0.3", 20 | "babel-preset-es2015": "^6.24.1", 21 | "babel-preset-react": "^6.24.1", 22 | "babel-preset-stage-2": "^6.24.1", 23 | "enzyme": "^2.9.1", 24 | "jest": "^20.0.4", 25 | "react": "^15.6.1", 26 | "react-dom": "^15.6.1", 27 | "react-test-renderer": "^15.6.1", 28 | "redux": "^3.7.2", 29 | "redux-observable": "^0.16.0", 30 | "regenerator-runtime": "^0.11.0", 31 | "rxjs": "^5.4.3" 32 | }, 33 | "scripts": { 34 | "test": "jest", 35 | "build": "babel src --out-dir dist", 36 | "prepublish": "npm run build" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /test/TodoList.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import reduxTdd from '../src/redux-tdd'; 4 | 5 | const TodoList = ({ listItems }) => 6 |
7 | {listItems.map(i =>
{i}
)} 8 |
9 | 10 | const AddTodo = ({ onAdd }) => 11 | 12 | 13 | function addTodo(todoText) { 14 | return { type: 'ADD_TODO', payload: todoText } 15 | } 16 | 17 | function list(state = {}, action) { 18 | switch (action.type) { 19 | case 'ADD_TODO': 20 | return { ...state, [action.payload]: {} } 21 | default: 22 | return state 23 | } 24 | } 25 | 26 | function getVisibleItems(state) { 27 | return Object.keys(state.list).length 28 | ? Object.keys(state.list).map(key => key) 29 | : [] 30 | } 31 | 32 | describe('TodoList', () => { 33 | reduxTdd({ list }, state => [ 34 | , 35 | 36 | ]) 37 | .it('should add a todo item') 38 | .switch(AddTodo) // the next dot-chained calls will work on AddTodo 39 | .action(props => props.onAdd('clean house')) // add 'clean house' 40 | 41 | .switch(TodoList) // back to TodoList 42 | .toMatchProps({ listItems: ["clean house"] }) 43 | 44 | .switch(AddTodo).action(props => props.onAdd('water plants')) 45 | .switch(TodoList).toMatchProps({ listItems: [ 46 | "clean house", 47 | "water plants" 48 | ]}) 49 | }) 50 | -------------------------------------------------------------------------------- /src/redux-tdd-v0.4.js: -------------------------------------------------------------------------------- 1 | import { ActionsObservable } from 'redux-observable'; 2 | 3 | class ReduxTdd { 4 | constructor(s, render) { 5 | this.state = { ...s }; 6 | this.render = render; 7 | this.currentAction = null; 8 | 9 | this.wrappers = render(this.state); 10 | } 11 | 12 | view() { 13 | if (Array.isArray(this.wrappers)) { 14 | this.wrappers.forEach(wrapper => 15 | wrapper.setProps(this.state) 16 | ); 17 | } else { 18 | this.wrappers.setProps(this.state); 19 | } 20 | return this; 21 | } 22 | 23 | reducer(reducer) { 24 | const newState = reducer(this.state, this.currentAction); 25 | this.state = newState; 26 | return this; 27 | } 28 | 29 | action(mockActionFn) { 30 | if (mockActionFn.mock) { 31 | expect(mockActionFn).toHaveBeenCalled(); 32 | const firstCall = mockActionFn.mock.calls[0]; 33 | this.currentAction = mockActionFn(...firstCall); 34 | } else { 35 | this.currentAction = mockActionFn(); 36 | } 37 | return this; 38 | } 39 | 40 | simulate(fn) { 41 | const result = fn(this.wrappers); 42 | return this; 43 | } 44 | 45 | toMatchAction(obj) { 46 | expect(this.currentAction).toMatchObject(obj); 47 | return this; 48 | } 49 | 50 | toMatchState(obj) { 51 | expect(this.state).toMatchObject(obj); 52 | return this; 53 | } 54 | 55 | contains(arg, truthy = true) { 56 | if (Array.isArray(this.wrappers)) { 57 | // arg is a function 58 | if (truthy) { 59 | expect(arg(this.wrappers)).toBeTruthy(); 60 | } else { 61 | expect(arg(this.wrappers)).toBeFalsy(); 62 | } 63 | } else { 64 | if (truthy) { 65 | expect(this.wrappers.containsMatchingElement(arg)).toBeTruthy(); 66 | } else { 67 | expect(this.wrappers.containsMatchingElement(arg)).toBeFalsy(); 68 | } 69 | } 70 | return this; 71 | } 72 | 73 | debug(cb) { 74 | cb(this); 75 | return this; 76 | } 77 | 78 | epic(epicFn, dependencies) { 79 | const action$ = ActionsObservable.of(this.currentAction); 80 | const store = null; 81 | 82 | epicFn(action$, store, dependencies) 83 | .toArray() // buffers all emitted actions until your Epic naturally completes() 84 | .subscribe(actions => { 85 | }); 86 | 87 | return this; 88 | } 89 | } 90 | 91 | var _old = ReduxTdd; 92 | ReduxTdd = function (...args) { return new _old(...args); }; 93 | export default ReduxTdd; 94 | -------------------------------------------------------------------------------- /src/redux-tdd-v0.5.js: -------------------------------------------------------------------------------- 1 | import { ActionsObservable } from 'redux-observable'; 2 | 3 | class ReduxTdd { 4 | constructor(s, reducer, render) { 5 | this.state = { ...s }; 6 | this.render = render; 7 | this.currentReducer = reducer; 8 | this.currentAction = null; 9 | 10 | this.wrappers = render(this.state); 11 | } 12 | 13 | view() { 14 | if (Array.isArray(this.wrappers)) { 15 | this.wrappers.forEach(wrapper => 16 | wrapper.setProps(this.state) 17 | ); 18 | } else { 19 | this.wrappers.setProps(this.state); 20 | } 21 | return this; 22 | } 23 | 24 | reducer(reducer) { 25 | const newState = reducer(this.state, this.currentAction); 26 | this.state = newState; 27 | return this; 28 | } 29 | 30 | // action(mockActionFn) { 31 | // if (mockActionFn.mock) { 32 | // expect(mockActionFn).toHaveBeenCalled(); 33 | // const firstCall = mockActionFn.mock.calls[0]; 34 | // this.currentAction = mockActionFn(...firstCall); 35 | // } else { 36 | // this.currentAction = mockActionFn(); 37 | // } 38 | // return this; 39 | // } 40 | 41 | action(fn) { 42 | const action = fn(this.wrappers); 43 | this.currentAction = action; 44 | return this; 45 | } 46 | 47 | toMatchAction(obj) { 48 | expect(this.currentAction).toMatchObject(obj); 49 | return this; 50 | } 51 | 52 | toMatchState(obj) { 53 | this.reducer(this.currentReducer) 54 | expect(this.state).toMatchObject(obj); 55 | return this; 56 | } 57 | 58 | contains(arg, truthy = true) { 59 | if (Array.isArray(this.wrappers)) { 60 | // arg is a function 61 | if (truthy) { 62 | expect(arg(this.wrappers)).toBeTruthy(); 63 | } else { 64 | expect(arg(this.wrappers)).toBeFalsy(); 65 | } 66 | } else { 67 | if (truthy) { 68 | expect(this.wrappers.containsMatchingElement(arg)).toBeTruthy(); 69 | } else { 70 | expect(this.wrappers.containsMatchingElement(arg)).toBeFalsy(); 71 | } 72 | } 73 | return this; 74 | } 75 | 76 | debug(cb) { 77 | cb(this); 78 | return this; 79 | } 80 | 81 | epic(epicFn, dependencies) { 82 | const action$ = ActionsObservable.of(this.currentAction); 83 | const store = null; 84 | 85 | epicFn(action$, store, dependencies) 86 | .toArray() // buffers all emitted actions until your Epic naturally completes() 87 | .subscribe(actions => { 88 | this.currentAction = actions[0] 89 | }); 90 | 91 | return this; 92 | } 93 | } 94 | 95 | var _old = ReduxTdd; 96 | ReduxTdd = function (...args) { return new _old(...args); }; 97 | export default ReduxTdd; 98 | -------------------------------------------------------------------------------- /test/old-tests/GithubTrending-v0.5.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import ReduxTdd from '../../src/redux-tdd-v0.5'; 6 | 7 | function GithubTrending({ projects, loading, onRefresh }) { 8 | return
9 | { loading &&
} 10 |
11 | { !projects.length && 'No projects' } 12 | { projects.map((p, idx) =>
{p.name}
) } 13 |
14 | 15 |
16 | } 17 | 18 | function refreshAction() { 19 | return { type: 'REFRESH' }; 20 | } 21 | function refreshDoneAction(payload) { 22 | return { type: 'REFRESH_DONE', payload }; 23 | } 24 | 25 | const initialState = { projects: [], loading: false }; 26 | function githubReducer(state = initialState, action) { 27 | switch (action.type) { 28 | case 'REFRESH': 29 | return { ...state, loading: true }; 30 | case 'REFRESH_DONE': 31 | return { ...state, loading: false, projects: action.payload }; 32 | default: 33 | return state; 34 | } 35 | } 36 | 37 | function handleRefreshEpic(action$, store, { getJSON }) { 38 | return action$.ofType('REFRESH') 39 | .mergeMap(() => 40 | getJSON('http://foo.bar') 41 | .map(response => refreshDoneAction(response)) 42 | ); 43 | } 44 | 45 | describe('', () => { 46 | it('should test flow', () => { 47 | ReduxTdd({ projects: [], loading: false }, githubReducer, state => shallow( 48 | 52 | )) 53 | .view() 54 | .contains(
, false) // shouldn't show loading 55 | .contains(
No projects
) 56 | .contains() 57 | 58 | .action(wrapper => 59 | wrapper.instance().props.onRefresh() 60 | ) 61 | .toMatchState({ loading: true }) 62 | .view().contains(
) 63 | 64 | .epic(handleRefreshEpic, { getJSON: () => 65 | Observable.of([ 66 | { name: 'redux-tdd' }, { name: 'redux-cycles' } 67 | ]) 68 | }) 69 | 70 | .toMatchState({ 71 | loading: false, 72 | projects: [{ name: 'redux-tdd' }, { name: 'redux-cycles' }] 73 | }) 74 | .view() 75 | .contains(
, false) // shouldn't show loading 76 | .contains(
77 |
redux-tdd
78 |
redux-cycles
79 |
) 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /test/old-tests/GithubTrending-v0.4.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import ReduxTdd from '../../src/redux-tdd-v0.4'; 6 | 7 | function GithubTrending({ projects, loading, onRefresh }) { 8 | return
9 | { loading &&
} 10 |
11 | { !projects.length && 'No projects' } 12 | { projects.map((p, idx) =>
{p.name}
) } 13 |
14 | 15 |
16 | } 17 | 18 | function refreshAction() { 19 | return { type: 'REFRESH' }; 20 | } 21 | function refreshDoneAction(payload) { 22 | return { type: 'REFRESH_DONE', payload }; 23 | } 24 | 25 | const initialState = { projects: [], loading: false }; 26 | function githubReducer(state = initialState, action) { 27 | switch (action.type) { 28 | case 'REFRESH': 29 | return { ...state, loading: true }; 30 | case 'REFRESH_DONE': 31 | return { ...state, loading: false, projects: action.payload }; 32 | default: 33 | return state; 34 | } 35 | } 36 | 37 | const refreshDoneActionMock = jest.fn(payload => refreshDoneAction(payload)) 38 | function handleRefreshEpic(action$, store, { getJSON }) { 39 | return action$.ofType('REFRESH') 40 | .mergeMap(() => 41 | getJSON('http://foo.bar') 42 | .map(response => refreshDoneActionMock(response)) 43 | ); 44 | } 45 | 46 | describe('', () => { 47 | it('should test flow', () => { 48 | const refreshActionMock = jest.fn(payload => refreshAction(payload)) 49 | ReduxTdd({ projects: [], loading: false }, state => shallow( 50 | 54 | )) 55 | .view() 56 | .contains(
, false) // shouldn't show loading 57 | .contains(
No projects
) 58 | .contains() 59 | 60 | .simulate(wrapper => wrapper.find('.refresh').simulate('click')) 61 | .action(refreshActionMock).toMatchAction({ type: 'REFRESH' }) 62 | .reducer(githubReducer).toMatchState({ loading: true }) 63 | .view().contains(
) 64 | 65 | .epic(handleRefreshEpic, { getJSON: () => 66 | Observable.of([ 67 | { name: 'redux-tdd' }, { name: 'redux-cycles' } 68 | ]) 69 | }) 70 | .action(refreshDoneActionMock).toMatchAction({ 71 | type: 'REFRESH_DONE', 72 | payload: [{ name: 'redux-tdd' }, { name: 'redux-cycles' }], 73 | }) 74 | .reducer(githubReducer).toMatchState({ 75 | loading: false, 76 | projects: [{ name: 'redux-tdd' }, { name: 'redux-cycles' }] 77 | }) 78 | .view() 79 | .contains(
, false) // shouldn't show loading 80 | .contains(
81 |
redux-tdd
82 |
redux-cycles
83 |
) 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /test/test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import ReduxTdd from '../src/redux-tdd'; 4 | 5 | function Counter({ onIncrement, onReset, counter }) { 6 | return ( 7 |
8 |
{counter}
9 | { counter === 10 10 | ? 11 | : 12 | } 13 |
14 | ); 15 | } 16 | 17 | function Modal({ show }) { 18 | return ( 19 |
20 | { show && 21 |
22 | } 23 |
24 | ); 25 | } 26 | 27 | function incrementAction() { 28 | return { 29 | type: 'INCREMENT' 30 | } 31 | } 32 | 33 | function resetAction() { 34 | return { 35 | type: 'RESET' 36 | } 37 | } 38 | 39 | function count(state = { count: 0 }, action) { 40 | switch (action.type) { 41 | case 'INCREMENT': 42 | return { count: state.count + 1 } 43 | case 'RESET': 44 | return { count: 0 } 45 | default: 46 | return state 47 | } 48 | } 49 | 50 | function multipleComponentsReducer(state = { count: 0, show: false }, action) { 51 | switch (action.type) { 52 | case 'INCREMENT': 53 | return { ...state, count: state.count + 1, show: (state.count + 1) % 2 !== 0 } 54 | case 'RESET': 55 | return { ...state, count: 0 } 56 | default: 57 | return state 58 | } 59 | } 60 | 61 | function mapStateToProps(state) { 62 | return { 63 | counter: state.count 64 | } 65 | } 66 | 67 | describe('', () => { 68 | it('should test increment', () => { 69 | ReduxTdd({ count }, state => ([ 70 | 74 | ])) 75 | .action(props => props.onIncrement()) 76 | .toMatchProps({ counter: 1 }) 77 | 78 | .action(props => props.onIncrement()) 79 | .toMatchProps({ counter: 2 }) 80 | }) 81 | it('should test reset', () => { 82 | ReduxTdd({ count }, state => ([ 83 | 87 | ])) 88 | .view(wrapper => { 89 | const incrementActionMock = jest.fn(incrementAction); 90 | wrapper.setProps({ onIncrement: incrementActionMock }) 91 | wrapper.find('button').simulate('click'); 92 | expect(incrementActionMock).toHaveBeenCalled(); 93 | }) 94 | .action(props => props.onIncrement()) 95 | .toMatchProps({ counter: 1 }) 96 | .contains(
{1}
) 97 | 98 | .action(props => props.onReset()) 99 | .toMatchProps({ counter: 0 }) 100 | .contains(
{0}
) 101 | }) 102 | }) 103 | 104 | describe(' and ', () => { 105 | it('should test interaction between multiple components', () => { 106 | ReduxTdd({ multipleComponentsReducer }, state => ([ 107 | 110 | , 111 | 113 | ])) 114 | 115 | // by default it works on the first component (Counter) 116 | .action((props) => props.onIncrement()) 117 | .toMatchProps({ counter: 1 }) 118 | .contains(
{1}
) 119 | 120 | .switch(1) 121 | .toMatchProps({ show: true }) 122 | .contains(
) 123 | 124 | .switch(0) 125 | .action((props) => props.onIncrement()) 126 | .toMatchProps({ counter: 2 }) 127 | .contains(
{2}
) 128 | 129 | .switch(1) 130 | .toMatchProps({ show: false }) 131 | .contains(
, false) 132 | }) 133 | }) 134 | -------------------------------------------------------------------------------- /test/old-tests/test-min-v0.5.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import ReduxTdd from '../../src/redux-tdd-v0.5'; 4 | 5 | function Counter({ onIncrement, onReset, count }) { 6 | return ( 7 |
8 |
{count}
9 | { count === 10 10 | ? 11 | : 12 | } 13 |
14 | ); 15 | } 16 | 17 | function Modal({ show }) { 18 | return ( 19 |
20 | { show && 21 |
22 | } 23 |
24 | ); 25 | } 26 | 27 | function incrementAction() { 28 | return { 29 | type: 'INCREMENT' 30 | } 31 | } 32 | 33 | function resetAction() { 34 | return { 35 | type: 'RESET' 36 | } 37 | } 38 | 39 | function reducer(state = { count: 0 }, action) { 40 | switch (action.type) { 41 | case 'INCREMENT': 42 | return { count: state.count + 1 } 43 | case 'RESET': 44 | return { count: 0 } 45 | default: 46 | return state 47 | } 48 | } 49 | 50 | function multipleComponentsReducer(state = { count: 0, show: false }, action) { 51 | switch (action.type) { 52 | case 'INCREMENT': 53 | return { ...state, count: state.count + 1, show: (state.count + 1) % 2 !== 0 } 54 | case 'RESET': 55 | return { ...state, count: 0 } 56 | default: 57 | return state 58 | } 59 | } 60 | 61 | describe('', () => { 62 | it('should test increment', () => { 63 | ReduxTdd({ count: 0 }, reducer, state => shallow( 64 | 68 | )) 69 | .action(wrapper => 70 | wrapper.instance().props.onIncrement() 71 | ) 72 | .toMatchState({ count: 1 }) 73 | .view().contains(
{1}
) 74 | .toMatchState({ count: 2 }) 75 | .view().contains(
{2}
) 76 | }) 77 | it('should test reset', () => { 78 | ReduxTdd({ count: 9 }, reducer, state => shallow( 79 | 83 | )) 84 | .action(wrapper => 85 | wrapper.instance().props.onIncrement() 86 | ) 87 | .toMatchState({ count: 10 }) 88 | .view().contains(
{10}
) 89 | 90 | .action(wrapper => 91 | wrapper.instance().props.onReset() 92 | ) 93 | .toMatchState({ count: 0 }) 94 | }) 95 | }) 96 | 97 | describe(' and ', () => { 98 | it('should test interaction between multiple components', () => { 99 | ReduxTdd({ count: 0, show: false }, multipleComponentsReducer, state => ([ 100 | shallow( 101 | 104 | ), 105 | shallow( 106 | 108 | ) 109 | ])) 110 | .action(([ counterWrapper, modalWrapper ]) => 111 | counterWrapper.instance().props.onIncrement() // simulate a click 112 | ) 113 | // should show modal when state.count is odd 114 | .toMatchState({ count: 1, show: true }) 115 | .view().contains(([ counter, modal ]) => 116 | counter.contains(
{1}
) && 117 | modal.contains(
) 118 | ) 119 | .action(([ counterWrapper, modalWrapper ]) => 120 | counterWrapper.instance().props.onIncrement() // simulate a click 121 | ) 122 | // should hide modal when state.count is even 123 | .toMatchState({ count: 2, show: false }) 124 | .view().contains(([ counter, modal ]) => 125 | counter.contains(
{2}
) && 126 | !modal.contains(
) 127 | ) 128 | }) 129 | }) 130 | -------------------------------------------------------------------------------- /src/redux-tdd.js: -------------------------------------------------------------------------------- 1 | import { ActionsObservable } from 'redux-observable'; 2 | import { combineReducers } from 'redux' 3 | import { shallow } from 'enzyme'; 4 | 5 | class ReduxTdd { 6 | constructor(reducers, render) { 7 | this.currentAction = null; 8 | this.currentKey = 0; 9 | this.epicActions = []; 10 | 11 | this.reducer = combineReducers(reducers); 12 | this.state = this.reducer(undefined, { type: '@@redux/INIT'}); 13 | this.render = render; 14 | this.components = render(this.state); // [ , ] 15 | this.wrappers = this.components.reduce((acc, curr) => 16 | ({ 17 | ...acc, 18 | [curr.type.name]: shallow(curr) 19 | }) 20 | //shallow(c) 21 | , {}) // { 'Component1' -> shallow(Component1) } 22 | } 23 | 24 | getCurrentWrapper() { 25 | if (typeof this.currentKey === 'number') { // .switch(1) 26 | return this.wrappers[Object.keys(this.wrappers)[this.currentKey]]; 27 | } else if (typeof this.currentKey === 'string') { // .switch('Foo') 28 | return this.wrappers[this.currentKey]; 29 | } else if (typeof this.currentKey === 'function') { // .switch(Foo) 30 | return this.wrappers[this.currentKey.name]; 31 | } 32 | } 33 | 34 | it(str) { 35 | it(str, () => undefined) 36 | return this; 37 | } 38 | 39 | switch(key) { 40 | this.currentKey = key; 41 | return this; 42 | } 43 | 44 | action(fn) { 45 | const action = fn(props(this.getCurrentWrapper()), this.epicActions.shift()); 46 | this.currentAction = action; 47 | 48 | // check if the action object is handled in reducer 49 | 50 | // first: change state 51 | const newState = this.reducer(this.state, action); 52 | this.state = newState; 53 | 54 | // second: update views 55 | Object.keys(this.wrappers).forEach((key, idx) => 56 | this.wrappers[key].setProps(this.render(this.state)[idx].props) 57 | ); 58 | 59 | return this; 60 | } 61 | 62 | debug(cb) { 63 | cb(this); 64 | return this; 65 | } 66 | 67 | epic(epicFn, dependencies) { 68 | const action$ = ActionsObservable.of(this.currentAction); 69 | const store = { 70 | getState: () => this.state 71 | }; 72 | 73 | epicFn(action$, store, dependencies) 74 | .toArray() // buffers all emitted actions until your Epic naturally completes() 75 | .subscribe(actions => { 76 | // only run automatically the first one 77 | actions.forEach((action, idx) => 78 | idx === 0 && this.action(() => action) 79 | ); 80 | actions.shift() 81 | this.epicActions = actions; 82 | }); 83 | 84 | return this; 85 | } 86 | 87 | contains(arg, truthy = true) { 88 | if (truthy) { 89 | expect(this.getCurrentWrapper().containsMatchingElement(arg)).toBeTruthy(); 90 | } else { 91 | expect(this.getCurrentWrapper().containsMatchingElement(arg)).toBeFalsy(); 92 | } 93 | 94 | return this; 95 | } 96 | 97 | view(fn) { 98 | fn(this.getCurrentWrapper()); 99 | return this; 100 | } 101 | 102 | toMatchProps(obj) { 103 | expect(this.getCurrentWrapper().instance().props).toMatchObject(obj); 104 | return this; 105 | } 106 | } 107 | 108 | export function props(wrapper) { 109 | return wrapper.instance().props; 110 | } 111 | // 112 | // export function contains(node) { 113 | // return function (wrapper) { 114 | // if (!wrapper.containsMatchingElement(node)) { 115 | // console.log(wrapper.debug()) 116 | // console.log(shallow(node).debug()) 117 | // } 118 | // expect(wrapper.containsMatchingElement(node)).toBeTruthy(); 119 | // } 120 | // } 121 | // 122 | // export function toMatchProps(obj) { 123 | // return function (wrapper) { 124 | // expect(wrapper.instance().props).toMatchObject(obj); 125 | // } 126 | // } 127 | 128 | var _old = ReduxTdd; 129 | ReduxTdd = function (...args) { return new _old(...args); }; 130 | export default ReduxTdd; 131 | -------------------------------------------------------------------------------- /test/Items.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import ReduxTdd from '../src/redux-tdd'; 4 | 5 | import { Observable } from 'rxjs/Observable'; 6 | import 'rxjs/add/observable/of'; 7 | import 'rxjs/add/operator/toArray'; 8 | import 'rxjs/add/operator/switchMap'; 9 | import 'rxjs/add/operator/map'; 10 | 11 | function fetchItems() { 12 | return { type: 'FETCH_ITEMS' } 13 | } 14 | 15 | function recievedItems(payload) { 16 | return { type: 'RECIEVED_ITEMS', payload } 17 | } 18 | function pauseItem(id) { 19 | return { type: 'PAUSE_ITEM', payload: id } 20 | } 21 | function pauseItemSuccess(payload) { 22 | return { type: 'PAUSE_ITEM_SUCCESS', payload } 23 | } 24 | 25 | function handleFetchItems(action$, store, { getJSON }) { 26 | return action$ 27 | .ofType('FETCH_ITEMS') 28 | .switchMap(action => 29 | getJSON('http://foo') 30 | .map(response => 31 | response.reduce((acc, curr) => 32 | ({ 33 | ...acc, 34 | [curr.id]: curr, 35 | }) 36 | , {}) 37 | ) 38 | .map(response => recievedItems(response)) 39 | ) 40 | } 41 | 42 | function handlePausItem(action$, store, { getJSON }) { 43 | return action$ 44 | .ofType('PAUSE_ITEM') 45 | .switchMap(action => 46 | getJSON('http://gg.com') 47 | .map(response => 48 | pauseItemSuccess(action.payload) 49 | ) 50 | ) 51 | } 52 | 53 | function Pause({ id, onClick }) { 54 | return pause 55 | } 56 | function Item({ item }) { 57 | return
58 | 59 |
60 | } 61 | function Items({ items }) { 62 | return
63 | {Object.keys(items).map(id => )} 64 |
65 | } 66 | 67 | // items: { id -> item } 68 | function items(state = {}, action) { 69 | switch (action.type) { 70 | case 'RECIEVED_ITEMS': 71 | return action.payload; 72 | case 'PAUSE_ITEM': 73 | return { 74 | ...state, 75 | [action.payload]: { 76 | ...state[action.payload], 77 | loading: true 78 | } 79 | } 80 | case 'PAUSE_ITEM_SUCCESS': 81 | return { 82 | ...state, 83 | [action.payload]: { 84 | ...state[action.payload], 85 | status: 'paused', 86 | loading: false 87 | } 88 | } 89 | default: 90 | return state; 91 | } 92 | } 93 | describe('Items and Item', () => { 94 | const response = [ 95 | { id: 1, name: 'Foo', date: 'March' }, 96 | { id: 2, name: 'Bar', date: 'November' } 97 | ] 98 | ReduxTdd({ items }, state => ([ 99 | 103 | , 104 | 107 | , 108 | 112 | ])) 113 | 114 | .it('should load items') 115 | .action(props => props.fetchItems()) 116 | .epic(handleFetchItems, { getJSON: () => 117 | Observable.of(response) 118 | }) 119 | .toMatchProps({ items: 120 | { 121 | 1: { id: 1, name: 'Foo', date: 'March' }, 122 | 2: { id: 2, name: 'Bar', date: 'November' } 123 | } 124 | }) 125 | .view(wrapper => 126 | expect(wrapper.find(Item).length).toEqual(2) 127 | ) 128 | 129 | .it('should test our Item got the right prop') 130 | .switch(Item) 131 | .toMatchProps({ item: 132 | { id: 1, name: 'Foo', date: 'March' } 133 | }) 134 | 135 | .it('should pause item with id 1') 136 | .switch(Pause) 137 | .action(props => props.onClick(1)) 138 | 139 | .it('should show loading on Pause button') 140 | .toMatchProps({ loading: true }) 141 | .epic(handlePausItem, { getJSON: () => 142 | Observable.of({}) 143 | }) 144 | 145 | .it('should hide loading') 146 | .toMatchProps({ loading: false }) 147 | 148 | .it('should test that our item recieved the correct "paused" status') 149 | .switch(Item) 150 | .toMatchProps({ item: 151 | { id: 1, name: 'Foo', date: 'March', status: 'paused' } 152 | }) 153 | }) 154 | -------------------------------------------------------------------------------- /test/old-tests/test-v0.4.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import ReduxTdd from '../../src/redux-tdd-v0.4'; 4 | 5 | function Counter({ onIncrement, onReset, count }) { 6 | return ( 7 |
8 |
{count}
9 | { count === 10 10 | ? 11 | : 12 | } 13 |
14 | ); 15 | } 16 | 17 | function Modal({ show }) { 18 | return ( 19 |
20 | { show && 21 |
22 | } 23 |
24 | ); 25 | } 26 | 27 | function incrementAction() { 28 | return { 29 | type: 'INCREMENT' 30 | } 31 | } 32 | 33 | function resetAction() { 34 | return { 35 | type: 'RESET' 36 | } 37 | } 38 | 39 | function reducer(state = { count: 0 }, action) { 40 | switch (action.type) { 41 | case 'INCREMENT': 42 | return { count: state.count + 1 } 43 | case 'RESET': 44 | return { count: 0 } 45 | default: 46 | return state 47 | } 48 | } 49 | 50 | function multipleComponentsReducer(state = { count: 0, show: false }, action) { 51 | switch (action.type) { 52 | case 'INCREMENT': 53 | return { ...state, count: state.count + 1, show: (state.count + 1) % 2 !== 0 } 54 | case 'RESET': 55 | return { ...state, count: 0 } 56 | default: 57 | return state 58 | } 59 | } 60 | 61 | describe('', () => { 62 | it('should test increment', () => { 63 | const incrementActionMock = jest.fn(payload => incrementAction(payload)) 64 | const resetActionMock = jest.fn(payload => resetAction(payload)) 65 | 66 | ReduxTdd({ count: 0 }, state => shallow()) 67 | .simulate(wrapper => wrapper.find('button').simulate('click')) 68 | .action(incrementActionMock).toMatchAction({ type: 'INCREMENT' }) 69 | .reducer(reducer).toMatchState({ count: 1 }) 70 | .view().contains(
{1}
) 71 | .reducer(reducer).toMatchState({ count: 2 }) 72 | .view().contains(
{2}
) 73 | }) 74 | it('should test reset', () => { 75 | const incrementActionMock = jest.fn(payload => incrementAction(payload)) 76 | const resetActionMock = jest.fn(payload => resetAction(payload)) 77 | 78 | ReduxTdd({ count: 9 }, state => shallow()) 79 | .simulate(wrapper => wrapper.find('button').simulate('click')) 80 | .action(incrementActionMock).toMatchAction({ type: 'INCREMENT' }) 81 | .reducer(reducer).toMatchState({ count: 10 }) 82 | .view().contains(
{10}
) 83 | .simulate(wrapper => wrapper.find('button').simulate('click')) 84 | .action(resetActionMock).toMatchAction({ type: 'RESET' }) 85 | .reducer(reducer).toMatchState({ count: 0 }) 86 | }) 87 | }) 88 | 89 | describe(' and ', () => { 90 | it('should test interaction between multiple components', () => { 91 | const incrementActionMock = jest.fn(payload => incrementAction(payload)) 92 | ReduxTdd({ count: 0, show: false }, state => ([ 93 | shallow( 94 | 97 | ), 98 | shallow( 99 | 101 | ) 102 | ])) 103 | .simulate(([ counterWrapper, modalWrapper ]) => 104 | counterWrapper.instance().props.onIncrement() // simulate a click 105 | ) 106 | .action(incrementActionMock).toMatchAction({ type: 'INCREMENT' }) 107 | // should show modal when state.count is odd 108 | .reducer(multipleComponentsReducer).toMatchState({ count: 1, show: true }) 109 | .view().contains(([ counter, modal ]) => 110 | counter.contains(
{1}
) && 111 | modal.contains(
) 112 | ) 113 | .simulate(([ counterWrapper, modalWrapper ]) => 114 | counterWrapper.instance().props.onIncrement() // simulate a click 115 | ) 116 | .action(incrementActionMock).toMatchAction({ type: 'INCREMENT' }) 117 | // should hide modal when state.count is even 118 | .reducer(multipleComponentsReducer).toMatchState({ count: 2, show: false }) 119 | .view().contains(([ counter, modal ]) => 120 | counter.contains(
{2}
) && 121 | !modal.contains(
) 122 | ) 123 | }) 124 | }) 125 | -------------------------------------------------------------------------------- /test/GithubPages.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import ReduxTdd from '../src/redux-tdd'; 6 | 7 | function items(state = [], action) { 8 | switch (action.type) { 9 | case 'REFRESH_DONE': 10 | return action.payload; 11 | default: 12 | return state 13 | } 14 | } 15 | function error(state = null, action) { 16 | switch (action.type) { 17 | case 'REFRESH_FAIL': 18 | return action.payload.error 19 | default: 20 | return state; 21 | } 22 | } 23 | function pages(state = { perPage: 2, selectedPage: 0 }, action) { 24 | switch (action.type) { 25 | case 'SELECT_PAGE': 26 | return { ...state, selectedPage: action.payload } 27 | default: 28 | return state 29 | } 30 | } 31 | function loading(state = false, action) { 32 | switch (action.type) { 33 | case 'REFRESH': 34 | return true 35 | case 'REFRESH_DONE': 36 | return false 37 | default: 38 | return state 39 | } 40 | } 41 | 42 | function List() { 43 | return
44 | } 45 | function Loading() { 46 | return
47 | } 48 | function Item() { 49 | return
50 | } 51 | function RefreshButton() { 52 | return
53 | } 54 | function Error() { 55 | return
56 | } 57 | function Pages() { 58 | return
59 | } 60 | 61 | function refreshAction() { 62 | return { type: 'REFRESH' }; 63 | } 64 | function refreshDoneAction(payload) { 65 | return { type: 'REFRESH_DONE', payload }; 66 | } 67 | function refreshFailAction(payload) { 68 | return { type: 'REFRESH_FAIL', payload, error: true }; 69 | } 70 | function selectPageAction(pageIdx) { 71 | return { type: 'SELECT_PAGE', payload: pageIdx } 72 | } 73 | 74 | function handleRefreshEpic(action$, store, { getJSON }) { 75 | return action$.ofType('REFRESH') 76 | .mergeMap(() => { 77 | const state = store.getState() 78 | return getJSON('http://foo.bar') 79 | .map(response => refreshDoneAction(response)) 80 | .catch(err => { 81 | err.error += state.loading // just append something from state to test 82 | return Observable.of(refreshFailAction(err)) 83 | }) 84 | }); 85 | } 86 | 87 | function getPages(state) { 88 | const numberOfPages = Math.floor(state.items.length / state.pages.perPage) + 1; 89 | var ret = [] 90 | for (var i=0; i { 103 | ReduxTdd({ items, error, loading, pages }, state => [ 104 | , 106 | , 108 | , 110 | , 112 | 115 | ]) 116 | .it('should not show any items') 117 | .contains(, false) 118 | 119 | .it('should click on refresh button') 120 | .switch(RefreshButton) 121 | .action(props => props.onClick()) 122 | 123 | .it('should show loading') 124 | .switch(Loading) 125 | .toMatchProps({ loading: true }) 126 | 127 | .it('should trigger a successful HTTP response') 128 | .epic(handleRefreshEpic, { getJSON: () => 129 | Observable.of([ 130 | { name: 'redux-tdd' }, { name: 'redux-cycles' }, { name: 'foo bar'} 131 | ]) 132 | }) 133 | 134 | .it('should show items based on the number of page selected') 135 | .switch(List) 136 | .toMatchProps({ items: [ 137 | { name: 'redux-tdd' }, { name: 'redux-cycles' } 138 | ]}) 139 | 140 | .it('should hide loading') 141 | .switch(Loading) 142 | .toMatchProps({ loading: false }) 143 | 144 | .it('should render correct amount of pages') 145 | .switch(Pages) 146 | .toMatchProps({ pages: [0, 1] }) 147 | 148 | .it('should select second page (index 1)') 149 | .action(props => props.onPageClick(1)) 150 | 151 | .it('should show items based on second page') 152 | .switch(List) 153 | .toMatchProps({ items: [ 154 | { name: 'foo bar' } 155 | ]}) 156 | 157 | .it('should trigger an error response') 158 | .switch(RefreshButton) 159 | .action(props => props.onClick()) 160 | .epic(handleRefreshEpic, { getJSON: () => 161 | Observable.throw({ error: 'Some error' }) 162 | }) 163 | 164 | .it('should test that Error component got right error message') 165 | .switch(Error) 166 | .toMatchProps({ 167 | message: 'Some errortrue' 168 | }) 169 | }) 170 | -------------------------------------------------------------------------------- /test/GithubTrending.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import ReduxTdd from '../src/redux-tdd'; 6 | 7 | function GithubTrending({ projects, loading, onRefresh }) { 8 | return
9 | { loading &&
} 10 |
11 | { !projects.length && 'No projects' } 12 | { projects.map((p, idx) =>
{p.name}
) } 13 |
14 | 15 |
16 | } 17 | 18 | function refreshAction() { 19 | return { type: 'REFRESH' }; 20 | } 21 | function refreshDoneAction(payload) { 22 | return { type: 'REFRESH_DONE', payload }; 23 | } 24 | function refreshFailAction(payload) { 25 | return { type: 'REFRESH_FAIL', payload, error: true }; 26 | } 27 | 28 | const initialState = { projects: [], loading: false }; 29 | function github(state = initialState, action) { 30 | switch (action.type) { 31 | case 'REFRESH': 32 | return { ...state, loading: true }; 33 | case 'REFRESH_DONE': 34 | return { ...state, loading: false, projects: action.payload }; 35 | case 'REFRESH_FAIL': 36 | return { ...state, loading: false, projects: [] }; 37 | default: 38 | return state; 39 | } 40 | } 41 | 42 | function handleRefreshEpic(action$, store, { getJSON }) { 43 | return action$.ofType('REFRESH') 44 | .mergeMap(() => 45 | getJSON('http://foo.bar') 46 | .map(response => refreshDoneAction(response)) 47 | .catch(err => Observable.of(refreshFailAction(err), setErrorAction(err))) 48 | ); 49 | } 50 | 51 | function error(state = { message: null }, action) { 52 | switch (action.type) { 53 | case 'ERROR': 54 | return { ...state, message: action.payload.error } 55 | default: 56 | return state; 57 | } 58 | } 59 | 60 | function setErrorAction(error) { 61 | return { type: 'ERROR', payload: error }; 62 | } 63 | 64 | function Error({ message }) { 65 | return
{message}
66 | } 67 | 68 | // const test = [ 69 | // // by default let's work on first component in list 70 | // { op: 'contains', value:
No projects
}, 71 | // { op: 'action', value: 'onRefresh' }, 72 | // { op: 'toMatchProps', value: { loading: true } }, 73 | // { op: 'contains', value:
}, 74 | // 75 | // { op: 'epic', value: { 76 | // dependency: 'getJSON', 77 | // output: Observable.of([ 78 | // { name: 'redux-tdd' }, { name: 'redux-cycles' } 79 | // ]) 80 | // } }, 81 | // 82 | // { op: 'toMatchProps', value: { 83 | // loading: false, 84 | // projects: [{ name: 'redux-tdd' }, { name: 'redux-cycles' }] 85 | // } }, 86 | // 87 | // ] 88 | // ReduxTdd({ github, error }, [ GithubTrending, Error ], test) 89 | 90 | const actions = { 91 | clickRefreshBtn: props => props.onRefresh() 92 | } 93 | 94 | const props = { 95 | isLoading: { loading: true } 96 | } 97 | 98 | const views = { 99 | loading:
, 100 | noProjects:
No projects
, 101 | refreshBtn: , 102 | } 103 | 104 | describe('', () => { 105 | ReduxTdd({ github, error }, state => [ 106 | 110 | , 111 | 112 | ]) 113 | .it('should show no loading and no projects') 114 | .contains(views.loading, false) 115 | .contains(views.noProjects) 116 | .contains(views.refreshBtn) 117 | 118 | .it('should click refresh button and show loading') 119 | .action(actions.clickRefreshBtn) 120 | .toMatchProps(props.isLoading) 121 | .contains(views.loading) 122 | 123 | .it('should simulate http success and render response') 124 | .epic(handleRefreshEpic, { getJSON: () => 125 | Observable.of([ 126 | { name: 'redux-tdd' }, { name: 'redux-cycles' } 127 | ]) 128 | }) 129 | .toMatchProps({ 130 | loading: false, 131 | projects: [{ name: 'redux-tdd' }, { name: 'redux-cycles' }] 132 | }) 133 | .contains(views.loading, false) // shouldn't show loading 134 | .contains(
135 |
redux-tdd
136 |
redux-cycles
137 |
) 138 | 139 | .it('should click refresh and simulate http error response') 140 | .action(actions.clickRefreshBtn) 141 | .epic(handleRefreshEpic, { getJSON: () => 142 | Observable.throw({ error: 'Some error' }) 143 | }) 144 | 145 | .toMatchProps({ 146 | loading: false, 147 | projects: [], 148 | }) 149 | .contains(views.noProjects) 150 | 151 | .it('should test that Error component got right error message') 152 | // consume the second action emitted by observable 153 | .action((props, epicAction) => { 154 | expect(epicAction).toMatchObject({ type: 'ERROR', payload: { error: 'Some error' } }) 155 | return epicAction 156 | }) 157 | .switch(1) 158 | .toMatchProps({ 159 | message: 'Some error' 160 | }) 161 | .contains(
{'Some error'}
) 162 | }) 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Redux TDD 2 | 3 | ### Install 4 | 5 | `npm install --save-dev redux-tdd` 6 | 7 | 8 | Also, redux-observable is a peer-dependency so make sure you have it installed before you use Redux TDD: `npm install --save-dev redux-observable` 9 | 10 | ### Example 11 | 12 | ```js 13 | import reduxTdd from 'redux-tdd'; 14 | 15 | // reduxTdd() takes as arguments an object which will be passed to redux combineReducers() 16 | // and a function which returns an array of components that map your state to props 17 | reduxTdd({ counter: counterReducer }, state => [ 18 | 21 | ]) 22 | .action(props => props.onClick()) // action() takes a function that must return a redux action 23 | .toMatchProps({ counter: 1 }) // toMatchProps() checks whether the component took the correct props 24 | .contains(1) // finally contains() checks that the component contains correct value 25 | ``` 26 | 27 | ### Testing multiple components 28 | 29 | You can return multiple components (it's an array) if you want to test how they work with each other. Then you can use the `.switch('ComponentName')` operator to assert certain things about the component. You can also use the `.it(string)` to document what you're testing so it's easier to know where things break: 30 | 31 | ```js 32 | const TodoList = ({ listItems }) => 33 |
34 | {listItems.map(i =>
{i}
)} 35 |
36 | 37 | const AddTodo = ({ onAdd }) => 38 | 39 | 40 | function addTodo(todoText) { 41 | return { type: 'ADD_TODO', payload: todoText } 42 | } 43 | 44 | function getVisibleItems(state) { 45 | return Object.keys(state.list).length 46 | ? Object.keys(state.list).map(key => key) 47 | : [] 48 | } 49 | 50 | function list(state = {}, action) { 51 | switch (action.type) { 52 | case 'ADD_TODO': 53 | return { ...state, [action.payload]: {} } 54 | default: 55 | return state 56 | } 57 | } 58 | 59 | reduxTdd({ list }, state => [ 60 | , 61 | 62 | ]) 63 | .it('should add a todo item') 64 | .switch('AddTodo') // the next dot-chained calls will work on AddTodo 65 | .action(props => props.onAdd('clean house')) // add 'clean house' 66 | .switch('TodoList') // back to TodoList 67 | .toMatchProps({ listItems: ["clean house"] }) 68 | ``` 69 | 70 | ### Async behavior 71 | 72 | Testing async behavior with Redux TDD can currently only be done using redux-observable. There's a specific `.epic()` operator that works such as: 73 | 74 | ```js 75 | function fetchItems() { 76 | return { type: 'FETCH_ITEMS' } 77 | } 78 | 79 | function handleFetchItems(action$, store, { getJSON }) { 80 | return action$ 81 | .ofType('FETCH_ITEMS') 82 | .switchMap(action => 83 | getJSON('http://foo') 84 | .map(response => 85 | response.reduce((acc, curr) => 86 | ({ 87 | ...acc, 88 | [curr.id]: curr, 89 | }) 90 | , {}) 91 | ) 92 | .map(response => recievedItems(response)) 93 | ) 94 | } 95 | 96 | reduxTdd({ items }, state => ([ 97 | 101 | ])) 102 | .it('should load items') 103 | .action(props => props.fetchItems()) 104 | .epic(handleFetchItems, { getJSON: () => 105 | // force/mock the API call to return this JSON 106 | Observable.of([ 107 | { id: 1, name: 'Foo', date: 'March' }, 108 | { id: 2, name: 'Bar', date: 'November' } 109 | ]) 110 | }) 111 | .toMatchProps({ items: 112 | { 113 | 1: { id: 1, name: 'Foo', date: 'March' }, 114 | 2: { id: 2, name: 'Bar', date: 'November' } 115 | } 116 | }) 117 | ``` 118 | 119 | `.epic()` works similar to the `.action()` operator, in the sense that it will dispatch the action returned by the epic, so you can chain `.toMatchProps()` after it to assert things about your components. 120 | 121 | ### Handling multiple async actions dispatched 122 | 123 | In redux-observable you may find yourself dispatching multiple actions: 124 | 125 | ```js 126 | function handleRefreshEpic(action$, store, { getJSON }) { 127 | return action$.ofType('REFRESH') 128 | .mergeMap(() => 129 | getJSON('http://foo.bar') 130 | .map(response => refreshDoneAction(response)) 131 | .catch(err => Observable.of(refreshFailAction(err), setErrorAction(err))) 132 | ); 133 | } 134 | ``` 135 | 136 | As you can see, this epic above will dispatch two actions if an error occurs. To test this in Redux TDD we can test the first action (`refreshFailAction`) as we did before: 137 | 138 | ```js 139 | .it('should click refresh and simulate http error response') 140 | .action(actions.clickRefreshBtn) 141 | .epic(handleRefreshEpic, { getJSON: () => 142 | Observable.throw({ error: 'Some error' }) 143 | }) 144 | .toMatchProps({ 145 | loading: false, 146 | projects: [], 147 | }) 148 | ``` 149 | 150 | And to test the second action (`setErrorAction`), we can call `.action()` again and use its second parameter to access the epicAction in the pipe. If your epic would return a third action, you can call .action again and things would be automatically shifted from the internal pipeline. 151 | 152 | ```js 153 | // consume the second action emitted by observable 154 | .action((props, epicAction) => { 155 | expect(epicAction).toMatchObject({ type: 'ERROR', payload: { error: 'Some error' } }) 156 | return epicAction 157 | }) 158 | .switch('Error') 159 | .toMatchProps({ 160 | message: 'Some error' 161 | }) 162 | ``` 163 | 164 | This is really cool because you can test in between your async calls, and make sure all your components render as expected. 165 | 166 | ### About 167 | 168 | Check out my HOW-TO article on Hacker Noon: https://hackernoon.com/redux-tdd-a-deep-dive-344cd7682a54 169 | 170 | Also to learn more in depth how to use Redux TDD please look inside the `/test` folder. 171 | --------------------------------------------------------------------------------