├── .gitignore ├── README.md ├── dist └── index.html ├── package-lock.json ├── package.json ├── src ├── components │ └── List │ │ ├── List.js │ │ ├── List.styl │ │ ├── List.test.js │ │ ├── NewToDo │ │ ├── NewToDo.js │ │ ├── NewToDo.styl │ │ └── NewToDo.test.js │ │ └── ToDo │ │ ├── ToDo.js │ │ ├── ToDo.styl │ │ ├── ToDo.test.js │ │ └── __snapshots__ │ │ └── ToDo.test.js.snap └── index.js ├── test-config ├── file-mock.js ├── setup.js └── style-mock.js └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | *.7z 2 | *.key 3 | *.ko 4 | *.log 5 | *.o 6 | *.out 7 | *.pid 8 | *.so 9 | *.swp 10 | *.tar 11 | *~ 12 | db 13 | node_modules 14 | typings 15 | tags 16 | x/ 17 | coverage 18 | .nyc_output 19 | bundle.js 20 | bundle.js.map 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## react-unit-test-practice 2 | 3 | In this repo lives a simple To Do list that is lacking in testing. The `ToDo` component has a full set of commented example tests to serve as a reference. 4 | 5 | Your goal is to write tests for the other two components and reach 100% code coverage. Remember that 100% coverage doesn't mean perfect tests! Try to find edge cases and errors that may creep up. Not every bit of what you will need is covered in the example tests, you'll need to hit the docs: 6 | 7 | * [Enzyme Docs](http://airbnb.io/enzyme/docs/api/) 8 | * [Jest Docs](https://facebook.github.io/jest/) 9 | 10 | NPM Commands(can all be found in the `package.json`): 11 | * `npm run test` - Runs the test suite a single time 12 | * `npm run test:watch` - Continuously runs the test suite on file change 13 | * `npm run test:coverage` - Prints out a coverage report to console 14 | * `npm run view-coverage` - Launches an HTML view of code coverage, with detailed information on how well pieces of code are covered 15 | * `npm run dev` - Runs the project on port 8080 via webpack dev server with webpack-dashboard 16 | * `npm run prettier` - Why code if it can't be beautiful? 17 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | React Testing 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-unit-test-practice", 3 | "version": "1.0.0", 4 | "description": "A repository to practice your react unit testing skills", 5 | "repository": "https://github.com/r-walsh/react-unit-test-practice", 6 | "keywords": [ 7 | "react", 8 | "unit-testing", 9 | "enzyme", 10 | "jest", 11 | "utahjs", 12 | "midwestjs" 13 | ], 14 | "author": "Ryan Walsh (https://twitter.com/_rtwalsh)", 15 | "license": "MIT", 16 | "scripts": { 17 | "dev": "webpack-dashboard -- webpack-dev-server --hot --inline --progress", 18 | "prettier": "prettier --single-quote --trailing-comma all --write \"./src/**/*.js\" \"./webpack.config.js\" \"./test-config/*.js\"", 19 | "test": "cross-env NODE_ENV=test jest", 20 | "test:watch": "npm run test -- --watch", 21 | "test:coverage": "npm run test -- --coverage", 22 | "view-coverage": "live-server ./coverage" 23 | }, 24 | "babel": { 25 | "presets": [ 26 | [ 27 | "env", 28 | { 29 | "targets": { 30 | "browsers": [ 31 | "> 10%", 32 | "last 2 versions" 33 | ] 34 | }, 35 | "useBuiltIns": true 36 | } 37 | ], 38 | "stage-2", 39 | "react" 40 | ], 41 | "plugins": [ 42 | "transform-runtime" 43 | ], 44 | "env": { 45 | "development": { 46 | "sourceMaps": "inline" 47 | } 48 | } 49 | }, 50 | "jest": { 51 | "collectCoverage": true, 52 | "coverageDirectory": "/coverage", 53 | "coverageReporters": [ 54 | "text", 55 | "html" 56 | ], 57 | "moduleNameMapper": { 58 | "^.+\\.(jpg|png|svg)$": "/test-config/file-mock.js", 59 | "^.+\\.styl": "/test-config/style-mock.js" 60 | }, 61 | "setupFiles": [ 62 | "/test-config/setup.js" 63 | ] 64 | }, 65 | "dependencies": { 66 | "prop-types": "^15.5.10", 67 | "react": "^15.6.1", 68 | "react-dom": "^15.6.1" 69 | }, 70 | "devDependencies": { 71 | "babel-core": "^6.25.0", 72 | "babel-loader": "^7.1.1", 73 | "babel-plugin-transform-runtime": "^6.23.0", 74 | "babel-preset-env": "^1.6.0", 75 | "babel-preset-react": "^6.24.1", 76 | "babel-preset-stage-2": "^6.24.1", 77 | "cross-env": "^5.0.5", 78 | "css-loader": "^0.28.4", 79 | "enzyme": "^2.9.1", 80 | "enzyme-to-json": "^1.5.1", 81 | "jest": "^20.0.4", 82 | "jsdom": "^11.1.0", 83 | "live-server": "^1.2.0", 84 | "prettier": "^1.5.3", 85 | "react-addons-test-utils": "^15.6.0", 86 | "react-test-renderer": "^15.6.1", 87 | "style-loader": "^0.18.2", 88 | "stylus": "^0.54.5", 89 | "stylus-loader": "^3.0.1", 90 | "webpack": "^3.5.2", 91 | "webpack-dashboard": "^0.4.0", 92 | "webpack-dev-server": "^2.7.1" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/components/List/List.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import './List.styl'; 4 | 5 | import NewToDo from './NewToDo/NewToDo'; 6 | import ToDo from './ToDo/ToDo'; 7 | 8 | export default class List extends Component { 9 | state = { toDos: [] }; 10 | 11 | submitToDo = text => 12 | this.setState(() => ({ 13 | toDos: [{ complete: false, text }, ...this.state.toDos], 14 | })); 15 | 16 | toggleCompletion = ({ text }) => 17 | this.setState(({ toDos }) => ({ 18 | toDos: toDos.map( 19 | toDo => 20 | toDo.text === text ? { complete: !toDo.complete, text } : toDo, 21 | ), 22 | })); 23 | 24 | deleteToDo = ({ text }) => 25 | this.setState(({ toDos }) => ({ 26 | toDos: toDos.filter(toDo => toDo.text !== text), 27 | })); 28 | 29 | render() { 30 | const toDos = this.state.toDos.map(toDo => 31 | , 37 | ); 38 | 39 | return ( 40 |
41 |

todos

42 | 43 | {toDos} 44 |
45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/components/List/List.styl: -------------------------------------------------------------------------------- 1 | .list 2 | align-items center 3 | display flex 4 | flex-direction column 5 | width 100% 6 | 7 | .list__header 8 | font-family "Helvetica Neue", Helvetica, Arial, sans-serif 9 | font-size 100px 10 | font-weight 100 11 | margin 10px -------------------------------------------------------------------------------- /src/components/List/List.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import List from './List'; 5 | 6 | describe('NewToDo', () => { 7 | it('renders', () => { 8 | const list = shallow(); 9 | 10 | expect(list).toBeTruthy(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/components/List/NewToDo/NewToDo.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import './NewToDo.styl'; 5 | 6 | export default class NewToDo extends Component { 7 | static propTypes = { submit: PropTypes.func.isRequired }; 8 | 9 | state = { toDo: '' }; 10 | 11 | handleChange = event => this.setState({ toDo: event.target.value }); 12 | 13 | submitTodo = event => { 14 | event.preventDefault(); 15 | 16 | this.props.submit(this.state.toDo); 17 | 18 | this.setState(() => ({ toDo: '' })); 19 | }; 20 | 21 | render() { 22 | const { toDo } = this.state; 23 | 24 | return ( 25 |
26 | 33 |
34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/components/List/NewToDo/NewToDo.styl: -------------------------------------------------------------------------------- 1 | .new-to-do__input 2 | box-shadow inset 0 -2px 1px rgba(0,0,0,0.03) 3 | height 33px 4 | font-size 24px 5 | font-weight: 300 6 | outline-width 0px 7 | padding 16px 16px 16px 60px 8 | width 450px -------------------------------------------------------------------------------- /src/components/List/NewToDo/NewToDo.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import NewToDo from './NewToDo'; 5 | 6 | describe('NewToDo', () => { 7 | it('renders', () => { 8 | const newToDo = shallow( {}} />); 9 | 10 | expect(newToDo).toBeTruthy(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/components/List/ToDo/ToDo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import './ToDo.styl'; 5 | 6 | export default function ToDo({ complete, deleteToDo, text, toggleCompletion }) { 7 | return ( 8 |
9 |
10 | 16 | {text} 17 |
18 | 19 | 20 |
21 | ); 22 | } 23 | 24 | ToDo.propTypes = { 25 | complete: PropTypes.bool.isRequired, 26 | deleteToDo: PropTypes.func.isRequired, 27 | text: PropTypes.string.isRequired, 28 | toggleCompletion: PropTypes.func.isRequired, 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/List/ToDo/ToDo.styl: -------------------------------------------------------------------------------- 1 | .to-do 2 | align-items center 3 | border-bottom 1px solid black 4 | display flex 5 | font-family "Helvetica Neue", Helvetica, Arial, sans-serif 6 | font-weight 300 7 | height 45px 8 | justify-content space-between 9 | width 530px 10 | 11 | &:last-child 12 | border-bottom none 13 | 14 | .to-do__info--complete 15 | text-decoration line-through 16 | 17 | .to-do__completion 18 | margin-right 50px -------------------------------------------------------------------------------- /src/components/List/ToDo/ToDo.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import toJson from 'enzyme-to-json'; 3 | import { shallow } from 'enzyme'; 4 | 5 | import ToDo from './ToDo'; 6 | 7 | describe('ToDo', () => { 8 | // Using jest to set up a single unit test. 9 | // The first argument is just an explanation of what the test is, 10 | // this is what will be output while tests run, so make it descriptive. 11 | it('displays text based on props.text', () => { 12 | // Creating a shallow rendered(pared down) version of the component with Enzyme. 13 | const toDo = shallow( 14 | // Ensure props are passed as they would be in production, even if they aren't used in the test. 15 | null} 18 | text="Test ToDo" 19 | toggleCompletion={() => null} 20 | />, 21 | ); 22 | 23 | // .find is like document.querySelector, it finds an element based on: 24 | // - Element i.e "button" 25 | // - id or class i.e "#app" ".foo" 26 | // - Component name, if we wanted to find our ToDo component from a parent component: "ToDo" 27 | // Once we have found the element we use the chai-enzyme assertion .to.have.text to check the element text 28 | expect(toDo.find('.to-do__info').text()).toBe('Test ToDo'); 29 | }); 30 | 31 | it('changes class based on props.completion', () => { 32 | const toDo = shallow( 33 | null} 36 | text="Test ToDo" 37 | toggleCompletion={() => null} 38 | />, 39 | ); 40 | const inCompleteToDo = shallow( 41 | null} 44 | text="Test ToDo" 45 | toggleCompletion={() => null} 46 | />, 47 | ); 48 | 49 | // The first time this test is run, it will generate a snapshot of the component's output. 50 | // As further changes are made to the component, you simply review the diff to ensure 51 | // that the changes are intentional. 52 | expect(toJson(toDo)).toMatchSnapshot(); 53 | // We still need to check both potential states of the component! 54 | expect(toJson(inCompleteToDo)).toMatchSnapshot(); 55 | }); 56 | 57 | it('ToDo calls props.toggleCompletion on checkbox change', () => { 58 | // Creating a sinon spy, 59 | // essentially a dummy function who's whole purpose is to tell us information about how it is called. 60 | const toggleCompletionSpy = jest.fn(); 61 | 62 | // We pass the spy as a prop to the component 63 | const toDo = shallow( 64 | null} 67 | text="Test ToDo" 68 | toggleCompletion={toggleCompletionSpy} 69 | />, 70 | ); 71 | 72 | // Enzyme is designed as a UI testing tool, 73 | // which means that rather than manually setting props, it is preferable to use the UI to simulate events. 74 | // Here we find the completion checkbox and simulate a "change" event, 75 | // matching the event handler inside the component. 76 | toDo.find('.to-do__completion').simulate('change'); 77 | 78 | // Here we check whether our mocked function has been called the expected number of times 79 | // If we needed to access the arguments we could say 80 | // expect(toggleCompletionSpy).toHaveBeenCalledWith(expectedArguments); 81 | expect(toggleCompletionSpy).toHaveBeenCalled(); 82 | }); 83 | 84 | it('ToDo calls props.deleteTodo on delete button click', () => { 85 | const deleteToDoSpy = jest.fn(); 86 | 87 | const toDo = shallow( 88 | null} 93 | />, 94 | ); 95 | 96 | toDo.find('button').simulate('click'); 97 | 98 | expect(deleteToDoSpy).toHaveBeenCalled(); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/components/List/ToDo/__snapshots__/ToDo.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ToDo changes class based on props.completion 1`] = ` 4 |
7 |
10 | 16 | Test ToDo 17 |
18 | 23 |
24 | `; 25 | 26 | exports[`ToDo changes class based on props.completion 2`] = ` 27 |
30 |
33 | 39 | Test ToDo 40 |
41 | 46 |
47 | `; 48 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | 4 | import List from './components/List/List'; 5 | 6 | document.addEventListener('DOMContentLoaded', () => { 7 | const reactNode = document.getElementById('app'); 8 | 9 | render(, reactNode); 10 | }); 11 | -------------------------------------------------------------------------------- /test-config/file-mock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /test-config/setup.js: -------------------------------------------------------------------------------- 1 | const JSDOM = require('jsdom').JSDOM; 2 | 3 | global.document = new JSDOM(''); 4 | global.window = document.defaultView; 5 | Object.keys(document.defaultView).forEach(property => { 6 | if (typeof global[property] === 'undefined') { 7 | global[property] = document.defaultView[property]; 8 | } 9 | }); 10 | 11 | global.navigator = { 12 | userAgent: 'node.js', 13 | }; 14 | -------------------------------------------------------------------------------- /test-config/style-mock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const Dashboard = require('webpack-dashboard/plugin'); 4 | const rc = require('./package.json').babel; 5 | 6 | module.exports = { 7 | context: path.resolve(__dirname), 8 | entry: { 9 | main: [ 10 | 'webpack-dev-server/client?http://0.0.0.0:8080', 11 | 'webpack/hot/only-dev-server', 12 | './src/index.js', 13 | ], 14 | }, 15 | devServer: { 16 | contentBase: './dist', 17 | historyApiFallback: true, 18 | hot: true, 19 | overlay: false, 20 | stats: { colors: true }, 21 | publicPath: 'http://127.0.0.1:8080', 22 | }, 23 | output: { 24 | path: path.join(__dirname, './dist'), 25 | filename: 'bundle.js', 26 | publicPath: '/', 27 | }, 28 | module: { 29 | rules: [ 30 | { 31 | exclude: /node_modules/, 32 | use: { 33 | loader: 'babel-loader', 34 | options: rc, 35 | }, 36 | test: /\.js/, 37 | }, 38 | { use: ['style-loader', 'css-loader', 'stylus-loader'], test: /\.styl/ }, 39 | { use: ['style-loader', 'css-loader'], test: /\.css/ }, 40 | ], 41 | }, 42 | 43 | resolve: { 44 | modules: [path.resolve('./src'), 'node_modules'], 45 | extensions: ['.js'], 46 | }, 47 | 48 | plugins: [new Dashboard()], 49 | }; 50 | --------------------------------------------------------------------------------