├── .travis.yml ├── .editorconfig ├── .gitignore ├── .babelrc ├── docs ├── images │ ├── jest-logo.png │ ├── house-of-cards.jpg │ ├── timing-issues.jpg │ ├── heavy-machinery.jpg │ ├── lego-components.jpg │ ├── shallow-rendering.odg │ ├── swiss-army-knife.jpg │ ├── container-components.odg │ ├── component-inputs-outputs.odg │ ├── container-components-mvc.odg │ ├── isolation-component-tree.odg │ ├── isolation-require-graph.odg │ ├── isolation-component-tree.svg │ ├── isolation-require-graph.svg │ ├── component-inputs-outputs.svg │ ├── container-components-mvc.svg │ ├── shallow-rendering.svg │ └── container-components.svg └── react-london-talk.html ├── tests ├── tests.js ├── utils.js ├── setup.js ├── TweetItem_test.js ├── StatusView_test.js └── TweetList_test.js ├── index.html ├── tests.html ├── assets └── ic_refresh_24px.svg ├── src ├── TweetStore.js ├── FeedActions.js ├── StatusView.js ├── TweetList.js ├── app.js ├── TweetListContainer.js └── TweetItem.js ├── Makefile ├── webpack.config.js ├── package.json ├── app.css ├── server └── server.js └── README.md /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4.1" 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.js] 2 | indent_style=space 3 | indent_size=2 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | *.swp 4 | server-config.js 5 | .~lock* 6 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["transform-react-jsx"], 3 | "presets": ["es2015"] 4 | } 5 | -------------------------------------------------------------------------------- /docs/images/jest-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertknight/react-testing/HEAD/docs/images/jest-logo.png -------------------------------------------------------------------------------- /docs/images/house-of-cards.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertknight/react-testing/HEAD/docs/images/house-of-cards.jpg -------------------------------------------------------------------------------- /docs/images/timing-issues.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertknight/react-testing/HEAD/docs/images/timing-issues.jpg -------------------------------------------------------------------------------- /docs/images/heavy-machinery.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertknight/react-testing/HEAD/docs/images/heavy-machinery.jpg -------------------------------------------------------------------------------- /docs/images/lego-components.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertknight/react-testing/HEAD/docs/images/lego-components.jpg -------------------------------------------------------------------------------- /docs/images/shallow-rendering.odg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertknight/react-testing/HEAD/docs/images/shallow-rendering.odg -------------------------------------------------------------------------------- /docs/images/swiss-army-knife.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertknight/react-testing/HEAD/docs/images/swiss-army-knife.jpg -------------------------------------------------------------------------------- /docs/images/container-components.odg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertknight/react-testing/HEAD/docs/images/container-components.odg -------------------------------------------------------------------------------- /docs/images/component-inputs-outputs.odg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertknight/react-testing/HEAD/docs/images/component-inputs-outputs.odg -------------------------------------------------------------------------------- /docs/images/container-components-mvc.odg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertknight/react-testing/HEAD/docs/images/container-components-mvc.odg -------------------------------------------------------------------------------- /docs/images/isolation-component-tree.odg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertknight/react-testing/HEAD/docs/images/isolation-component-tree.odg -------------------------------------------------------------------------------- /docs/images/isolation-require-graph.odg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robertknight/react-testing/HEAD/docs/images/isolation-require-graph.odg -------------------------------------------------------------------------------- /tests/tests.js: -------------------------------------------------------------------------------- 1 | // this is the entry point for the test suite for use in 2 | // webpack 3 | require('./TweetItem_test'); 4 | require('./TweetList_test'); 5 | 6 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /assets/ic_refresh_24px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/TweetStore.js: -------------------------------------------------------------------------------- 1 | import {Store} from 'flummox'; 2 | 3 | export default class TweetStore extends Store { 4 | constructor(flux) { 5 | super(); 6 | 7 | this.state = { 8 | tweets: [] 9 | }; 10 | 11 | const feedActionIds = flux.getActionIds('tweets'); 12 | this.register(feedActionIds.fetchTimeline, this.handleTimelineUpdate); 13 | } 14 | 15 | getTweets() { 16 | return this.state.tweets; 17 | } 18 | 19 | handleTimelineUpdate(tweets) { 20 | this.setState({tweets: tweets}); 21 | } 22 | } 23 | 24 | -------------------------------------------------------------------------------- /tests/utils.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {unmountComponentAtNode} from 'react-dom'; 3 | 4 | export function withContainer(callback) { 5 | if (typeof document === 'undefined') { 6 | throw new Error('DOM environment has not been set up'); 7 | } 8 | 9 | var React = require('react'); 10 | 11 | let appElement = document.getElementById('app'); 12 | if (!appElement) { 13 | appElement = document.createElement('div'); 14 | appElement.id = 'app'; 15 | document.body.appendChild(appElement); 16 | } 17 | 18 | appElement.innerHTML = ''; 19 | callback(appElement); 20 | unmountComponentAtNode(appElement); 21 | } 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MOCHA_ARGS=--compilers js:babel-core/register --require tests/setup.js 2 | SRC_FILES=$(wildcard src/*.js) webpack.config.js 3 | TEST_FILES=$(wildcard tests/*.js) 4 | NODE_BIN=$(PWD)/node_modules/.bin 5 | 6 | all: dist/app.bundle.js dist/tests.bundle.js 7 | 8 | dist/app.bundle.js: $(SRC_FILES) 9 | $(NODE_BIN)/webpack 10 | 11 | dist/tests.bundle.js: $(SRC_FILES) $(TEST_FILES) 12 | $(NODE_BIN)/webpack 13 | 14 | test: 15 | $(NODE_BIN)/mocha $(MOCHA_ARGS) $(TEST_FILES) 16 | 17 | test-watch: 18 | $(NODE_BIN)/mocha $(MOCHA_ARGS) --watch $(TEST_FILES) 19 | 20 | serve: 21 | $(NODE_BIN)/webpack-dev-server --hot --quiet 22 | 23 | clean: 24 | rm -rf dist 25 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var RewirePlugin = require('rewire-webpack'); 2 | 3 | module.exports = { 4 | entry: { 5 | app: ['./src/app', 'webpack/hot/dev-server'], 6 | tests: ['./tests/tests', 'webpack/hot/dev-server'] 7 | }, 8 | output: { 9 | path: __dirname + '/dist', 10 | filename: '[name].bundle.js' 11 | }, 12 | module: { 13 | loaders: [{ 14 | test: /\.js$/, 15 | exclude: /node_modules/, 16 | loader: 'babel-loader' 17 | },{ 18 | test: /tests.*_test\.js$/, 19 | loader: 'mocha-loader!babel-loader' 20 | },{ 21 | test: /node_modules\/(jsdom|node-fetch)/, 22 | loader: 'null-loader' 23 | }] 24 | }, 25 | plugins: [ 26 | new RewirePlugin() 27 | ] 28 | }; 29 | -------------------------------------------------------------------------------- /src/FeedActions.js: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-fetch'; 2 | import {Actions} from 'flummox'; 3 | 4 | const TWITTER_FEED_URL = 'data/tweets.js'; 5 | 6 | export default class FeedActions extends Actions { 7 | fetchTimeline() { 8 | return fetch(TWITTER_FEED_URL).then(res => { 9 | return res.json(); 10 | }).then(json => { 11 | return json.map(entry => { 12 | return { 13 | id: entry.id, 14 | user: { 15 | screenName: entry.user.screen_name, 16 | description: entry.user.name, 17 | icon: entry.user.profile_image_url_https 18 | }, 19 | text: entry.text, 20 | createdAt: entry.created_at 21 | } 22 | }); 23 | }).catch(err => { 24 | console.error('fetching tweets failed'); 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/StatusView.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import React, {Component} from 'react'; 3 | 4 | export default class StatusView extends Component { 5 | constructor(props) { 6 | super(props); 7 | 8 | this.state = { 9 | refreshing: false 10 | }; 11 | } 12 | 13 | render() { 14 | const feedActions = this.props.flux.getActions('tweets'); 15 | const fetchTimeline = () => { 16 | this.setState({refreshing: true}); 17 | feedActions.fetchTimeline().then(() => { 18 | this.setState({refreshing: false}); 19 | }); 20 | }; 21 | 22 | return
23 | 27 |
; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- 1 | // this handles setup of the fake DOM when the tests are 2 | // run in Node 3 | 4 | import jsdom from 'jsdom'; 5 | 6 | var FAKE_DOM_HTML = ` 7 | 8 | 9 | 10 | 11 | `; 12 | 13 | function setupFakeDOM() { 14 | if (typeof document !== 'undefined') { 15 | // if the fake DOM has already been set up, or 16 | // if running in a real browser, do nothing 17 | return; 18 | } 19 | 20 | // setup the fake DOM environment. 21 | // 22 | // Note that we use the synchronous jsdom.jsdom() API 23 | // instead of jsdom.env() because the 'document' and 'window' 24 | // objects must be available when React is require()-d for 25 | // the first time. 26 | // 27 | // If you want to do any async setup in your tests, use 28 | // the before() and beforeEach() hooks. 29 | global.document = jsdom.jsdom(FAKE_DOM_HTML); 30 | global.window = document.defaultView; 31 | global.navigator = window.navigator; 32 | } 33 | 34 | setupFakeDOM(); 35 | 36 | -------------------------------------------------------------------------------- /src/TweetList.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | 3 | // Imports that are going to be stubbed need to use CommonJS style 4 | // imports rather than ES6-style imports. This is because rewire works 5 | // by changing the value of top-level variables within a module and 6 | // the names of variables generated when Babel converts 'import foo from "bar"' 7 | // to ES5 is not defined. 8 | // 9 | // In future, it should be possible to use 10 | // https://github.com/speedskater/babel-plugin-rewire instead of rewire, once 11 | // compatibility issues with Babel 6 are resolved, see 12 | // https://github.com/speedskater/babel-plugin-rewire/issues/71 13 | var TweetItem = require('./TweetItem').default; 14 | 15 | export default class TweetList extends Component { 16 | constructor(props) { 17 | super(props); 18 | 19 | this.state = { 20 | selectedtweet: null 21 | }; 22 | } 23 | 24 | render() { 25 | return
26 | {this.props.tweets.map(tweet => 27 | this.setState({selectedTweet: tweet})} 33 | /> 34 | )} 35 |
36 | } 37 | } 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-testing", 3 | "version": "1.0.0", 4 | "description": "A simple Twitter client that demonstrates various React testing tools", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "make test" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "http://github.com/robertknight/react-testing.git" 12 | }, 13 | "author": "Robert Knight ", 14 | "license": "ISC", 15 | "dependencies": { 16 | "classnames": "^1.2.0", 17 | "cors": "^2.5.3", 18 | "express": "^4.12.3", 19 | "flummox": "^3.1.1", 20 | "q": "^1.2.0", 21 | "react": "^0.14.6", 22 | "react-dom": "^0.14.6", 23 | "twit": "^1.1.20" 24 | }, 25 | "devDependencies": { 26 | "babel-core": "^6.4.0", 27 | "babel-loader": "^6.2.1", 28 | "babel-plugin-transform-react-jsx": "^6.4.0", 29 | "babel-preset-es2015": "^6.3.13", 30 | "babel-register": "^6.3.13", 31 | "chai": "^2.1.2", 32 | "gulp": "^3.8.11", 33 | "htmlparser": "^1.7.7", 34 | "isomorphic-fetch": "^2.2.0", 35 | "jsdom": ">=4.0.2", 36 | "mocha": "^2.2.1", 37 | "mocha-loader": "^0.7.1", 38 | "null-loader": "^0.1.1", 39 | "react-addons-test-utils": "^0.14.6", 40 | "rewire": "^2.5.1", 41 | "rewire-webpack": "^1.0.1", 42 | "rx": "^2.4.3", 43 | "webpack": "^1.7.3", 44 | "webpack-dev-server": "^1.7.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import {Flux} from 'flummox'; 2 | import React, {Component} from 'react'; 3 | import * as ReactDOM from 'react-dom'; 4 | 5 | import FeedActions from './FeedActions'; 6 | import StatusView from './StatusView'; 7 | import TweetListContainer from './TweetListContainer'; 8 | import TweetStore from './TweetStore'; 9 | 10 | class AppFlux extends Flux { 11 | constructor() { 12 | super(); 13 | 14 | this.createActions('tweets', FeedActions); 15 | this.createStore('tweets', TweetStore, this); 16 | } 17 | 18 | fetchInitialData() { 19 | this.getActions('tweets').fetchTimeline(); 20 | } 21 | } 22 | 23 | class App extends Component { 24 | constructor(props) { 25 | super(props); 26 | 27 | this.state = { 28 | selectedTweet: null 29 | }; 30 | } 31 | 32 | render() { 33 | return
34 |
35 | 36 | 37 | this.setState({selectedTweet: tweet}) 38 | }/> 39 |
40 |
41 | } 42 | } 43 | 44 | var flux = new AppFlux(); 45 | flux.fetchInitialData(); 46 | flux.addListener('dispatch', action => { 47 | console.log('dispatching', action.actionId); 48 | }); 49 | 50 | var content = document.getElementById('app'); 51 | ReactDOM.render(, content); 52 | -------------------------------------------------------------------------------- /src/TweetListContainer.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | 3 | import TweetList from './TweetList'; 4 | import TweetStore from './TweetStore'; 5 | 6 | /** A wrapper around TweetList which handles data fetching 7 | * for that component. 8 | */ 9 | export default class TweetListContainer extends Component { 10 | constructor(props) { 11 | super(props); 12 | 13 | this.tweetStore = props.flux.getStore('tweets'); 14 | this.state = { 15 | tweets: this.tweetStore.getTweets() 16 | }; 17 | } 18 | 19 | componentDidMount() { 20 | // in a typical app using flummox, the FluxComponent wrapper 21 | // which ships with the library would be used instead of manually 22 | // setting up a store change listener, subscribing to updates 23 | // and removing the listener on unmount. 24 | // 25 | // It is done here for exposition 26 | this.feedUpdateListener = () => { 27 | this.getTweets(); 28 | }; 29 | this.tweetStore.addListener('change', this.feedUpdateListener); 30 | } 31 | 32 | componentWillUnmount() { 33 | this.tweetStore.removeListener('change', this.feedUpdateListener); 34 | } 35 | 36 | getTweets() { 37 | this.setState({tweets: this.tweetStore.getTweets()}); 38 | } 39 | 40 | render() { 41 | return 42 | this.setState({selectedTweet: tweet}) 43 | }/> 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/TweetItem_test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {renderIntoDocument} from 'react-addons-test-utils'; 3 | 4 | import {expect} from 'chai'; 5 | 6 | import setup from './setup'; 7 | 8 | import TweetItem from '../src/TweetItem'; 9 | 10 | // CommonJS syntax is used for importing rewire for compatibility 11 | // with babel-loader. 12 | // 13 | // See https://github.com/jhnns/rewire-webpack/issues/12#issuecomment-95797024 14 | // for an explanation 15 | var rewire = require('rewire'); 16 | 17 | const TEST_TWEET = { 18 | id: 'tweet-1', 19 | user: { 20 | screenName: 'robknight_', 21 | description: 'Robert Knight' 22 | }, 23 | text: 'Hello tweet', 24 | createdAt: new Date(Date.now() - 180.0 * 1000) 25 | }; 26 | 27 | describe('TweetItem', () => { 28 | it('should display item details', () => { 29 | const tweet = TEST_TWEET; 30 | const item = renderIntoDocument( 31 | 32 | ); 33 | 34 | const userIcon = item.refs.userIcon; 35 | const userDescription = item.refs.userDescription; 36 | const userScreenName = item.refs.userScreenName; 37 | const date = item.refs.date; 38 | const text = item.refs.text; 39 | 40 | expect(userDescription.textContent).to.equal(tweet.user.description); 41 | expect(userScreenName.textContent).to.equal('@' + tweet.user.screenName); 42 | expect(date.textContent).to.include('3m'); 43 | expect(text.textContent).to.equal(tweet.text); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/TweetItem.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import React, {Component} from 'react'; 3 | 4 | function relativeDate(input) { 5 | const ageMs = Date.now() - input; 6 | const ageMinutes = Math.floor(ageMs / 1000.0 / 60.0); 7 | const ageHours = Math.floor(ageMinutes / 60.0); 8 | 9 | if (ageMinutes < 1.0) { 10 | return 'seconds ago'; 11 | } else if (ageHours < 1.0) { 12 | return `${ageMinutes}m`; 13 | } else if (ageHours < 24.0) { 14 | return `${ageHours}h`; 15 | } else { 16 | return input.toLocaleDateString(); 17 | } 18 | } 19 | 20 | export default class TweetItem extends Component { 21 | constructor(props) { 22 | super(props); 23 | } 24 | 25 | render() { 26 | const tweet = this.props.tweet; 27 | const dateString = relativeDate(new Date(Date.parse(tweet.createdAt))); 28 | 29 | return
36 |
37 | 38 |
39 |
40 | {tweet.user.description} 41 | @{tweet.user.screenName} 42 | - {dateString} 43 |
{tweet.text}
44 |
45 |
; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/StatusView_test.js: -------------------------------------------------------------------------------- 1 | import {Actions, Flux} from 'flummox'; 2 | import {expect} from 'chai'; 3 | import Q from 'q'; 4 | import * as React from 'react'; 5 | import {Simulate, renderIntoDocument} from 'react-addons-test-utils'; 6 | 7 | import StatusView from '../src/StatusView'; 8 | import * as utils from './utils'; 9 | 10 | class FakeTweetActions extends Actions { 11 | fetchTimeline() { 12 | return Q({}); 13 | } 14 | } 15 | 16 | class FakeFlux extends Flux { 17 | constructor() { 18 | super(); 19 | this.createActions('tweets', FakeTweetActions); 20 | } 21 | } 22 | 23 | describe('StatusView', () => { 24 | it('should trigger refresh on click', () => { 25 | let fetchCount = 0; 26 | let fetchComplete = Q.defer(); 27 | 28 | // setup a fake flux instance which provides the actions 29 | // that the refresh button should trigger but nothing more 30 | const flux = new FakeFlux(); 31 | flux.addListener('dispatch', action => { 32 | const fetchTimelineId = flux.getActionIds('tweets').fetchTimeline; 33 | if (action.actionId === fetchTimelineId) { 34 | if (action.async === 'begin') { 35 | ++fetchCount; 36 | } else if (action.async === 'success') { 37 | fetchComplete.resolve(true); 38 | } else { 39 | fetchComplete.reject(); 40 | } 41 | } 42 | }); 43 | 44 | const statusView = renderIntoDocument( 45 | 46 | ); 47 | const refreshButton = statusView.refs.spinner; 48 | 49 | // check that the status view shows the refresh indicator when 50 | // clicked, and stops the indicator once the refresh completes 51 | expect(fetchCount).to.equal(0); 52 | Simulate.click(refreshButton); 53 | expect(fetchCount).to.equal(1); 54 | expect(statusView.state.refreshing).to.equal(true); 55 | 56 | return fetchComplete.promise.then(() => { 57 | expect(statusView.state.refreshing).to.equal(false); 58 | }); 59 | }); 60 | }); 61 | 62 | -------------------------------------------------------------------------------- /app.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 3 | font-size: 14px; 4 | background-image: url('https://abs.twimg.com/images/themes/theme1/bg.png'); 5 | background-color: #C0DEED; 6 | background-repeat: no-repeat; 7 | } 8 | 9 | .app { 10 | display: flex; 11 | flex-direction: row; 12 | justify-content: center; 13 | } 14 | 15 | .app-center-column { 16 | display: flex; 17 | flex-direction: column; 18 | justify-content: center; 19 | } 20 | 21 | .status-view { 22 | margin-top: 5px; 23 | margin-bottom: 5px; 24 | background-color: white; 25 | border-radius: 3px; 26 | padding: 10px; 27 | } 28 | 29 | .status-view-refresh-icon { 30 | } 31 | 32 | .spin-element { 33 | animation-name: spin; 34 | animation-duration: 1s; 35 | animation-iteration-count: infinite; 36 | animation-timing-function: linear; 37 | } 38 | 39 | .status-view-refresh-icon:hover { 40 | filter: drop-shadow(0px 0px 2px #000); 41 | } 42 | 43 | @keyframes spin { 44 | from: { 45 | transform: rotate(0deg); 46 | } 47 | 48 | to { 49 | transform: rotate(360deg); 50 | } 51 | } 52 | 53 | .tweet-list { 54 | width: 500px; 55 | background-color: white; 56 | border-radius: 5px; 57 | } 58 | 59 | .tweet-item { 60 | display: flex; 61 | flex-direction: row; 62 | cursor: pointer; 63 | padding: 10px; 64 | border-bottom: 1px solid #ddd; 65 | } 66 | 67 | .tweet-item-details { 68 | margin-left: 10px; 69 | } 70 | 71 | .tweet-item:hover { 72 | background-color: #efefef; 73 | } 74 | 75 | .tweet-item-user-icon { 76 | border-radius: 3px; 77 | width: 48px; 78 | height: 48px; 79 | } 80 | 81 | .tweet-item-user-description { 82 | font-weight: bold; 83 | padding-right: 5px; 84 | } 85 | 86 | .tweet-item-user-screen-name { 87 | color: #bbb; 88 | font-size: 12px; 89 | } 90 | 91 | .tweet-item-date { 92 | color: #bbb; 93 | } 94 | 95 | .tweet-item-text { 96 | } 97 | 98 | .tweet-item-selected { 99 | background-color: #eee; 100 | } 101 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | // this is a trivial proxy of the Twitter API for use in testing, 2 | // as the real API has a very low rate limit. 3 | // 4 | // It reads API keys from a 'server-config.js' config file, fetches 5 | // the user's current timeline when a call to /timeline is made and 6 | // then returns a timeline consisting of the oldest tweet. 7 | // 8 | // Each successive call to /timeline returns the timeline with one 9 | // newer entry, until the cache is exhausted at which point it refetches 10 | // from the real API 11 | 12 | import assert from 'assert'; 13 | import cors from 'cors'; 14 | import express from 'express'; 15 | import fs from 'fs'; 16 | import Q from 'q'; 17 | 18 | import Twitter from 'twit'; 19 | 20 | const CACHE_FILE = 'data/tweets.js'; 21 | 22 | function readCache() { 23 | try { 24 | const cachedTweets = JSON.parse(fs.readFileSync(CACHE_FILE).toString()); 25 | console.log(`read ${cachedTweets.length} Tweets from ${CACHE_FILE}`); 26 | return cachedTweets; 27 | } catch (ex) { 28 | console.warn(`Unable to read tweet cache. Starting afresh`); 29 | return []; 30 | } 31 | } 32 | 33 | function writeCache(tweets) { 34 | fs.writeFileSync(CACHE_FILE, JSON.stringify(tweets, null, 2)); 35 | } 36 | 37 | function sendTweets(pendingTweets, sentTweets, res) { 38 | assert(pendingTweets.length > 0); 39 | 40 | let minSentTweets = Math.max(sentTweets.length + 1, 5); 41 | while (sentTweets.length < minSentTweets && pendingTweets.length > 0) { 42 | const nextTweet = pendingTweets.shift(); 43 | sentTweets.unshift(nextTweet); 44 | } 45 | 46 | // send tweets after a delay to simulate latency 47 | // when fetching updates 48 | setTimeout(() => { 49 | res.send(sentTweets); 50 | res.end(); 51 | }, 300); 52 | } 53 | 54 | function fetchTweets() { 55 | var params = { screen_name: 'reactjs' }; 56 | var result = Q.defer(); 57 | client.get('statuses/user_timeline', params, (error, tweets, response) => { 58 | if (error) { 59 | console.error('failed to fetch Tweets from Twitter API:', error.toString()); 60 | result.reject(error); 61 | } else { 62 | console.error(`fetched ${tweets.length} Tweets from Twitter API`); 63 | result.resolve(tweets); 64 | } 65 | }); 66 | return result.promise; 67 | } 68 | 69 | const config = JSON.parse(fs.readFileSync('server-config.js')); 70 | const client = new Twitter(config); 71 | const app = express(); 72 | 73 | let pendingTweets = readCache(); 74 | let sentTweets = []; 75 | 76 | app.use(cors()); 77 | app.get('/timeline', (req, res) => { 78 | if (pendingTweets.length > 0) { 79 | sendTweets(pendingTweets, sentTweets, res); 80 | } else { 81 | fetchTweets().then(tweets => { 82 | var seenTweetIds = pendingTweets.concat(sentTweets).map(tweet => tweet.id_str); 83 | var newTweets = tweets.filter(tweet => { 84 | return seenTweetIds.indexOf(tweet.id_str) === -1; 85 | }).reverse(); 86 | 87 | newTweets.forEach(tweet => { 88 | pendingTweets.push(tweet); 89 | }); 90 | console.log(`fetched ${tweets.length} tweets, ${newTweets.length} were new.`); 91 | if (pendingTweets.length > 0) { 92 | sendTweets(pendingTweets, sentTweets, res); 93 | writeCache(sentTweets.concat(pendingTweets)); 94 | } 95 | }).catch(err => { 96 | console.error(`fetching tweets failed: ${err}`); 97 | res.status(500); 98 | res.send(`fetching timeline failed: ${error.toString()}`); 99 | }); 100 | } 101 | }); 102 | 103 | let server = app.listen(3000, () => { 104 | console.log('Twitter proxy listening'); 105 | }); 106 | 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OBSOLETE ADVICE WARNING 2 | 3 | _(Update on 2020-04-22)_: This talk was given a long time ago. Tools and best practices have evolved since then. For more up-to-date information, have a look at: 4 | 5 | - The [Testing Overview](https://reactjs.org/docs/testing.html) section in the official React docs 6 | - A [blog post](https://robertknight.me.uk/posts/shallow-rendering-revisited/) I wrote about unit-testing React or Preact components 7 | 8 | React Testability 9 | ================= 10 | 11 | This is a simple Twitter client project which demonstrates 12 | various tools and techniques for writing tests for React 13 | components, written as part of a talk ([slides](https://robertknight.github.io/react-testing/docs/react-london-talk.html), [video](https://www.youtube.com/watch?v=_RKrgouBvLM)) at the 14 | [London React](http://www.meetup.com/London-React-User-Group/) meetup. 15 | 16 | It shows the essentials for writing tests for a React application that can be run in Node 17 | and the browser, isolating modules under test using shallow rendering and rewire() and 18 | using Flummox for testable use of the Flux architecture. 19 | 20 | ## Requirements 21 | * NodeJS 4.x or later is required in order to use the current version of the jsdom library. 22 | 23 | ## Building and running 24 | 25 | ``` 26 | npm install . 27 | make 28 | 29 | # run the app 30 | # (you can also use 'webpack-dev-server') 31 | python -m SimpleHTTPServer 8000 32 | open http://localhost:8000 33 | 34 | # run tests in the browser 35 | open tests.html 36 | 37 | # run tests on the command-line 38 | make test 39 | ``` 40 | 41 | ## Libraries and Tools Used 42 | * React (obviously). v0.13 is used for [shallow-rendering support](http://facebook.github.io/react/docs/test-utils.html#shallow-rendering) which enables 43 | testing of rendering of a single level of the component tree in isolation. 44 | _Update (28/02/16): Shallow rendering support has since improved in React v0.14.7 to 45 | include some support for stateful components_ 46 | * [Mocha](http://mochajs.org/) and chai are the basic testing frameworks used, these were chosen as they 47 | are popular, polished and well documented. 48 | * [Webpack](http://webpack.github.io/) is used to package the tests for running/debugging in the 49 | browser. 50 | * [jsdom](https://github.com/tmpvar/jsdom) is used for testing of rendering DOM components outside of the browser. 51 | * The [Flummox](https://github.com/acdlite/flummox) implementation of the Flux architecture 52 | is used for fetching data and updating views in response. 53 | [Flummox](https://github.com/acdlite/flummox) avoidance of singletons makes it 54 | easy to inject fake/mock actions in unit and integration tests. _Update (28/02/16): Flummox still works perfectly well, 55 | but [Redux](https://github.com/reactjs/redux) has since become the de-facto choice for state management in the 56 | React community and it has an even better testability story._ 57 | * [Rewire](https://github.com/jhnns/rewire) is used to show one approach to mocking out 58 | React components in tests. _Update (28/02/16): I would probably recommend looking at 59 | [inject-loader](https://www.npmjs.com/package/inject-loader) for Webpack 60 | or [Proxyquire](https://github.com/thlorenz/proxyquire) for Browserify instead as these provide 61 | a cleaner way to mock JS modules in my view_. 62 | * [isomorphic-fetch](https://github.com/matthew-andrews/isomorphic-fetch) provides a uniform API for fetching data in the browser and Node. 63 | 64 | ## Recommended Reading & Videos 65 | * [Awesome React - Testing React Tutorials](https://github.com/enaqx/awesome-react#testing-react-tutorials) - Awesome React is a great collection 66 | of links for all aspects of building React apps. The section on testing references a number of useful tutorials. 67 | * Separating visual and data-fetching components 68 | * [React.js Conf 2015 - Making your app fast with high-performance components](https://www.youtube.com/watch?v=KYzlpRvWZ6c). This talk introduces a policy of separating pure visual components from containers which contain data fetching logic. 69 | * Beyond unit testing 70 | * [Dave McCabe - Property Testing for React](https://vimeo.com/122070164). This is a great talk on how to do property testing, where tests are fed a stream of random but repeatable and plausible inputs, and the testing framework checks that various invariants that you specify hold for all inputs. 71 | -------------------------------------------------------------------------------- /tests/TweetList_test.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {findDOMNode, render} from 'react-dom'; 3 | import {createRenderer, scryRenderedComponentsWithType, renderIntoDocument} from 'react-addons-test-utils'; 4 | import {expect} from 'chai'; 5 | 6 | import * as utils from './utils'; 7 | 8 | import TweetItem from '../src/TweetItem'; 9 | 10 | var rewire = require('rewire'); 11 | 12 | const TEST_TWEETS = [{ 13 | id: 'tweet-1', 14 | user: { 15 | screenName: 'robknight_', 16 | description: 'Robert Knight' 17 | }, 18 | text: 'Hello tweet', 19 | createdAt: 'Mon Nov 29 21:18:15 +0000 2010' 20 | },{ 21 | id: 'tweet-2', 22 | user: { 23 | screenName: 'reactjs', 24 | description: 'React News' 25 | }, 26 | text: 'Another test tweet', 27 | createdAt: 'Mon Nov 29 21:18:15 +0000 2010' 28 | }]; 29 | 30 | class StubTweetItem extends Component { 31 | render() { 32 | return
stub tweet
; 33 | } 34 | } 35 | 36 | describe('TweetList', () => { 37 | var tweetListLib; 38 | var TweetList; 39 | 40 | beforeEach(() => { 41 | tweetListLib = rewire('../src/TweetList'); 42 | TweetList = tweetListLib.default; 43 | }); 44 | 45 | it('should display tweets (DOM class matching)', () => { 46 | const list = renderIntoDocument(); 47 | const items = findDOMNode(list).querySelectorAll('.tweet-item'); 48 | expect(items.length).to.equal(TEST_TWEETS.length); 49 | }); 50 | 51 | it('should display tweets (component type matching)', () => { 52 | const list = renderIntoDocument(); 53 | const items = scryRenderedComponentsWithType(list, TweetItem); 54 | expect(items.length).to.equal(TEST_TWEETS.length); 55 | }); 56 | 57 | it('should display tweets (stub component type matching)', () => { 58 | tweetListLib.__set__('TweetItem', StubTweetItem); 59 | 60 | utils.withContainer(element => { 61 | const list = render( 62 | 63 | , element); 64 | const items = scryRenderedComponentsWithType(list, StubTweetItem); 65 | 66 | // check that the correct number of tweets 67 | // were rendered 68 | expect(items.length).to.equal(TEST_TWEETS.length); 69 | 70 | // check that tweet items were rendered with the correct 71 | // props 72 | expect(items[0].props.subject).to.equal(TEST_TWEETS[0].subject); 73 | expect(items[0].props.from).to.equal(TEST_TWEETS[0].from); 74 | expect(items[0].props.snippet).to.equal(TEST_TWEETS[0].snippet); 75 | }); 76 | }); 77 | 78 | it('should display tweets (shallow rendering)', () => { 79 | const shallowRenderer = createRenderer(); 80 | const renderList = () => { 81 | shallowRenderer.render(); 82 | const list = shallowRenderer.getRenderOutput(); 83 | return list.props.children.filter(component => component.type == TweetItem); 84 | } 85 | const items = renderList(); 86 | 87 | expect(items.length).to.equal(TEST_TWEETS.length); 88 | }); 89 | 90 | it('should select tweet on click (stub component)', () => { 91 | tweetListLib.__set__('TweetItem', StubTweetItem); 92 | 93 | utils.withContainer(element => { 94 | const list = render(, element); 95 | const items = scryRenderedComponentsWithType(list, StubTweetItem); 96 | expect(items[0].props.isSelected).to.equal(false); 97 | 98 | items[0].props.onClick(); 99 | expect(items[0].props.isSelected).to.equal(true); 100 | }); 101 | }); 102 | 103 | it('should select tweet on click (shallow rendering)', () => { 104 | const shallowRenderer = createRenderer(); 105 | shallowRenderer.render(); 106 | 107 | const getRenderedItems = () => { 108 | const list = shallowRenderer.getRenderOutput(); 109 | return list.props.children.filter(component => component.type == TweetItem); 110 | } 111 | let items = getRenderedItems(); 112 | 113 | expect(items.length).to.equal(TEST_TWEETS.length); 114 | expect(items[0].props.isSelected).to.equal(false); 115 | items[0].props.onClick(); 116 | 117 | items = getRenderedItems(); 118 | expect(items[0].props.isSelected).to.equal(true); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /docs/images/isolation-component-tree.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | props 157 | 158 | 159 | 160 | 161 | props 162 | 163 | 164 | 165 | 166 | 167 | 168 | -------------------------------------------------------------------------------- /docs/images/isolation-require-graph.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | require() 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | require() 165 | 166 | 167 | 168 | 169 | require() 170 | 171 | 172 | 173 | 174 | require() 175 | 176 | 177 | 178 | 179 | 180 | 181 | -------------------------------------------------------------------------------- /docs/react-london-talk.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | React Testing Tools & Tricks 5 | 6 | 42 | 43 | 44 | 598 | 600 | 603 | 604 | 605 | -------------------------------------------------------------------------------- /docs/images/component-inputs-outputs.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | Props 113 | 114 | 115 | 116 | 117 | Callbacks 118 | 119 | 120 | 121 | 122 | Component Trees 123 | 124 | 125 | 126 | 127 | Callbacks & Actions 128 | 129 | 130 | 131 | 132 | 133 | 134 | -------------------------------------------------------------------------------- /docs/images/container-components-mvc.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | Controller 99 | 100 | 101 | 102 | 103 | View 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | Model 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | Actions 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | Dispatcher 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | Callbacks 170 | 171 | 172 | 173 | 174 | Props 175 | 176 | 177 | 178 | 179 | 180 | 181 | -------------------------------------------------------------------------------- /docs/images/shallow-rendering.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | Props 192 | 193 | 194 | 195 | 196 | Props 197 | 198 | 199 | 200 | 201 | 202 | 203 | -------------------------------------------------------------------------------- /docs/images/container-components.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | WidgetContainer 101 | 102 | 103 | 104 | 105 | Widget 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | Data Source 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | Actions 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | Dispatcher 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | Callbacks 172 | 173 | 174 | 175 | 176 | Props 177 | 178 | 179 | 180 | 181 | 182 | 183 | --------------------------------------------------------------------------------