├── .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 |
--------------------------------------------------------------------------------