From ccf1416de48ae81c823d7e58b8d3f9f0fa0df5fb Mon Sep 17 00:00:00 2001 From: Frank Showalter Date: Wed, 16 Mar 2016 21:05:26 -0400 Subject: [PATCH] add async example tests (for #1481) --- examples/async/actions/index.js | 2 +- examples/async/containers/App.js | 28 +-- examples/async/package.json | 9 +- examples/async/test/.eslintrc | 5 + examples/async/test/actions/actions.spec.js | 167 ++++++++++++++++++ examples/async/test/components/App.spec.js | 123 +++++++++++++ examples/async/test/components/Picker.spec.js | 52 ++++++ examples/async/test/components/Posts.spec.js | 36 ++++ examples/async/test/reducers/reducer.spec.js | 89 ++++++++++ 9 files changed, 496 insertions(+), 15 deletions(-) create mode 100644 examples/async/test/.eslintrc create mode 100644 examples/async/test/actions/actions.spec.js create mode 100644 examples/async/test/components/App.spec.js create mode 100644 examples/async/test/components/Picker.spec.js create mode 100644 examples/async/test/components/Posts.spec.js create mode 100644 examples/async/test/reducers/reducer.spec.js diff --git a/examples/async/actions/index.js b/examples/async/actions/index.js index b3c32cd68d..490a27b9b2 100644 --- a/examples/async/actions/index.js +++ b/examples/async/actions/index.js @@ -1,4 +1,4 @@ -import fetch from 'isomorphic-fetch' +import 'isomorphic-fetch' export const REQUEST_POSTS = 'REQUEST_POSTS' export const RECEIVE_POSTS = 'RECEIVE_POSTS' diff --git a/examples/async/containers/App.js b/examples/async/containers/App.js index 6109878fca..145d5f2fdb 100644 --- a/examples/async/containers/App.js +++ b/examples/async/containers/App.js @@ -1,38 +1,38 @@ import React, { Component, PropTypes } from 'react' import { connect } from 'react-redux' -import { selectReddit, fetchPostsIfNeeded, invalidateReddit } from '../actions' +import * as actions from '../actions' import Picker from '../components/Picker' import Posts from '../components/Posts' -class App extends Component { +export class App extends Component { constructor(props) { super(props) this.handleChange = this.handleChange.bind(this) this.handleRefreshClick = this.handleRefreshClick.bind(this) } - componentDidMount() { - const { dispatch, selectedReddit } = this.props - dispatch(fetchPostsIfNeeded(selectedReddit)) + componentWillMount() { + const { selectedReddit, fetchPostsIfNeeded } = this.props + fetchPostsIfNeeded(selectedReddit) } componentWillReceiveProps(nextProps) { if (nextProps.selectedReddit !== this.props.selectedReddit) { - const { dispatch, selectedReddit } = nextProps - dispatch(fetchPostsIfNeeded(selectedReddit)) + const { fetchPostsIfNeeded, selectedReddit } = nextProps + fetchPostsIfNeeded(selectedReddit) } } handleChange(nextReddit) { - this.props.dispatch(selectReddit(nextReddit)) + this.props.selectReddit(nextReddit) } handleRefreshClick(e) { e.preventDefault() - const { dispatch, selectedReddit } = this.props - dispatch(invalidateReddit(selectedReddit)) - dispatch(fetchPostsIfNeeded(selectedReddit)) + const { invalidateReddit, fetchPostsIfNeeded, selectedReddit } = this.props + invalidateReddit(selectedReddit) + fetchPostsIfNeeded(selectedReddit) } render() { @@ -73,7 +73,9 @@ App.propTypes = { posts: PropTypes.array.isRequired, isFetching: PropTypes.bool.isRequired, lastUpdated: PropTypes.number, - dispatch: PropTypes.func.isRequired + fetchPostsIfNeeded: PropTypes.func.isRequired, + selectReddit: PropTypes.func.isRequired, + invalidateReddit: PropTypes.func.isRequired } function mapStateToProps(state) { @@ -95,4 +97,4 @@ function mapStateToProps(state) { } } -export default connect(mapStateToProps)(App) +export default connect(mapStateToProps, actions)(App) diff --git a/examples/async/package.json b/examples/async/package.json index 6eb76c1b18..9f9b172daf 100644 --- a/examples/async/package.json +++ b/examples/async/package.json @@ -3,7 +3,9 @@ "version": "0.0.0", "description": "Redux async example", "scripts": { - "start": "node server.js" + "start": "node server.js", + "test": "cross-env NODE_ENV=test mocha --recursive --compilers js:babel-register", + "test:watch": "npm test -- --watch" }, "repository": { "type": "git", @@ -41,9 +43,14 @@ "babel-preset-es2015": "^6.3.13", "babel-preset-react": "^6.3.13", "babel-preset-react-hmre": "^1.1.1", + "cross-env": "^1.0.7", + "enzyme": "^2.0.0", "expect": "^1.6.0", "express": "^4.13.3", + "fetch-mock": "^4.1.1", + "mocha": "^2.2.5", "node-libs-browser": "^0.5.2", + "react-addons-test-utils": "^0.14.7", "webpack": "^1.9.11", "webpack-dev-middleware": "^1.2.0", "webpack-hot-middleware": "^2.9.1" diff --git a/examples/async/test/.eslintrc b/examples/async/test/.eslintrc new file mode 100644 index 0000000000..7eeefc33b6 --- /dev/null +++ b/examples/async/test/.eslintrc @@ -0,0 +1,5 @@ +{ + "env": { + "mocha": true + } +} diff --git a/examples/async/test/actions/actions.spec.js b/examples/async/test/actions/actions.spec.js new file mode 100644 index 0000000000..437c8e2d46 --- /dev/null +++ b/examples/async/test/actions/actions.spec.js @@ -0,0 +1,167 @@ +import expect from 'expect' +import fetchMock from 'fetch-mock' +import { fetchPostsIfNeeded } from '../../actions' + +function setup(reddit = 'reactjs') { + const dispatch = expect.createSpy() + const getState = expect.createSpy() + + const actionCreator = fetchPostsIfNeeded(reddit) + + const response = { + body: JSON.stringify({ + data: { + children: [ + { data: 'post 1' }, + { data: 'post 2' } + ] + } + }) + } + + fetchMock.mock(`https://www.reddit.com/r/${reddit}.json`, response) + + return { + actionCreator: actionCreator, + dispatch: dispatch, + getState: getState + } +} + +function callThroughWithDispatch(dispatch) { + return dispatch.calls[0].arguments[0](dispatch) +} + +describe('fetchPostsIfNeeded action', () => { + afterEach(() => { + fetchMock.restore() + }) + + it('should dispatch REQUEST_POSTS action', () => { + const { actionCreator, dispatch, getState } = setup() + + getState.andReturn({ + postsByReddit: {} + }) + + actionCreator(dispatch, getState) + + callThroughWithDispatch(dispatch) + + expect(dispatch).toHaveBeenCalledWith({ + type: 'REQUEST_POSTS', + reddit: 'reactjs' + }) + }) + + it('should dispatch RECEIVE_POSTS action', () => { + const { actionCreator, dispatch, getState } = setup() + + getState.andReturn({ + postsByReddit: {} + }) + + actionCreator(dispatch, getState) + + return callThroughWithDispatch(dispatch) + .then(() => { + const { type, reddit, posts } = dispatch.calls[2].arguments[0] + + const action = { type, reddit, posts } + + expect(action).toEqual({ + type: 'RECEIVE_POSTS', + reddit: 'reactjs', + posts: [ 'post 1', 'post 2' ] + }) + }) + }) + + describe('when fetching', () => { + it('should not dispatch REQUEST_POSTS action', () => { + const { actionCreator, dispatch, getState } = setup() + + getState.andReturn({ + postsByReddit: { + reactjs: { + isFetching: true + } + } + }) + + actionCreator(dispatch, getState) + + expect(dispatch).toNotHaveBeenCalled() + }) + }) + + describe('when existing posts', () => { + it('should not dispatch REQUEST_POSTS action', () => { + const { actionCreator, dispatch, getState } = setup() + + getState.andReturn({ + postsByReddit: { + reactjs: { + posts: [ 'post 3', 'post 4' ] + } + } + }) + + actionCreator(dispatch, getState) + + expect(dispatch).toNotHaveBeenCalled() + }) + + describe('when invalidated', () => { + it('should dispatch REQUEST_POSTS action', () => { + const { actionCreator, dispatch, getState } = setup() + + getState.andReturn({ + postsByReddit: { + reactjs: { + posts: [ 'post 3', 'post 4' ], + didInvalidate: true + } + } + }) + + actionCreator(dispatch, getState) + + callThroughWithDispatch(dispatch) + + expect(dispatch).toHaveBeenCalledWith({ + type: 'REQUEST_POSTS', + reddit: 'reactjs' + }) + }) + + it('should dispatch RECEIVE_POSTS action', () => { + const { actionCreator, dispatch, getState } = setup() + + getState.andReturn({ + postsByReddit: { + reactjs: { + posts: [ 'post 3', 'post 4' ], + didInvalidate: true + } + } + }) + + actionCreator(dispatch, getState) + + return callThroughWithDispatch(dispatch) + .then(() => { + const { type, reddit, posts } = dispatch.calls[2].arguments[0] + + const action = { type, reddit, posts } + + expect(action).toEqual({ + type: 'RECEIVE_POSTS', + reddit: 'reactjs', + posts: [ 'post 1', 'post 2' ] + }) + }) + }) + }) + }) +}) diff --git a/examples/async/test/components/App.spec.js b/examples/async/test/components/App.spec.js new file mode 100644 index 0000000000..1cd66829cc --- /dev/null +++ b/examples/async/test/components/App.spec.js @@ -0,0 +1,123 @@ +import expect from 'expect' +import React from 'react' +import { shallow } from 'enzyme' +import { App } from '../../containers/App' +import Picker from '../../components/Picker' +import Posts from '../../components/Posts' + +function setup(selectedReddit, isFetching = false, posts = []) { + const actions = { + fetchPostsIfNeeded: expect.createSpy(), + selectReddit: expect.createSpy(), + invalidateReddit: expect.createSpy() + } + + const eventArgs = { + preventDefault: expect.createSpy() + } + + const component = shallow( + + ) + + return { + component: component, + actions: actions, + picker: component.find(Picker), + p: component.find('p'), + refreshLink: component.findWhere(n => n.type() === 'a' && n.contains('Refresh')), + status: component.find('h2'), + postList: component.find(Posts), + postListWrap: component.find('div').last(), + eventArgs: eventArgs + } +} + +describe('App component', () => { + it('should render Picker component', () => { + const { picker } = setup('reactjs') + expect(picker.length).toEqual(1) + }) + + it('should render last updated', () => { + const { p } = setup('reactjs') + expect(p.text()).toMatch(/Last updated at /) + }) + + it('should render refresh link', () => { + const { refreshLink } = setup('reactjs') + expect(refreshLink.length).toEqual(1) + }) + + it('should render empty status', () => { + const { status } = setup('reactjs') + expect(status.text()).toEqual('Empty.') + }) + + describe('when fetching', () => { + it('should not render refresh link', () => { + const { refreshLink } = setup('reactjs', true) + expect(refreshLink.length).toEqual(0) + }) + + it('should render loading status', () => { + const { status } = setup('reactjs', true) + expect(status.text()).toEqual('Loading...') + }) + }) + + describe('when given posts', () => { + const posts = [ + { title: 'Post 1' }, + { title: 'Post 2' } + ] + + it('should render Posts component', () => { + const { postList } = setup('reactjs', false, posts) + expect(postList.prop('posts')).toEqual(posts) + }) + + describe('when fetching', () => { + it('should render Posts at half opacity', () => { + const { postListWrap } = setup('reactjs', true, posts) + expect(postListWrap.prop('style')).toEqual({ opacity: '0.50' }) + }) + }) + }) + + it('should call selectReddit action on Picker change', () => { + const { picker, actions } = setup('reactjs') + picker.simulate('change') + expect(actions.selectReddit).toHaveBeenCalled() + }) + + it('should call invalidateReddit action on refresh click', () => { + const { refreshLink, actions, eventArgs } = setup('reactjs') + refreshLink.simulate('click', eventArgs) + expect(actions.invalidateReddit).toHaveBeenCalled() + }) + + it('should call fetchPostsIfNeeded action on mount', () => { + const { actions } = setup('reactjs') + expect(actions.fetchPostsIfNeeded).toHaveBeenCalled() + }) + + it('should call fetchPostsIfNeeded action on refresh click', () => { + const { refreshLink, actions, eventArgs } = setup('reactjs') + refreshLink.simulate('click', eventArgs) + + expect(actions.fetchPostsIfNeeded.calls.length).toEqual(2) + }) + + it('should call fetchPostsIfNeeded action when given new selectedReddit', () => { + const { component, actions } = setup('reactjs') + component.setProps(Object.assign({}, actions, { selectedReddit: 'frontend', posts: [], isFetching: false, lastUpdated: new Date().getTime() })) + + expect(actions.fetchPostsIfNeeded.calls.length).toEqual(2) + }) +}) diff --git a/examples/async/test/components/Picker.spec.js b/examples/async/test/components/Picker.spec.js new file mode 100644 index 0000000000..6eba671805 --- /dev/null +++ b/examples/async/test/components/Picker.spec.js @@ -0,0 +1,52 @@ +import expect from 'expect' +import React from 'react' +import { shallow } from 'enzyme' +import Picker from '../../components/Picker' + +function setup(value, options = []) { + const actions = { + onChange: expect.createSpy() + } + + const eventArgs = { + target: { + value: expect.createSpy() + } + } + + const component = shallow( + + ) + + return { + component: component, + actions: actions, + header: component.find('h1'), + select: component.find('select'), + options: component.find('option'), + eventArgs: eventArgs + } +} + +describe('Picker component', () => { + it('should display value as header', () => { + const { header } = setup('Test Value') + expect(header.text()).toEqual('Test Value') + }) + + it('should select value', () => { + const { select } = setup('Test Value', [ 'Test Value' ]) + expect(select.prop('value')).toEqual('Test Value') + }) + + it('should render option node for each option', () => { + const { options } = setup('Test Value', [ 'Test Value', 'Other Value' ]) + expect(options.length).toEqual(2) + }) + + it('should call action on select change', () => { + const { select, actions, eventArgs } = setup('Test Value', [ 'Test Value', 'Other Value' ]) + select.simulate('change', eventArgs) + expect(actions.onChange).toHaveBeenCalled() + }) +}) diff --git a/examples/async/test/components/Posts.spec.js b/examples/async/test/components/Posts.spec.js new file mode 100644 index 0000000000..f1ab415eb3 --- /dev/null +++ b/examples/async/test/components/Posts.spec.js @@ -0,0 +1,36 @@ +import expect from 'expect' +import React from 'react' +import { shallow } from 'enzyme' +import Posts from '../../components/Posts' + +function setup(posts = []) { + const component = shallow( + + ) + + return { + component: component, + listItems: component.find('li') + } +} + +describe('Posts component', () => { + it('should render a list item with post title', () => { + const posts = [ + { title: 'Post 1' } + ] + + const { listItems } = setup(posts) + expect(listItems.first().text()).toEqual('Post 1') + }) + + it('should render a list item for each post', () => { + const posts = [ + { title: 'Post 1' }, + { title: 'Post 2' } + ] + + const { listItems } = setup(posts) + expect(listItems.length).toEqual(2) + }) +}) diff --git a/examples/async/test/reducers/reducer.spec.js b/examples/async/test/reducers/reducer.spec.js new file mode 100644 index 0000000000..47ec4bdfb1 --- /dev/null +++ b/examples/async/test/reducers/reducer.spec.js @@ -0,0 +1,89 @@ +import expect from 'expect' +import reducer from '../../reducers' +import { RECEIVE_POSTS, INVALIDATE_REDDIT, REQUEST_POSTS, SELECT_REDDIT } from '../../actions' + +describe('reducer', () => { + it('should provide the initial state', () => { + expect(reducer(undefined, {})).toEqual({ postsByReddit: {}, selectedReddit: 'reactjs' }) + }) + + it('should handle RECEIVE_POSTS action', () => { + const action = { + type: RECEIVE_POSTS, + reddit: 'reactjs', + posts: [ 'post 1', 'post 2' ], + receivedAt: 'now' + } + + const stateAfter = { + postsByReddit: { + reactjs: { + didInvalidate: false, + isFetching: false, + items: [ + 'post 1', + 'post 2' + ], + lastUpdated: 'now' + } + }, + selectedReddit: 'reactjs' + } + + expect(reducer(undefined, action)).toEqual(stateAfter) + }) + + it('should handle INVALIDATE_REDDIT action', () => { + const action = { + type: INVALIDATE_REDDIT, + reddit: 'reactjs' + } + + const stateAfter = { + postsByReddit: { + reactjs: { + didInvalidate: true, + isFetching: false, + items: [] + } + }, + selectedReddit: 'reactjs' + } + + expect(reducer(undefined, action)).toEqual(stateAfter) + }) + + it('should handle REQUEST_POSTS action', () => { + const action = { + type: REQUEST_POSTS, + reddit: 'reactjs' + } + + const stateAfter = { + postsByReddit: { + reactjs: { + didInvalidate: false, + isFetching: true, + items: [] + } + }, + selectedReddit: 'reactjs' + } + + expect(reducer(undefined, action)).toEqual(stateAfter) + }) + + it('should handle RECEIVE_POSTS action', () => { + const action = { + type: SELECT_REDDIT, + reddit: 'frontend' + } + + const stateAfter = { + postsByReddit: {}, + selectedReddit: 'frontend' + } + + expect(reducer(undefined, action)).toEqual(stateAfter) + }) +})