├── .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 | add todo
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 |
refresh
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(refresh )
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 |
refresh
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(refresh )
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 | ?
reset
11 | :
increment
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 | ?
reset
11 | :
increment
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
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 | ?
reset
11 | :
increment
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 |
refresh
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: refresh ,
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 | add todo
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 |
--------------------------------------------------------------------------------