├── .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 | 22 | ))} 23 | 24 | ))} 25 | 26 |
onCellClicked(rIndex, cIndex)} 19 | > 20 | {cell} 21 |
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