├── .nvmrc
├── .eslintignore
├── test
├── helpers
│ ├── setupFramework.js
│ ├── teardown.js
│ ├── environment.js
│ └── setup.js
├── App.driver.js
└── App.e2e.js
├── src
├── index.css
├── gameService.unit.js
├── gameService.js
├── index.js
├── App.css
├── Registration.js
├── App.driver.js
├── Game.js
├── App.spec.js
├── App.js
├── logo.svg
└── registerServiceWorker.js
├── public
├── favicon.ico
├── tic-tac-toe.png
├── manifest.json
└── index.html
├── .travis.yml
├── config
├── jest
│ ├── fileTransform.js
│ └── cssTransform.js
├── polyfills.js
├── paths.js
├── env.js
├── webpackDevServer.config.js
├── webpack.config.dev.js
└── webpack.config.prod.js
├── .gitignore
├── scripts
├── test.js
├── start.js
└── build.js
├── TESTING.md
├── .eslintrc
├── README.md
└── package.json
/.nvmrc:
--------------------------------------------------------------------------------
1 | 8.9.1
2 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | /public
2 | /scripts
3 | /config
--------------------------------------------------------------------------------
/test/helpers/setupFramework.js:
--------------------------------------------------------------------------------
1 | jest.setTimeout(10000);
2 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: sans-serif;
5 | }
6 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yanivefraim/react-tdd-workshop/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/tic-tac-toe.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yanivefraim/react-tdd-workshop/HEAD/public/tic-tac-toe.png
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: required
2 | dist: trusty
3 | language: node_js
4 |
5 | script: >
6 | npm run test
7 |
8 | notifications:
9 | email:
10 | recipients:
11 | - yaniv.efraim@gmail.com
12 |
--------------------------------------------------------------------------------
/src/gameService.unit.js:
--------------------------------------------------------------------------------
1 | const { gameStatus } = require('./gameService');
2 |
3 | test('X should win', () => {
4 | const board = [['X', 'X', 'X'], ['', '', ''], ['', '', '']];
5 | expect(gameStatus(board)).toBe('X');
6 | });
7 |
--------------------------------------------------------------------------------
/src/gameService.js:
--------------------------------------------------------------------------------
1 | export const gameStatus = board => {
2 | const isWin = symbol => board[0].every(cell => cell === symbol);
3 | if (isWin('X')) {
4 | return 'X';
5 | }
6 | if (isWin('O')) {
7 | return 'O';
8 | }
9 | };
10 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './App';
5 | import registerServiceWorker from './registerServiceWorker';
6 |
7 | ReactDOM.render(, document.getElementById('root'));
8 | registerServiceWorker();
9 |
--------------------------------------------------------------------------------
/config/jest/fileTransform.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const path = require('path');
4 |
5 | // This is a custom Jest transformer turning file imports into filenames.
6 | // http://facebook.github.io/jest/docs/en/webpack.html
7 |
8 | module.exports = {
9 | process(src, filename) {
10 | return `module.exports = ${JSON.stringify(path.basename(filename))};`;
11 | },
12 | };
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
--------------------------------------------------------------------------------
/config/jest/cssTransform.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | // This is a custom Jest transformer turning style imports into empty objects.
4 | // http://facebook.github.io/jest/docs/en/webpack.html
5 |
6 | module.exports = {
7 | process() {
8 | return 'module.exports = {};';
9 | },
10 | getCacheKey() {
11 | // The output is always the same.
12 | return 'cssTransform';
13 | },
14 | };
15 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/test/helpers/teardown.js:
--------------------------------------------------------------------------------
1 | const os = require('os');
2 | const path = require('path');
3 | const fs = require('fs-extra');
4 |
5 | const watchMode = process.argv.includes('--watch');
6 |
7 | const DIR = path.join(os.tmpdir(), 'jest_puppeteer_global_setup');
8 |
9 | module.exports = async () => {
10 | if (!watchMode) {
11 | global.SERVER.close();
12 | }
13 |
14 | await fs.remove(DIR);
15 | await global.BROWSER.close();
16 | };
17 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | text-align: center;
3 | }
4 |
5 | .App-logo {
6 | animation: App-logo-spin infinite 20s linear;
7 | height: 80px;
8 | }
9 |
10 | .App-header {
11 | background-color: #222;
12 | height: 150px;
13 | padding: 20px;
14 | color: white;
15 | }
16 |
17 | .App-title {
18 | font-size: 1.5em;
19 | }
20 |
21 | .App-intro {
22 | font-size: large;
23 | }
24 |
25 | @keyframes App-logo-spin {
26 | from { transform: rotate(0deg); }
27 | to { transform: rotate(360deg); }
28 | }
29 |
30 | td {
31 | border: 1px solid black;
32 | height: 50px;
33 | width: 50px;
34 | }
--------------------------------------------------------------------------------
/test/helpers/environment.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const os = require('os');
3 | const path = require('path');
4 | const puppeteer = require('puppeteer');
5 | const NodeEnvironment = require('jest-environment-node');
6 |
7 | const DIR = path.join(os.tmpdir(), 'jest_puppeteer_global_setup');
8 |
9 | module.exports = class PuppeteerEnvironment extends NodeEnvironment {
10 | async setup() {
11 | await super.setup();
12 |
13 | const browserWSEndpoint = fs.readFileSync(path.join(DIR, 'wsEndpoint'), 'utf8');
14 |
15 | if (!browserWSEndpoint) {
16 | throw new Error('wsEndpoint not found');
17 | }
18 |
19 | this.global.BROWSER = await puppeteer.connect({
20 | browserWSEndpoint,
21 | });
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/scripts/test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | // Do this as the first thing so that any code reading it knows the right env.
4 | process.env.BABEL_ENV = 'test';
5 | process.env.NODE_ENV = 'test';
6 | process.env.PUBLIC_URL = '';
7 |
8 | // Makes the script crash on unhandled rejections instead of silently
9 | // ignoring them. In the future, promise rejections that are not handled will
10 | // terminate the Node.js process with a non-zero exit code.
11 | process.on('unhandledRejection', err => {
12 | throw err;
13 | });
14 |
15 | // Ensure environment variables are read.
16 | require('../config/env');
17 |
18 | const jest = require('jest');
19 | const argv = process.argv.slice(2);
20 |
21 | // Watch unless on CI or in coverage mode
22 | if (!process.env.CI && argv.indexOf('--coverage') < 0) {
23 | argv.push('--watch');
24 | }
25 |
26 |
27 | jest.run(argv);
28 |
--------------------------------------------------------------------------------
/TESTING.md:
--------------------------------------------------------------------------------
1 | # Testing terms / technologies
2 |
3 | ### Browser tests / E2E
4 |
5 | Technologies:
6 |
7 | - Selenium based libraries (Protractor, Nightwatch etc.).
8 |
9 | - Chrome devtools based (Puppeteer).
10 |
11 | - Karma*
12 |
13 | Tests are running in node environment, while production code is running on a real / headless browser.
14 |
15 | Test use cases:
16 |
17 | - browser dependent stuff (scrolling, measurements etc.)
18 |
19 | - Navigation
20 |
21 | - API calls
22 |
23 | ### Component tests
24 |
25 | Technologies:
26 |
27 | Mocha / Jest
28 |
29 | Running tests in node environment, using JSDOM to mimic the browser
30 |
31 | Test use cases:
32 |
33 | React components
34 |
35 | ### Unit tests
36 |
37 | Running tests on small units, using node environment
38 |
39 | Test use cases:
40 |
41 | Test specifig app logic
42 |
43 | ### IT tests
44 |
45 | Running tests on API endpoints, using node
--------------------------------------------------------------------------------
/src/Registration.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | export default class Registration extends Component {
5 | static propTypes = {
6 | onNewGame: PropTypes.func.isRequired,
7 | };
8 | constructor() {
9 | super();
10 | this.state = {
11 | p1Name: '',
12 | p2Name: '',
13 | };
14 | }
15 | render() {
16 | return (
17 |
18 | this.setState({ p1Name: el.target.value })} data-hook="p1-input" />
19 | this.setState({ p2Name: el.target.value })} data-hook="p2-input" />
20 |
28 |
29 | );
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/test/App.driver.js:
--------------------------------------------------------------------------------
1 | const appDriver = page => ({
2 | navigate: () => page.goto('http://localhost:3000'),
3 | newGame: async (player1, player2) => {
4 | await page.type('[data-hook="p1-input"]', player1);
5 | await page.type('[data-hook="p2-input"]', player2);
6 | await page.click('[data-hook="new-game"]');
7 | },
8 | getPlayer1Title: () => page.$eval('[data-hook="p1-name"]', el => el.innerText),
9 | getPlayer2Title: () => page.$eval('[data-hook="p2-name"]', el => el.innerText),
10 | clickACellAt: index => page.$$eval('[data-hook="cell"]', (cells, i) => cells[i].click(), index),
11 | getACellValueAt: index =>
12 | page.$$eval('[data-hook="cell"]', (cells, i) => cells[i].innerText, index),
13 | getWinnerMessage: () => page.$eval('[data-hook="winner-message"]', el => el.innerText),
14 | hasWinner: async () => !!await page.$('[data-hook="winner-message"]'),
15 | });
16 |
17 | module.exports = appDriver;
18 |
--------------------------------------------------------------------------------
/test/helpers/setup.js:
--------------------------------------------------------------------------------
1 | const os = require('os');
2 | const path = require('path');
3 | const fs = require('fs-extra');
4 | const express = require('express');
5 | const puppeteer = require('puppeteer');
6 |
7 | const watchMode = process.argv.includes('--watch');
8 |
9 | const DIR = path.join(os.tmpdir(), 'jest_puppeteer_global_setup');
10 |
11 | module.exports = async () => {
12 | if (!watchMode) {
13 | await new Promise(resolve => {
14 | const app = express();
15 |
16 | app.use(express.static(path.join(__dirname, '..', '..', 'build')));
17 | app.use(express.static(path.join(__dirname, '..', '..', 'public')));
18 |
19 | global.SERVER = app.listen(3000, resolve);
20 | });
21 | }
22 |
23 | const browser = (global.BROWSER = await puppeteer.launch({
24 | headless: true,
25 | args: ['--no-sandbox'],
26 | }));
27 |
28 | await fs.outputFile(path.join(DIR, 'wsEndpoint'), browser.wsEndpoint());
29 | };
30 |
--------------------------------------------------------------------------------
/config/polyfills.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | if (typeof Promise === 'undefined') {
4 | // Rejection tracking prevents a common issue where React gets into an
5 | // inconsistent state due to an error, but it gets swallowed by a Promise,
6 | // and the user has no idea what causes React's erratic future behavior.
7 | require('promise/lib/rejection-tracking').enable();
8 | window.Promise = require('promise/lib/es6-extensions.js');
9 | }
10 |
11 | // fetch() polyfill for making API calls.
12 | require('whatwg-fetch');
13 |
14 | // Object.assign() is commonly used with React.
15 | // It will use the native implementation if it's present and isn't buggy.
16 | Object.assign = require('object-assign');
17 |
18 | // In tests, polyfill requestAnimationFrame since jsdom doesn't provide it yet.
19 | // We don't polyfill it in the browser--this is user's responsibility.
20 | if (process.env.NODE_ENV === 'test') {
21 | require('raf').polyfill(global);
22 | }
23 |
--------------------------------------------------------------------------------
/src/App.driver.js:
--------------------------------------------------------------------------------
1 | import { mount } from 'enzyme';
2 |
3 | const appDriver = () => {
4 | let wrapper;
5 | return {
6 | render: node => {
7 | wrapper = mount(node, { attachTo: document.createElement('div') });
8 | return wrapper;
9 | },
10 | newGame: (p1Name, p2Name) => {
11 | wrapper.find('[data-hook="p1-input"]').simulate('change', { target: { value: p1Name } });
12 | wrapper.find('[data-hook="p2-input"]').simulate('change', { target: { value: p2Name } });
13 | wrapper.find('[data-hook="new-game"]').simulate('click');
14 | },
15 | clickACellAt: index =>
16 | wrapper
17 | .find('[data-hook="cell"]')
18 | .at(index)
19 | .simulate('click'),
20 | getACellAt: index =>
21 | wrapper
22 | .find('[data-hook="cell"]')
23 | .at(index)
24 | .text(),
25 | getWinnerMessage: () => wrapper.find('[data-hook="winner-message"]').text(),
26 | };
27 | };
28 |
29 | export default appDriver;
30 |
--------------------------------------------------------------------------------
/src/Game.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | const Game = ({ p1Name, p2Name, board, onCellClicked }) => {
5 | return (
6 |
7 |
{p1Name}
8 |
{p2Name}
9 |
10 |
11 | {board.map((row, rIndex) => (
12 |
13 | {row.map((cell, cIndex) => (
14 | | onCellClicked(rIndex, cIndex)}
19 | >
20 | {cell}
21 | |
22 | ))}
23 |
24 | ))}
25 |
26 |
27 |
28 | );
29 | };
30 |
31 | Game.propTypes = {
32 | p1Name: PropTypes.string.isRequired,
33 | p2Name: PropTypes.string.isRequired,
34 | board: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)).isRequired,
35 | onCellClicked: PropTypes.func.isRequired,
36 | };
37 | export default Game;
38 |
--------------------------------------------------------------------------------
/src/App.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { configure } from 'enzyme';
4 | import Adapter from 'enzyme-adapter-react-16';
5 | import App from './App';
6 | import appDriver from './App.driver';
7 |
8 | configure({ adapter: new Adapter() });
9 | let driver;
10 | beforeEach(() => (driver = appDriver()));
11 |
12 | test('renders without crashing', () => {
13 | const div = document.createElement('div');
14 | ReactDOM.render(, div);
15 | ReactDOM.unmountComponentAtNode(div);
16 | });
17 |
18 | test('should show "O" after second player clicks', () => {
19 | const p1Name = 'Yaniv';
20 | const p2Name = 'Computer';
21 | driver.render();
22 | driver.newGame(p1Name, p2Name);
23 | driver.clickACellAt(0);
24 | driver.clickACellAt(1);
25 | expect(driver.getACellAt(1)).toBe('O');
26 | });
27 |
28 | test('"O" should win the game', () => {
29 | const p1Name = 'Yaniv';
30 | const p2Name = 'Computer';
31 | driver.render();
32 | driver.newGame(p1Name, p2Name);
33 | driver.clickACellAt(4);
34 | driver.clickACellAt(0);
35 | driver.clickACellAt(5);
36 | driver.clickACellAt(1);
37 | driver.clickACellAt(7);
38 | driver.clickACellAt(2);
39 | expect(driver.getWinnerMessage()).toBe(`${p2Name} won!`);
40 | });
41 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["airbnb", "prettier"],
3 | "env": {
4 | "browser": true,
5 | "jest/globals": true
6 | },
7 | "parser": "babel-eslint",
8 | "plugins": [
9 | "jest",
10 | "prettier"
11 | ],
12 | "globals": {
13 | "monaco": true
14 | },
15 | "rules": {
16 | "prettier/prettier": [
17 | "error",
18 | {
19 | "bracketSpacing": true,
20 | "printWidth": 100,
21 | "singleQuote": true,
22 | "trailingComma": "all"
23 | }
24 | ],
25 | "react/no-array-index-key": "off",
26 | "arrow-body-style": "off",
27 | "class-methods-use-this": "off",
28 | "consistent-return": "off",
29 | "function-paren-newline": "off",
30 | "global-require": "off",
31 | "import/no-dynamic-require": "off",
32 | "import/prefer-default-export": "off",
33 | "jsx-a11y/no-static-element-interactions": "off",
34 | "jsx-a11y/click-events-have-key-events": "off",
35 | "no-case-declarations": "off",
36 | "no-debugger": "off",
37 | "no-console": "off",
38 | "no-mixed-operators": "off",
39 | "no-multi-assign": "off",
40 | "no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 1 }],
41 | "no-param-reassign": "off",
42 | "no-return-assign": "off",
43 | "no-plusplus": "off",
44 | "no-use-before-define": "off",
45 | "no-useless-concat": "off",
46 | "react/jsx-filename-extension": "off",
47 | "react/prop-types": "off"
48 | }
49 | }
--------------------------------------------------------------------------------
/test/App.e2e.js:
--------------------------------------------------------------------------------
1 | const appDriver = require('./App.driver');
2 |
3 | describe('Tic Tac Toe', () => {
4 | let driver;
5 | let page;
6 |
7 | beforeEach(async () => {
8 | page = await global.BROWSER.newPage();
9 | driver = appDriver(page);
10 | await driver.navigate();
11 | });
12 |
13 | test('should start a new game', async () => {
14 | const player1 = 'Yaniv';
15 | const player2 = 'Computer';
16 | await driver.newGame(player1, player2);
17 | const p1Name = await driver.getPlayer1Title();
18 | const p2Name = await driver.getPlayer2Title();
19 | expect(p1Name).toBe(player1);
20 | expect(p2Name).toBe(player2);
21 | });
22 |
23 | test('should show "X" after first player clicks', async () => {
24 | const player1 = 'Yaniv';
25 | const player2 = 'Computer';
26 | await driver.newGame(player1, player2);
27 | expect(await driver.getACellValueAt(0)).toBe('');
28 | await driver.clickACellAt(0);
29 | expect(await driver.getACellValueAt(0)).toBe('X');
30 | });
31 |
32 | test('first player should win the game', async () => {
33 | const player1 = 'Yaniv';
34 | const player2 = 'Computer';
35 | await driver.newGame(player1, player2);
36 | await driver.clickACellAt(0);
37 | await driver.clickACellAt(3);
38 | expect(await driver.hasWinner()).toBe(false);
39 | await driver.clickACellAt(1);
40 | await driver.clickACellAt(4);
41 | await driver.clickACellAt(2);
42 | expect(await driver.getWinnerMessage()).toBe(`${player1} won!`);
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Registration from './Registration';
3 | import Game from './Game';
4 | import { gameStatus } from './gameService';
5 | import './App.css';
6 |
7 | class App extends React.Component {
8 | constructor() {
9 | super();
10 | this.state = {
11 | p1Name: '',
12 | p2Name: '',
13 | board: [['', '', ''], ['', '', ''], ['', '', '']],
14 | winner: '',
15 | currentPlayer: 'X',
16 | };
17 | }
18 | onNewGame = ({ p1Name, p2Name }) => {
19 | this.setState({ p1Name, p2Name });
20 | };
21 |
22 | handleCellClick = (rIndex, cIndex) => {
23 | const board = this.state.board.map(row => [...row]);
24 | board[rIndex][cIndex] = this.state.currentPlayer;
25 | if (gameStatus(board) === this.state.currentPlayer) {
26 | this.setState({ winner: this.state.currentPlayer });
27 | }
28 | const nextPlayer = this.state.currentPlayer === 'X' ? 'O' : 'X';
29 | this.setState({ board, currentPlayer: nextPlayer });
30 | };
31 | render() {
32 | return (
33 |
34 |
35 |
41 | {this.state.winner && (
42 |
43 | {`${this.state.winner === 'X' ? this.state.p1Name : this.state.p2Name} won!`}
44 |
45 | )}
46 |
47 | );
48 | }
49 | }
50 | export default App;
51 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 |
22 | React App
23 |
24 |
25 |
28 |
29 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/config/paths.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const path = require('path');
4 | const fs = require('fs');
5 | const url = require('url');
6 |
7 | // Make sure any symlinks in the project folder are resolved:
8 | // https://github.com/facebookincubator/create-react-app/issues/637
9 | const appDirectory = fs.realpathSync(process.cwd());
10 | const resolveApp = relativePath => path.resolve(appDirectory, relativePath);
11 |
12 | const envPublicUrl = process.env.PUBLIC_URL;
13 |
14 | function ensureSlash(path, needsSlash) {
15 | const hasSlash = path.endsWith('/');
16 | if (hasSlash && !needsSlash) {
17 | return path.substr(path, path.length - 1);
18 | } else if (!hasSlash && needsSlash) {
19 | return `${path}/`;
20 | } else {
21 | return path;
22 | }
23 | }
24 |
25 | const getPublicUrl = appPackageJson =>
26 | envPublicUrl || require(appPackageJson).homepage;
27 |
28 | // We use `PUBLIC_URL` environment variable or "homepage" field to infer
29 | // "public path" at which the app is served.
30 | // Webpack needs to know it to put the right