├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── INSTRUCTIONS.md ├── LICENSE ├── README.md ├── exercises-final ├── components │ ├── Button.js │ ├── Button.test.js │ ├── README.md │ ├── Toggle.js │ ├── Toggle.test.js │ └── __snapshots__ │ │ └── Button.test.js.snap ├── containers │ ├── CustomerList.js │ ├── CustomerList.test.js │ ├── README.md │ └── __snapshots__ │ │ └── CustomerList.test.js.snap ├── jest.config.json └── store │ ├── Customers.js │ ├── Customers.stub.js │ ├── Customers.test.js │ └── README.md ├── exercises ├── components │ ├── Button.js │ ├── Button.test.js │ ├── README.md │ ├── Toggle.js │ └── Toggle.test.js ├── containers │ ├── CustomerList.js │ ├── CustomerList.test.js │ └── README.md ├── jest.config.json └── store │ ├── Customers.js │ ├── Customers.stub.js │ ├── Customers.test.js │ └── README.md ├── package.json ├── scripts ├── install.js └── verify.js ├── templates ├── components │ ├── Button.js │ ├── Button.test.js │ ├── README.md │ ├── Toggle.js │ └── Toggle.test.js ├── containers │ ├── CustomerList.js │ ├── CustomerList.test.js │ └── README.md ├── jest.config.json └── store │ ├── Customers.js │ ├── Customers.stub.js │ ├── Customers.test.js │ └── README.md └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-2", "react"], 3 | "env": { 4 | "test": { 5 | "plugins": [ 6 | "istanbul" 7 | ] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # all files 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | indent_style = space 11 | indent_size = 2 12 | charset = utf-8 13 | trim_trailing_whitespace = true 14 | max_line_length = 120 15 | 16 | [*.js] 17 | quote_type = single 18 | curly_bracket_next_line = false 19 | spaces_around_operators = true 20 | spaces_around_brackets = inside 21 | indent_brace_style = BSD KNF 22 | 23 | # HTML 24 | [*.html] 25 | quote_type = double 26 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | 3 | # these scripts have to work on older versions of node 4 | scripts/install.js 5 | scripts/verify.js 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "kentcdodds/best-practices", 4 | "kentcdodds/possible-errors", 5 | "kentcdodds/es6/best-practices", 6 | "kentcdodds/es6/possible-errors", 7 | "kentcdodds/import/best-practices", 8 | "kentcdodds/import/possible-errors", 9 | "kentcdodds/jest", 10 | "kentcdodds/react", 11 | ], 12 | "rules": { 13 | "no-invalid-this": 0, 14 | "global-require": 0, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | -------------------------------------------------------------------------------- /INSTRUCTIONS.md: -------------------------------------------------------------------------------- 1 | # Instructions 2 | 3 | If you'd like to follow along: 4 | 5 | 1. `$ git clone https://github.com/kentcdodds/react-jest-workshop.git` 6 | 2. `$ cd react-jest-workshop` 7 | 4. `$ npm run setup` 8 | 9 | You'll notice that this repository is already (mostly) set up for a React project. 10 | It's a bit contrived and doesn't actually amount to anything but a couple 11 | disconnected components and a fairly worthless (non-flux) store. 12 | 13 | You'll also notice that right next to each module, there's a `.test.js` file where 14 | there's a single test that just verifies that your tests are running. 15 | 16 | We already have many of the same dependencies you would have in a normal react 17 | project like `babel` and friends, `react` (and friends), and 18 | `eslint`... and friends. However, to get our tests going, we're going to need 19 | a few more dependencies. Oh, and one more thing, let me introduce you to your new 20 | best friend: 21 | 22 | 🐯 *- Hi! I'm Terry the Tiger! These instructions are really long and boring! So* 23 | *I'll pop up here and there where you'll be expected to actually do something!* 24 | *And if you really want to skip around, just copy me and +f* 25 | *(or CTRL+f on windows) for me on the page. See you around!* 26 | 27 | # Incomplete :-( 28 | 29 | Unfortunately I haven't had time to finish this (as I did with the AVA counterpart) 30 | 31 | On the plus side, Jest is pretty easy to get set up! And I have a few videos 32 | on [egghead.io](https://egghead.io/) [here](http://kcd.im/egghead-jest) to help you 33 | get going. 34 | 35 | Also, this repo has `exercises` (where you'll do most of your work) and 36 | `exercises-final` (where you can check your work). If you notice any typos or anything 37 | feel free to [make a pull request](http://makeapullrequest.com/) to the `templates` 38 | directory :) 39 | 40 | Thanks! 41 | 42 | --- 43 | 44 | ## Appendix 45 | 46 | ### Redux 47 | 48 | You may be wondering, "how do I test components that use Redux?" Well, this repo 49 | doesn't really show that, but it's because it's pretty much exactly how you do 50 | a normal `Props` input test because if you're using `connect` from `react-redux` 51 | then you simply `export` the component that you're wrapping in `connect` for 52 | testing purposes, and just test that the same way you do other components with 53 | `Props` inputs. 54 | 55 | If you're not using `connect` and you're subscribing to it yourself, then you'll 56 | simply treat it like the `Data` input test where you accept the store as a prop 57 | and add an item in `defaultProps` for the actual store singleton. 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Kent C. Dodds 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [React][React] + [Jest][Jest] + [Enzyme][Enzyme] = :heart: 2 | 3 | [![slides-badge][slides-badge]][slides] 4 | [![PRs Welcome][prs-badge]][prs] 5 | [![Donate][donate-badge]][donate] 6 | 7 | Sponsor 8 | 9 | Find slides [here](http://kcd.im/testing-react) 10 | 11 | This is a workshop for learning how to test [React][React] with the [Jest][Jest] testing framework and the 12 | [Enzyme][Enzyme] testing library. 13 | 14 | ## Project Setup 15 | 16 | This project assumes you have [NodeJS v6](http://nodejs.org/) or greater installed. It's also recommended to use the 17 | [`yarn`](https://yarnpkg.com/) client (rather than [npm](https://www.npmjs.com/)). If you'd rather stick with `npm`, 18 | that's fine. Just replace `yarn` with `npm` in the instructions below and hope that things don't break 😏. 19 | You'll also need a recent version of [git](https://git-scm.com/) installed as well. 20 | 21 | With that, run: 22 | 23 | ``` 24 | git clone https://github.com/kentcdodds/react-jest-workshop.git 25 | cd react-jest-workshop 26 | yarn run setup 27 | ``` 28 | 29 | If the `yarn run setup` script finishes without errors (don't worry about warnings) then you're good to go. Otherwise, 30 | please [file an issue](https://help.github.com/articles/creating-an-issue/). 31 | 32 | ## Testing Instructions 33 | 34 | There are two directories in this project that you should be interested in: 35 | 36 | - `exercises`: Where the unfinished tests are (where you should add your tests). 37 | - `exercises-final`: Where the finished tests are (where you can reference if you get stuck). 38 | 39 | The tests in `exercises` are actually all scaffolded for you. So your goal is to go through and write all the tests. Do this: 40 | 41 | 1. Run `yarn run watch:test` which will start running the tests in watch mode, meaning that as you save your file, it 42 | will automatically re-run your tests so you can quickly see how you're doing. 43 | 2. Choose a file in the `exercises` directory that ends in `.test.js` and implement the tests one-by-one. 44 | 45 | Good luck! 46 | 47 | # LICENSE 48 | 49 | MIT 50 | 51 | [React]: https://facebook.github.io/react/ 52 | [Jest]: http://facebook.github.io/jest/ 53 | [Enzyme]: http://airbnb.io/enzyme/ 54 | [slides]: http://kcd.im/react-jest 55 | [slides-badge]: https://cdn.rawgit.com/kentcdodds/custom-badges/2/badges/slides.svg 56 | [donate]: http://kcd.im/donate 57 | [prs-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square 58 | [prs]: http://makeapullrequest.com 59 | [donate-badge]: https://img.shields.io/badge/$-support-green.svg?style=flat-square 60 | -------------------------------------------------------------------------------- /exercises-final/components/Button.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from 'react' 2 | 3 | export default Button 4 | 5 | function Button({children}, {color}) { 6 | return ( 7 | 10 | ) 11 | } 12 | 13 | Button.propTypes = { 14 | children: PropTypes.any.isRequired, 15 | } 16 | 17 | Button.contextTypes = { 18 | color: React.PropTypes.string 19 | } 20 | -------------------------------------------------------------------------------- /exercises-final/components/Button.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {mount} from 'enzyme' 3 | import {mountToJson} from 'enzyme-to-json' 4 | import Button from './Button' 5 | 6 | test('styles the button with a background of the context color', () => { 7 | const wrapper = mount(, { 8 | context: {color: 'blue'} 9 | }) 10 | expect(mountToJson(wrapper)).toMatchSnapshot() 11 | }) 12 | -------------------------------------------------------------------------------- /exercises-final/components/README.md: -------------------------------------------------------------------------------- 1 | # Toggle 2 | 3 | This demonstrates how to test for output and avoid testing implementation details. 4 | One of the things you want to avoid in testing, be it unit, integration, whatever, 5 | is testing *how* something works rather than simply that it accomplishes what it 6 | needs to accomplish. 7 | 8 | In this component, we want to test as much as we can by purely changing the props 9 | used to initialize it. However, this component also responds to user interaction 10 | to alter some of its state, so we'll work with simulating user-invoked events to 11 | test how that interaction changes the output of our component as well as how it 12 | interacts with the props that we pass it. 13 | 14 | # Button 15 | 16 | This demonstrates how you might test something that utilizes context. 17 | It is unlikely you have very many places where you'll be doing this 18 | (using context), however there are some cases where you'll do that. 19 | -------------------------------------------------------------------------------- /exercises-final/components/Toggle.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes, Component} from 'react' 2 | 3 | class Toggle extends Component { 4 | constructor(props, ...rest) { 5 | super(props, ...rest) 6 | this.state = { 7 | toggledOn: props.initialToggledOn || false, 8 | } 9 | } 10 | 11 | handleToggleClick = () => { 12 | const toggledOn = !this.state.toggledOn 13 | this.props.onToggle(toggledOn) 14 | this.setState({toggledOn}) 15 | } 16 | 17 | render() { 18 | const {children} = this.props 19 | const {toggledOn} = this.state 20 | 21 | const onOff = toggledOn ? 'on' : 'off' 22 | const toggledClassName = `toggle--${onOff}` 23 | return ( 24 |
25 | 28 |
29 | ) 30 | } 31 | } 32 | 33 | Toggle.propTypes = { 34 | initialToggledOn: PropTypes.bool, 35 | onToggle: PropTypes.func.isRequired, 36 | children: PropTypes.any.isRequired, 37 | } 38 | 39 | export default Toggle 40 | -------------------------------------------------------------------------------- /exercises-final/components/Toggle.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {render, mount} from 'enzyme' 3 | import Toggle from './Toggle' 4 | 5 | test('has toggle--off class applied by default', () => { 6 | const wrapper = renderToggle() 7 | expect(rootHasClass(wrapper, 'toggle--off')).toBe(true) 8 | }) 9 | 10 | test('has toggle--on class applied when initialToggledOn specified to true', () => { 11 | const wrapper = renderToggle({initialToggledOn: true}) 12 | expect(rootHasClass(wrapper, 'toggle--on')).toBe(true) 13 | }) 14 | 15 | test('invokes the onToggle prop when clicked', () => { 16 | const onToggle = jest.fn() 17 | const wrapper = mountToggle({onToggle}) 18 | clickButton(wrapper) 19 | expect(onToggle).toHaveBeenCalledTimes(1) 20 | expect(onToggle).toBeCalledWith(true) 21 | }) 22 | 23 | 24 | /** 25 | * Uses enzyme to mount the Toggle component 26 | * @param {Object} props - the props to mount the component with 27 | * @return {Object} - the enzyme wrapper 28 | */ 29 | function mountToggle(props = {}) { 30 | return mount( 31 | {}} 33 | children="Toggle Me" 34 | {...props} 35 | /> 36 | ) 37 | } 38 | 39 | /** 40 | * Uses enzyme to render the Toggle component 41 | * @param {Object} props - the props to render the component with 42 | * @return {Object} - the enzyme wrapper 43 | */ 44 | function renderToggle(props = {}) { 45 | return render( 46 | {}} 48 | children="Toggle Me" 49 | {...props} 50 | /> 51 | ) 52 | } 53 | 54 | /** 55 | * finds the button in the given wrapper and simulates a click event 56 | * @param {Object} wrapper - the enzyme wrapper 57 | */ 58 | function clickButton(wrapper) { 59 | wrapper.find('button').first().simulate('click') 60 | } 61 | 62 | /** 63 | * Returns whether the root of the given wrapper has the given className 64 | * @param {Object} wrapper - the wrapper to get the root element from 65 | * @param {String} className - the class to check for 66 | * @return {Boolean} whether the root element has the given class 67 | */ 68 | function rootHasClass(wrapper, className) { 69 | return wrapper.children().first().hasClass(className) 70 | } 71 | -------------------------------------------------------------------------------- /exercises-final/components/__snapshots__/Button.test.js.snap: -------------------------------------------------------------------------------- 1 | exports[`test styles the button with a background of the context color 1`] = ` 2 | 11 | 12 | `; 13 | -------------------------------------------------------------------------------- /exercises-final/containers/CustomerList.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes, Component} from 'react' 2 | import store from '../store/Customers' 3 | 4 | class CustomerList extends Component { 5 | constructor(props) { 6 | super(props) 7 | this.state = { 8 | customers: props.store.getCustomers(), 9 | } 10 | } 11 | componentDidMount() { 12 | this.unsubscribe = this.props.store.subscribe(this.updateStateWithCustomers) 13 | } 14 | componentWillUnmount() { 15 | this.unsubscribe() 16 | } 17 | 18 | updateStateWithCustomers = () => { 19 | const customers = this.props.store.getCustomers() 20 | this.setState({customers}) 21 | } 22 | 23 | render() { 24 | const {customers} = this.state 25 | if (customers.length === 0) { 26 | return 27 | } else { 28 | return 29 | } 30 | } 31 | } 32 | 33 | CustomerList.defaultProps = {store} 34 | 35 | CustomerList.propTypes = { 36 | store: PropTypes.shape({ 37 | getCustomers: PropTypes.func, 38 | subscribe: PropTypes.func, 39 | }), 40 | } 41 | 42 | function ListOfCustomers({customers}) { 43 | return ( 44 |
45 | Here is your list of customers! 46 |
    47 | {customers.map((c, i) =>
  • {c.name}
  • )} 48 |
49 |
50 | ) 51 | } 52 | 53 | ListOfCustomers.propTypes = { 54 | customers: PropTypes.array, 55 | } 56 | 57 | function NoCustomers() { 58 | return ( 59 |
60 | You have no customers. Better get to work! 61 |
62 | ) 63 | } 64 | 65 | export default CustomerList 66 | -------------------------------------------------------------------------------- /exercises-final/containers/CustomerList.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {render, mount} from 'enzyme' 3 | import {renderToJson, mountToJson} from 'enzyme-to-json' 4 | import getStoreStub from '../store/Customers.stub' 5 | import CustomerList from './CustomerList' 6 | 7 | test('should render no customers', () => { 8 | snapshotCustomerList() 9 | }) 10 | 11 | test('should render customers', () => { 12 | const {store} = getStoreStub([{name: 'Bob'}, {name: 'Joanna'}]) 13 | snapshotCustomerList({store}) 14 | }) 15 | 16 | test('should respond to store updates', () => { 17 | const {store, updateCustomers} = getStoreStub() 18 | const wrapper = mountCustomerList({store}) 19 | expect(mountToJson(wrapper)).toMatchSnapshot() 20 | updateCustomers([{name: 'Jill'}, {name: 'Fred'}]) 21 | expect(mountToJson(wrapper)).toMatchSnapshot() 22 | }) 23 | 24 | test('unsubscribe when unmounted', () => { 25 | const {unsubscribe, store} = getStoreStub() 26 | const wrapper = mountCustomerList({store}) 27 | wrapper.unmount() 28 | expect(unsubscribe).toHaveBeenCalledTimes(1) 29 | }) 30 | 31 | /** 32 | * Render the and snapshot it 33 | * @param {Object} props - the props to render with 34 | */ 35 | function snapshotCustomerList(props = {}) { 36 | const wrapper = renderCustomerList(props) 37 | expect(renderToJson(wrapper)).toMatchSnapshot() 38 | } 39 | 40 | /** 41 | * Renders with the given props 42 | * @param {Object} props - the props to render with 43 | * @return {Object} the rendered component 44 | */ 45 | function renderCustomerList({store = getStoreStub().store}) { 46 | return render() 47 | } 48 | 49 | /** 50 | * Mounts with the given props 51 | * @param {Object} props - the props to mount with 52 | * @return {Object} the rendered component 53 | */ 54 | function mountCustomerList({store = getStoreStub().store}) { 55 | return mount() 56 | } 57 | -------------------------------------------------------------------------------- /exercises-final/containers/README.md: -------------------------------------------------------------------------------- 1 | # CustomerList 2 | 3 | This demonstrates how to test a component that has conditional logic in its 4 | render method. More usefully, it demonstrates how you can test components 5 | that re-render based off of updates from an external data source. In this 6 | case this is a (non-flux) Customer Store. 7 | 8 | With Redux, doing this test is simpler because you don't concern yourself 9 | with state in your components and they're simply passed the state via props. 10 | 11 | In this scenario, we're depending on a "singleton" store for our application 12 | and our component's lifecycle hooks are subscribing to this store. This can 13 | cause issues with our tests potentially mucking with the state of other tests. 14 | To combat this, if we make it possible to override the store we're using (by 15 | utilizing `defaultProps`) then we can easily pass our own stubbed version of 16 | this store to avoid issues with sharing a singleton across tests and allow 17 | us to perform assertions on properties of this stubbed store. 18 | 19 | -------------------------------------------------------------------------------- /exercises-final/containers/__snapshots__/CustomerList.test.js.snap: -------------------------------------------------------------------------------- 1 | exports[`test should render customers 1`] = ` 2 |
3 | Here is your list of customers! 4 |
    5 |
  • 6 | Bob 7 |
  • 8 |
  • 9 | Joanna 10 |
  • 11 |
12 |
13 | `; 14 | 15 | exports[`test should render no customers 1`] = ` 16 |
17 | You have no customers. Better get to work! 18 |
19 | `; 20 | 21 | exports[`test should respond to store updates 1`] = ` 22 | 29 | 30 |
31 | You have no customers. Better get to work! 32 |
33 |
34 |
35 | `; 36 | 37 | exports[`test should respond to store updates 2`] = ` 38 | 45 | 56 |
57 |
    58 |
  • 59 | Jill 60 |
  • 61 |
  • 62 | Fred 63 |
  • 64 |
65 |
66 |
67 |
68 | `; 69 | -------------------------------------------------------------------------------- /exercises-final/jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "testPathDirs": [ 3 | "exercises-final" 4 | ], 5 | "coverageThreshold": { 6 | "global": { 7 | "branches": 100, 8 | "functions": 95, 9 | "lines": 100, 10 | "statements": 100 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /exercises-final/store/Customers.js: -------------------------------------------------------------------------------- 1 | let _customers = [] 2 | const callbacks = [] 3 | 4 | export default { 5 | getCustomers, 6 | setCustomers, 7 | subscribe, 8 | } 9 | 10 | /** 11 | * Returns the current list of customers 12 | * @return {Array} customers 13 | */ 14 | function getCustomers() { 15 | return _customers 16 | } 17 | 18 | /** 19 | * Sets the current list of customers to the given customers 20 | * and lets all the subscribers know about the update 21 | * @param {Array} customers - An array of objects that have a name property that is a string 22 | */ 23 | function setCustomers(customers) { 24 | _customers = customers 25 | _letSubscribersKnow() 26 | } 27 | 28 | /** 29 | * Adds the given callback to a list of functions to be called when the current customers are set 30 | * @param {Function} callback - the callback to be called 31 | * @return {Function} - a function to call to unsubscribe 32 | */ 33 | function subscribe(callback) { 34 | callbacks.push(callback) 35 | return function removeCallback() { 36 | callbacks.splice(callbacks.indexOf(callback), 1) 37 | } 38 | } 39 | 40 | /** 41 | * Iterates through all callbacks and calls them 42 | */ 43 | function _letSubscribersKnow() { 44 | callbacks.forEach(cb => cb()) 45 | } 46 | -------------------------------------------------------------------------------- /exercises-final/store/Customers.stub.js: -------------------------------------------------------------------------------- 1 | export default getStoreStub 2 | 3 | /** 4 | * Create a stub for the store which can be used for assertions 5 | * @param {Array} customers - the array of customers 6 | * @returns {Object} - ref property has customers and will haf ref.callback when 7 | * store.callback is invoked. store.getCustomers will return ref.customers 8 | */ 9 | function getStoreStub(customers = []) { 10 | let callback 11 | const unsubscribe = jest.fn() 12 | const ref = {customers} 13 | 14 | const store = { 15 | getCustomers: () => ref.customers, 16 | subscribe: cb => { 17 | callback = cb 18 | return unsubscribe 19 | }, 20 | } 21 | return {unsubscribe, store, updateCustomers} 22 | 23 | function updateCustomers(newCustomers) { 24 | ref.customers = newCustomers 25 | callback() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /exercises-final/store/Customers.test.js: -------------------------------------------------------------------------------- 1 | test('should start with empty', () => { 2 | const {store} = setup() 3 | const customers = store.getCustomers() 4 | expect(customers.length).toBe(0) 5 | }) 6 | 7 | test('should allow you to set customers and get them', () => { 8 | const {store} = setup() 9 | const c0 = {name: 'Bill'} 10 | const c1 = {name: 'Francine'} 11 | store.setCustomers([c0, c1]) 12 | const customers = store.getCustomers() 13 | const [sc0, sc1] = customers 14 | expect(customers.length).toBe(2) 15 | expect(c0).toBe(sc0) 16 | expect(c1).toBe(sc1) 17 | }) 18 | 19 | test('should allow you to subscribe to the store', () => { 20 | const {store} = setup() 21 | const subscriber = jest.fn() 22 | const unsubscribe = store.subscribe(subscriber) 23 | store.setCustomers([]) 24 | expect(subscriber).toHaveBeenCalledTimes(1) 25 | subscriber.mockClear() 26 | unsubscribe() 27 | store.setCustomers([]) 28 | expect(subscriber).not.toBeCalled() 29 | }) 30 | 31 | /** 32 | * Prepares our environment for an individual test and returns whatever is needed for that test to run. 33 | * @return {Object} what is needed for tests to run. In this case it is only a fresh copy of the store 34 | */ 35 | function setup() { 36 | // clear the require cache so when we require the store we get a fresh copy 37 | jest.resetModules() 38 | const store = require('./Customers').default 39 | return {store} 40 | } 41 | -------------------------------------------------------------------------------- /exercises-final/store/README.md: -------------------------------------------------------------------------------- 1 | # Old-school 2 | 3 | Not all applications use Redux 😞. I hacked together this (non-flux) Customers store 4 | to pretty much demonstrate how you test dealing with updates that happen to your 5 | components when they're updated from an external source of data (like a store). 6 | 7 | I don't recommend you write your stores this way. 8 | 9 | This store is tested, but there's not really much going on in here... 10 | -------------------------------------------------------------------------------- /exercises/components/Button.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from 'react' 2 | 3 | export default Button 4 | 5 | function Button({children}, {color}) { 6 | return ( 7 | 10 | ) 11 | } 12 | 13 | Button.propTypes = { 14 | children: PropTypes.any.isRequired, 15 | } 16 | 17 | Button.contextTypes = { 18 | color: React.PropTypes.string 19 | } 20 | -------------------------------------------------------------------------------- /exercises/components/Button.test.js: -------------------------------------------------------------------------------- 1 | // You're going to need react, mount from enzyme, 2 | // mountToJson from enzyme-to-json, and ./Button 3 | 4 | test('styles the button with a background of the context color', () => { 5 | // get a new version of the Button component by using stubContext to stub it with the color blue 6 | // render that component 7 | // take a snapshot of the result and verify the snapshot 8 | }) 9 | -------------------------------------------------------------------------------- /exercises/components/README.md: -------------------------------------------------------------------------------- 1 | # Toggle 2 | 3 | This demonstrates how to test for output and avoid testing implementation details. 4 | One of the things you want to avoid in testing, be it unit, integration, whatever, 5 | is testing *how* something works rather than simply that it accomplishes what it 6 | needs to accomplish. 7 | 8 | In this component, we want to test as much as we can by purely changing the props 9 | used to initialize it. However, this component also responds to user interaction 10 | to alter some of its state, so we'll work with simulating user-invoked events to 11 | test how that interaction changes the output of our component as well as how it 12 | interacts with the props that we pass it. 13 | 14 | # Button 15 | 16 | This demonstrates how you might test something that utilizes context. 17 | It is unlikely you have very many places where you'll be doing this 18 | (using context), however there are some cases where you'll do that. 19 | -------------------------------------------------------------------------------- /exercises/components/Toggle.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes, Component} from 'react' 2 | 3 | class Toggle extends Component { 4 | constructor(props, ...rest) { 5 | super(props, ...rest) 6 | this.state = { 7 | toggledOn: props.initialToggledOn || false, 8 | } 9 | } 10 | 11 | handleToggleClick = () => { 12 | const toggledOn = !this.state.toggledOn 13 | this.props.onToggle(toggledOn) 14 | this.setState({toggledOn}) 15 | } 16 | 17 | render() { 18 | const {children} = this.props 19 | const {toggledOn} = this.state 20 | 21 | const onOff = toggledOn ? 'on' : 'off' 22 | const toggledClassName = `toggle--${onOff}` 23 | return ( 24 |
25 | 28 |
29 | ) 30 | } 31 | } 32 | 33 | Toggle.propTypes = { 34 | initialToggledOn: PropTypes.bool, 35 | onToggle: PropTypes.func.isRequired, 36 | children: PropTypes.any.isRequired, 37 | } 38 | 39 | export default Toggle 40 | -------------------------------------------------------------------------------- /exercises/components/Toggle.test.js: -------------------------------------------------------------------------------- 1 | // you'll need to import react, enzyme's render and mount functions, 2 | // and ./Toggle 3 | 4 | test('has toggle--off class applied by default', () => { 5 | // create a renderToggle function and call that without arguments to get a wrapper with the defaults 6 | // expect the first child to have the class toggle--off (tip: create rootHasClass(wrapper, className) function) 7 | }) 8 | 9 | test('has toggle--on class applied when initialToggledOn specified to true', () => { 10 | // use the renderToggle function and call it with {initialToggledOn: true} 11 | // expect the first child to have the class toggle--on 12 | }) 13 | 14 | test('invokes the onToggle prop when clicked', () => { 15 | // create a mock function of onToggle with jest.fn() 16 | // create a mountToggle function and call that with {onToggle} 17 | // take the returned enzyme wrapper and simulate a click event on the button 18 | // assert that onToggle was called once 19 | // assert that it was called with `true` 20 | }) 21 | 22 | 23 | // create a renderToggle function that accepts some props and applies those to a render of the component 24 | // you should also provide defaults for any required props 25 | // create a mountToggle function that does basically the same thing except with mount 26 | // Also a clickButton(wrapper) function would be handy to create here as well as both tests will need to do that. 27 | -------------------------------------------------------------------------------- /exercises/containers/CustomerList.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes, Component} from 'react' 2 | import store from '../store/Customers' 3 | 4 | class CustomerList extends Component { 5 | constructor(props) { 6 | super(props) 7 | this.state = { 8 | customers: store.getCustomers(), 9 | } 10 | } 11 | componentDidMount() { 12 | this.unsubscribe = store.subscribe(this.updateStateWithCustomers) 13 | } 14 | componentWillUnmount() { 15 | this.unsubscribe() 16 | } 17 | 18 | updateStateWithCustomers = () => { 19 | const customers = store.getCustomers() 20 | this.setState({customers}) 21 | } 22 | 23 | render() { 24 | const {customers} = this.state 25 | if (customers.length === 0) { 26 | return 27 | } else { 28 | return 29 | } 30 | } 31 | } 32 | 33 | function ListOfCustomers({customers}) { 34 | return ( 35 |
36 | Here is your list of customers! 37 |
    38 | {customers.map((c, i) =>
  • {c.name}
  • )} 39 |
40 |
41 | ) 42 | } 43 | 44 | ListOfCustomers.propTypes = { 45 | customers: PropTypes.array, 46 | } 47 | 48 | function NoCustomers() { 49 | return ( 50 |
51 | You have no customers. Better get to work! 52 |
53 | ) 54 | } 55 | 56 | export default CustomerList 57 | -------------------------------------------------------------------------------- /exercises/containers/CustomerList.test.js: -------------------------------------------------------------------------------- 1 | // you're going to need to import a few things here: 2 | // react, react-test-renderer, ../store/Customers.stub, 3 | // and the ./CustomerList component (which we're testing) 4 | 5 | test('should render no customers', () => { 6 | // create a snapshotCustomerList function and test the default 7 | // behavior by calling it without arguments 8 | // Then use the resulting component to check the snapshot 9 | }) 10 | 11 | test('should render customers', () => { 12 | // get a store from the stub and initialize it with two customers 13 | // we need to have the component use our stub instead of the singleton store somehow... 14 | // We _could_ use Jest's mocking capabilities. Or, we could just alter the CustomerList component to allow you 15 | // to specify a store! So go to the CustomerList.js file and add a prop called `store`. Wherever the singleton 16 | // `store` is used, use `this.props.store` instead and use defaultProps to have the `store` default to the singleton 17 | // `store` (that way actual users of the component don't have to specify the store). 18 | // Now use the snapshotCustomerList function you wrote to pass the store as a prop 19 | }) 20 | 21 | test('should respond to store updates', () => { 22 | // get both the store and the updateCustomers from a call to `../store/Customers.stub` 23 | // render the customer list with the store stub 24 | // take a snapshot 25 | // call updateCustomers with a few customers 26 | // take another snapshot 27 | }) 28 | 29 | test('unsubscribe when unmounted', () => { 30 | // we want to make sure that the unsubscribe function is called on the store 31 | // so get the store stub and the unsubscribe mock function from '../store/Customers.stub' 32 | // Then use enzyme's `mount` function to mount `./CustomerList` with the store stub. 33 | // Take the resulting wrapper from that `mount` and unmount it by calling `wrapper.unmount` 34 | // Then assert that the `unsubscribe` mock was called once with toHaveBeenCalledTimes(1) 35 | }) 36 | 37 | // Create a snapshotCustomerList function that: 38 | // 1. Accepts props 39 | // 2. Creates a component with those props with a call to renderer.create (tip: you may wanna do this in a separate function) 40 | // 3. Asserts on a snapshot of that component with expect(component).toMatchSnapshot() 41 | // Create a renderCustomerList function that: 42 | // 1. Accepts props and defaults the store to the store stub 43 | // 2. Returns a render the CustomerList with those propse 44 | // Create a mountCustomerList function that: 45 | // 1. Accepts props and defaults the store to the store stub 46 | // 2. Returns a mount the CustomerList with those propse 47 | -------------------------------------------------------------------------------- /exercises/containers/README.md: -------------------------------------------------------------------------------- 1 | # CustomerList 2 | 3 | This demonstrates how to test a component that has conditional logic in its 4 | render method. More usefully, it demonstrates how you can test components 5 | that re-render based off of updates from an external data source. In this 6 | case this is a (non-flux) Customer Store. 7 | 8 | With Redux, doing this test is simpler because you don't concern yourself 9 | with state in your components and they're simply passed the state via props. 10 | 11 | In this scenario, we're depending on a "singleton" store for our application 12 | and our component's lifecycle hooks are subscribing to this store. This can 13 | cause issues with our tests potentially mucking with the state of other tests. 14 | To combat this, if we make it possible to override the store we're using (by 15 | utilizing `defaultProps`) then we can easily pass our own stubbed version of 16 | this store to avoid issues with sharing a singleton across tests and allow 17 | us to perform assertions on properties of this stubbed store. 18 | 19 | -------------------------------------------------------------------------------- /exercises/jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "testPathDirs": [ 3 | "exercises" 4 | ], 5 | "coverageThreshold": { 6 | "global": { 7 | "branches": 100, 8 | "functions": 95, 9 | "lines": 100, 10 | "statements": 100 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /exercises/store/Customers.js: -------------------------------------------------------------------------------- 1 | let _customers = [] 2 | const callbacks = [] 3 | 4 | export default { 5 | getCustomers, 6 | setCustomers, 7 | subscribe, 8 | } 9 | 10 | /** 11 | * Returns the current list of customers 12 | * @return {Array} customers 13 | */ 14 | function getCustomers() { 15 | return _customers 16 | } 17 | 18 | /** 19 | * Sets the current list of customers to the given customers 20 | * and lets all the subscribers know about the update 21 | * @param {Array} customers - An array of objects that have a name property that is a string 22 | */ 23 | function setCustomers(customers) { 24 | _customers = customers 25 | _letSubscribersKnow() 26 | } 27 | 28 | /** 29 | * Adds the given callback to a list of functions to be called when the current customers are set 30 | * @param {Function} callback - the callback to be called 31 | * @return {Function} - a function to call to unsubscribe 32 | */ 33 | function subscribe(callback) { 34 | callbacks.push(callback) 35 | return function removeCallback() { 36 | callbacks.splice(callbacks.indexOf(callback), 1) 37 | } 38 | } 39 | 40 | /** 41 | * Iterates through all callbacks and calls them 42 | */ 43 | function _letSubscribersKnow() { 44 | callbacks.forEach(cb => cb()) 45 | } 46 | -------------------------------------------------------------------------------- /exercises/store/Customers.stub.js: -------------------------------------------------------------------------------- 1 | export default getStoreStub 2 | 3 | /** 4 | * Create a stub for the store which can be used for assertions 5 | * @param {Array} customers - the array of customers 6 | * @returns {Object} - ref property has customers and will haf ref.callback when 7 | * store.callback is invoked. store.getCustomers will return ref.customers 8 | */ 9 | function getStoreStub(customers = []) { 10 | let callback 11 | const unsubscribe = jest.fn() 12 | const ref = {customers} 13 | 14 | const store = { 15 | getCustomers: () => ref.customers, 16 | subscribe: cb => { 17 | callback = cb 18 | return unsubscribe 19 | }, 20 | } 21 | return {unsubscribe, store, updateCustomers} 22 | 23 | function updateCustomers(newCustomers) { 24 | ref.customers = newCustomers 25 | callback() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /exercises/store/Customers.test.js: -------------------------------------------------------------------------------- 1 | test('should start with empty', () => { 2 | // get the store from your setup function 3 | // call getCustomers on it 4 | // assert that the lenth of customers is 0 5 | }) 6 | 7 | test('should allow you to set customers and get them', () => { 8 | // get the store 9 | // create two customers and set the store to them 10 | // get the customers from the store 11 | // assert that there are two customers 12 | // assert that the customers you got are the ones you set 13 | }) 14 | 15 | test('should allow you to subscribe to the store', () => { 16 | // get the store 17 | // setup a jest mock function (jest.fn()) for your subscriber 18 | // subscribe to the store with that function 19 | // call setCustomers 20 | // assert your subscriber was called once 21 | // clear your subscriber mock function (subscriber.mockClear()) 22 | // call the unsubscribe function you got when subscribing 23 | // call setCustomers 24 | // assert that your mock function was not called 25 | }) 26 | 27 | // Create a `setup` function: 28 | // clear the require cache with jest.resetModules() so you can require a fresh copy of the store 29 | // require the ./Customers module (note: because it's using `export default`, 30 | // the store is on the `default` property of what you're requiring) 31 | // return {store} 32 | -------------------------------------------------------------------------------- /exercises/store/README.md: -------------------------------------------------------------------------------- 1 | # Old-school 2 | 3 | Not all applications use Redux 😞. I hacked together this (non-flux) Customers store 4 | to pretty much demonstrate how you test dealing with updates that happen to your 5 | components when they're updated from an external source of data (like a store). 6 | 7 | I don't recommend you write your stores this way. 8 | 9 | This store is tested, but there's not really much going on in here... 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-jest-workshop", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "🐯 A workshop repository for testing React ⚛ with Jest 🃏", 6 | "main": "index.js", 7 | "scripts": { 8 | "lint": "eslint .", 9 | "test": "jest --coverage --config=exercises/jest.config.json", 10 | "test:final": "jest --coverage --config=exercises-final/jest.config.json", 11 | "watch:test": "jest --watch --config=exercises/jest.config.json", 12 | "generate": "split-guide generate", 13 | "validate": "npm run generate -s && npm run lint -s && npm run test:final -s", 14 | "setup": "node ./scripts/verify && node ./scripts/install && npm run validate" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/kentcdodds/react-jest-workshop.git" 19 | }, 20 | "keywords": [], 21 | "author": "Kent C. Dodds (http://kentcdodds.com/)", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/kentcdodds/react-jest-workshop/issues" 25 | }, 26 | "homepage": "https://github.com/kentcdodds/react-jest-workshop#readme", 27 | "dependencies": { 28 | "react": "^15.4.1", 29 | "react-dom": "^15.4.1" 30 | }, 31 | "devDependencies": { 32 | "babel-jest": "^17.0.2", 33 | "babel-polyfill": "6.16.0", 34 | "babel-preset-es2015": "^6.18.0", 35 | "babel-preset-react": "6.16.0", 36 | "babel-preset-stage-2": "^6.18.0", 37 | "enzyme": "^2.6.0", 38 | "enzyme-to-json": "^1.4.4", 39 | "eslint": "^3.11.1", 40 | "eslint-config-kentcdodds": "^11.1.0", 41 | "glob": "7.1.1", 42 | "jest": "^17.0.3", 43 | "mkdirp": "0.5.1", 44 | "react-addons-test-utils": "^15.3.2", 45 | "rimraf": "2.5.4", 46 | "split-guide": "1.1.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /scripts/install.js: -------------------------------------------------------------------------------- 1 | var cp = require('child_process') 2 | var spawn = cp.spawn, execSync = cp.execSync 3 | 4 | var useYarn = false 5 | try { 6 | useYarn = !!execSync('yarn --version') 7 | } catch (e) { 8 | // use npm instead :-( 9 | } 10 | 11 | var installer = useYarn ? 'yarn' : 'npm' 12 | 13 | console.log('\n📦 Installing dependencies via `' + installer + ' install`') 14 | 15 | spawn(installer, ['install'], {stdio: 'inherit', shell: true}) 16 | -------------------------------------------------------------------------------- /scripts/verify.js: -------------------------------------------------------------------------------- 1 | var execSync = require('child_process').execSync 2 | 3 | var desiredVersions = { 4 | yarn: '0.17.10', 5 | node: '6.0.0', 6 | npm: '4.0.3', 7 | } 8 | 9 | var errors = { 10 | noYarn: { 11 | message: 'You do not have yarn installed. This is a package manager client that installs from the regular npm ' + 12 | 'registry, but ensures you get the same versions of all dependencies required for this repository. ' + 13 | 'It is highly recommended that you install yarn: `npm install --global yarn` (learn more: https://yarnpkg.com/)', 14 | isProblem: false, 15 | }, 16 | oldYarn: { 17 | getMessage: function(desired, actual) { 18 | return 'Your version of yarn (' + actual + ') is older than the recommended version of ' + desired + '. ' + 19 | 'Run `yarn self-update` (or `npm install --global yarn@latest`) to update.' 20 | }, 21 | isProblem: false, 22 | }, 23 | oldNode: { 24 | getMessage: function(desired, actual) { 25 | return 'Your version of node (' + actual + ') is older than the recommended version of ' + desired + '. ' + 26 | 'Please install a more recent version. You can use http://git.io/nvm or https://github.com/coreybutler/nvm-windows ' + 27 | 'to make upgrading your version of node easier.' 28 | }, 29 | isProblem: false, 30 | }, 31 | oldNpm: { 32 | getMessage: function(desired, actual) { 33 | return 'Your version of npm (' + actual + ') is older than the recommended version of ' + desired + '. ' + 34 | 'You should install yarn anyway, but if you would rather use npm, please at least have a more recent version. ' + 35 | 'You can install the latest version by running `npm install --global npm@latest`.' 36 | }, 37 | isProblem: false, 38 | }, 39 | } 40 | 41 | var nodeVersion = process.versions.node 42 | errors.oldNode.isProblem = !versionIsGreater(desiredVersions.node, nodeVersion) 43 | errors.oldNode.message = errors.oldNode.getMessage(desiredVersions.node, nodeVersion) 44 | 45 | try { 46 | var yarnVersion = execSync('yarn --version').toString().trim() 47 | errors.oldYarn.isProblem = !versionIsGreater(desiredVersions.yarn, yarnVersion) 48 | errors.oldYarn.message = errors.oldYarn.getMessage(desiredVersions.yarn, yarnVersion) 49 | } catch (e) { 50 | errors.noYarn.isProblem = true 51 | var npmVersion = execSync('npm --version').toString().trim() 52 | errors.oldNpm.isProblem = !versionIsGreater(desiredVersions.npm, npmVersion) 53 | errors.oldNpm.message = errors.oldNpm.getMessage(desiredVersions.npm, npmVersion) 54 | } 55 | 56 | var systemErrors = Object.keys(errors) 57 | .filter(function(key) { return errors[key].isProblem }) 58 | 59 | var errorCount = systemErrors.length 60 | 61 | if (errorCount) { 62 | var errorMessage = systemErrors 63 | .reduce(function(messages, key) { 64 | messages.push(' - ' + errors[key].message) 65 | return messages 66 | }, []) 67 | .join('\n') 68 | var one = errorCount === 1 69 | 70 | console.error( 71 | 'There ' + (one ? 'is an issue' : 'are some issues') + ' with your system. ' + 72 | 'It is quite likely that if you do not resolve these, you will have a hard time running this repository.\n' + 73 | errorMessage 74 | ) 75 | console.info('If you don\'t care about these warnings, go ahead and install dependencies with `node ./scripts/install`') 76 | process.exitCode = 1 77 | } 78 | 79 | // returns actualVersion >= desiredVersion 80 | function versionIsGreater(desiredVersion, actualVersion) { 81 | var desiredVersions = /v?(\d+)\.(\d+)\.(\d+)/.exec(desiredVersion) 82 | var desiredMajor = Number(desiredVersions[1]), desiredMinor = Number(desiredVersions[2]), desiredPatch = Number(desiredVersions[3]) 83 | var actualVersions = /v?(\d+)\.(\d+)\.(\d+)/.exec(actualVersion) 84 | var actualMajor = Number(actualVersions[1]), actualMinor = Number(actualVersions[2]), actualPatch = Number(actualVersions[3]) 85 | if (actualMajor < desiredMajor) { 86 | return false 87 | } else if (actualMajor > desiredMajor) { 88 | return true 89 | } 90 | if (actualMinor < desiredMinor) { 91 | return false 92 | } else if (actualMinor > desiredMinor) { 93 | return true 94 | } 95 | if (actualPatch < desiredPatch) { 96 | return false 97 | } else if (actualPatch > desiredPatch) { 98 | return true 99 | } 100 | // by this point they should be equal 101 | return true 102 | } -------------------------------------------------------------------------------- /templates/components/Button.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from 'react' 2 | 3 | export default Button 4 | 5 | function Button({children}, {color}) { 6 | return ( 7 | 10 | ) 11 | } 12 | 13 | Button.propTypes = { 14 | children: PropTypes.any.isRequired, 15 | } 16 | 17 | Button.contextTypes = { 18 | color: React.PropTypes.string 19 | } 20 | -------------------------------------------------------------------------------- /templates/components/Button.test.js: -------------------------------------------------------------------------------- 1 | // FINAL_START 2 | import React from 'react' 3 | import {mount} from 'enzyme' 4 | import {mountToJson} from 'enzyme-to-json' 5 | import Button from './Button' 6 | // FINAL_END 7 | // WORKSHOP_START 8 | // You're going to need react, mount from enzyme, 9 | // mountToJson from enzyme-to-json, and ./Button 10 | // WORKSHOP_END 11 | 12 | test('styles the button with a background of the context color', () => { 13 | // FINAL_START 14 | const wrapper = mount(, { 15 | context: {color: 'blue'} 16 | }) 17 | expect(mountToJson(wrapper)).toMatchSnapshot() 18 | // FINAL_END 19 | // WORKSHOP_START 20 | // get a new version of the Button component by using stubContext to stub it with the color blue 21 | // render that component 22 | // take a snapshot of the result and verify the snapshot 23 | // WORKSHOP_END 24 | }) 25 | -------------------------------------------------------------------------------- /templates/components/README.md: -------------------------------------------------------------------------------- 1 | # Toggle 2 | 3 | This demonstrates how to test for output and avoid testing implementation details. 4 | One of the things you want to avoid in testing, be it unit, integration, whatever, 5 | is testing *how* something works rather than simply that it accomplishes what it 6 | needs to accomplish. 7 | 8 | In this component, we want to test as much as we can by purely changing the props 9 | used to initialize it. However, this component also responds to user interaction 10 | to alter some of its state, so we'll work with simulating user-invoked events to 11 | test how that interaction changes the output of our component as well as how it 12 | interacts with the props that we pass it. 13 | 14 | # Button 15 | 16 | This demonstrates how you might test something that utilizes context. 17 | It is unlikely you have very many places where you'll be doing this 18 | (using context), however there are some cases where you'll do that. 19 | -------------------------------------------------------------------------------- /templates/components/Toggle.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes, Component} from 'react' 2 | 3 | class Toggle extends Component { 4 | constructor(props, ...rest) { 5 | super(props, ...rest) 6 | this.state = { 7 | toggledOn: props.initialToggledOn || false, 8 | } 9 | } 10 | 11 | handleToggleClick = () => { 12 | const toggledOn = !this.state.toggledOn 13 | this.props.onToggle(toggledOn) 14 | this.setState({toggledOn}) 15 | } 16 | 17 | render() { 18 | const {children} = this.props 19 | const {toggledOn} = this.state 20 | 21 | const onOff = toggledOn ? 'on' : 'off' 22 | const toggledClassName = `toggle--${onOff}` 23 | return ( 24 |
25 | 28 |
29 | ) 30 | } 31 | } 32 | 33 | Toggle.propTypes = { 34 | initialToggledOn: PropTypes.bool, 35 | onToggle: PropTypes.func.isRequired, 36 | children: PropTypes.any.isRequired, 37 | } 38 | 39 | export default Toggle 40 | -------------------------------------------------------------------------------- /templates/components/Toggle.test.js: -------------------------------------------------------------------------------- 1 | // FINAL_START 2 | import React from 'react' 3 | import {render, mount} from 'enzyme' 4 | import Toggle from './Toggle' 5 | // FINAL_END 6 | // WORKSHOP_START 7 | // you'll need to import react, enzyme's render and mount functions, 8 | // and ./Toggle 9 | // WORKSHOP_END 10 | 11 | test('has toggle--off class applied by default', () => { 12 | // FINAL_START 13 | const wrapper = renderToggle() 14 | expect(rootHasClass(wrapper, 'toggle--off')).toBe(true) 15 | // FINAL_END 16 | // WORKSHOP_START 17 | // create a renderToggle function and call that without arguments to get a wrapper with the defaults 18 | // expect the first child to have the class toggle--off (tip: create rootHasClass(wrapper, className) function) 19 | // WORKSHOP_END 20 | }) 21 | 22 | test('has toggle--on class applied when initialToggledOn specified to true', () => { 23 | // FINAL_START 24 | const wrapper = renderToggle({initialToggledOn: true}) 25 | expect(rootHasClass(wrapper, 'toggle--on')).toBe(true) 26 | // FINAL_END 27 | // WORKSHOP_START 28 | // use the renderToggle function and call it with {initialToggledOn: true} 29 | // expect the first child to have the class toggle--on 30 | // WORKSHOP_END 31 | }) 32 | 33 | test('invokes the onToggle prop when clicked', () => { 34 | // FINAL_START 35 | const onToggle = jest.fn() 36 | const wrapper = mountToggle({onToggle}) 37 | clickButton(wrapper) 38 | expect(onToggle).toHaveBeenCalledTimes(1) 39 | expect(onToggle).toBeCalledWith(true) 40 | // FINAL_END 41 | // WORKSHOP_START 42 | // create a mock function of onToggle with jest.fn() 43 | // create a mountToggle function and call that with {onToggle} 44 | // take the returned enzyme wrapper and simulate a click event on the button 45 | // assert that onToggle was called once 46 | // assert that it was called with `true` 47 | // WORKSHOP_END 48 | }) 49 | 50 | // COMMENT_START 51 | // this one isn't working for some reason... Anyone wanna give it a look? 52 | test('changes the class to toggle--on when clicked', () => { 53 | // FINAL_START 54 | const wrapper = mountToggle() 55 | clickButton(wrapper) 56 | expect(rootHasClass(wrapper, 'toggle--on')).toBe(true) 57 | // FINAL_END 58 | // WORKSHOP_START 59 | // mountToggle with no specified props (just use defaults from your mountToggle function) 60 | // click the button 61 | // take a snapshot of the wrapper with mountToJson from enzyme-to-json and verify it looks good 62 | // WORKSHOP_END 63 | }) 64 | // COMMENT_END 65 | 66 | // FINAL_START 67 | /** 68 | * Uses enzyme to mount the Toggle component 69 | * @param {Object} props - the props to mount the component with 70 | * @return {Object} - the enzyme wrapper 71 | */ 72 | function mountToggle(props = {}) { 73 | return mount( 74 | {}} 76 | children="Toggle Me" 77 | {...props} 78 | /> 79 | ) 80 | } 81 | 82 | /** 83 | * Uses enzyme to render the Toggle component 84 | * @param {Object} props - the props to render the component with 85 | * @return {Object} - the enzyme wrapper 86 | */ 87 | function renderToggle(props = {}) { 88 | return render( 89 | {}} 91 | children="Toggle Me" 92 | {...props} 93 | /> 94 | ) 95 | } 96 | 97 | /** 98 | * finds the button in the given wrapper and simulates a click event 99 | * @param {Object} wrapper - the enzyme wrapper 100 | */ 101 | function clickButton(wrapper) { 102 | wrapper.find('button').first().simulate('click') 103 | } 104 | 105 | /** 106 | * Returns whether the root of the given wrapper has the given className 107 | * @param {Object} wrapper - the wrapper to get the root element from 108 | * @param {String} className - the class to check for 109 | * @return {Boolean} whether the root element has the given class 110 | */ 111 | function rootHasClass(wrapper, className) { 112 | return wrapper.children().first().hasClass(className) 113 | } 114 | // FINAL_END 115 | // WORKSHOP_START 116 | // create a renderToggle function that accepts some props and applies those to a render of the component 117 | // you should also provide defaults for any required props 118 | // create a mountToggle function that does basically the same thing except with mount 119 | // Also a clickButton(wrapper) function would be handy to create here as well as both tests will need to do that. 120 | // WORKSHOP_END 121 | -------------------------------------------------------------------------------- /templates/containers/CustomerList.js: -------------------------------------------------------------------------------- 1 | // COMMENT_START 2 | /* eslint no-dupe-keys:0, no-redeclare:0 */ 3 | // COMMENT_END 4 | import React, {PropTypes, Component} from 'react' 5 | import store from '../store/Customers' 6 | 7 | class CustomerList extends Component { 8 | constructor(props) { 9 | super(props) 10 | this.state = { 11 | // FINAL_START the key is getting the store from props 12 | customers: props.store.getCustomers(), 13 | // FINAL_END 14 | // WORKSHOP_START normally you'll just use the singleton store 15 | customers: store.getCustomers(), 16 | // WORKSHOP_END 17 | } 18 | } 19 | componentDidMount() { 20 | // FINAL_START 21 | this.unsubscribe = this.props.store.subscribe(this.updateStateWithCustomers) 22 | // FINAL_END 23 | // WORKSHOP_START 24 | this.unsubscribe = store.subscribe(this.updateStateWithCustomers) 25 | // WORKSHOP_END 26 | } 27 | componentWillUnmount() { 28 | this.unsubscribe() 29 | } 30 | 31 | updateStateWithCustomers = () => { 32 | // FINAL_START 33 | const customers = this.props.store.getCustomers() 34 | // FINAL_END 35 | // WORKSHOP_START 36 | const customers = store.getCustomers() 37 | // WORKSHOP_END 38 | this.setState({customers}) 39 | } 40 | 41 | render() { 42 | const {customers} = this.state 43 | if (customers.length === 0) { 44 | return 45 | } else { 46 | return 47 | } 48 | } 49 | } 50 | 51 | // FINAL_START 52 | CustomerList.defaultProps = {store} 53 | 54 | CustomerList.propTypes = { 55 | store: PropTypes.shape({ 56 | getCustomers: PropTypes.func, 57 | subscribe: PropTypes.func, 58 | }), 59 | } 60 | 61 | // FINAL_END 62 | function ListOfCustomers({customers}) { 63 | return ( 64 |
65 | Here is your list of customers! 66 |
    67 | {customers.map((c, i) =>
  • {c.name}
  • )} 68 |
69 |
70 | ) 71 | } 72 | 73 | ListOfCustomers.propTypes = { 74 | customers: PropTypes.array, 75 | } 76 | 77 | function NoCustomers() { 78 | return ( 79 |
80 | You have no customers. Better get to work! 81 |
82 | ) 83 | } 84 | 85 | export default CustomerList 86 | -------------------------------------------------------------------------------- /templates/containers/CustomerList.test.js: -------------------------------------------------------------------------------- 1 | // FINAL_START 2 | import React from 'react' 3 | import {render, mount} from 'enzyme' 4 | import {renderToJson, mountToJson} from 'enzyme-to-json' 5 | import getStoreStub from '../store/Customers.stub' 6 | import CustomerList from './CustomerList' 7 | // FINAL_END 8 | // WORKSHOP_START 9 | // you're going to need to import a few things here: 10 | // react, react-test-renderer, ../store/Customers.stub, 11 | // and the ./CustomerList component (which we're testing) 12 | // WORKSHOP_END 13 | 14 | test('should render no customers', () => { 15 | // FINAL_START 16 | snapshotCustomerList() 17 | // FINAL_END 18 | // WORKSHOP_START 19 | // create a snapshotCustomerList function and test the default 20 | // behavior by calling it without arguments 21 | // Then use the resulting component to check the snapshot 22 | // WORKSHOP_END 23 | }) 24 | 25 | test('should render customers', () => { 26 | // FINAL_START 27 | const {store} = getStoreStub([{name: 'Bob'}, {name: 'Joanna'}]) 28 | snapshotCustomerList({store}) 29 | // FINAL_END 30 | // WORKSHOP_START 31 | // get a store from the stub and initialize it with two customers 32 | // we need to have the component use our stub instead of the singleton store somehow... 33 | // We _could_ use Jest's mocking capabilities. Or, we could just alter the CustomerList component to allow you 34 | // to specify a store! So go to the CustomerList.js file and add a prop called `store`. Wherever the singleton 35 | // `store` is used, use `this.props.store` instead and use defaultProps to have the `store` default to the singleton 36 | // `store` (that way actual users of the component don't have to specify the store). 37 | // Now use the snapshotCustomerList function you wrote to pass the store as a prop 38 | // WORKSHOP_END 39 | }) 40 | 41 | test('should respond to store updates', () => { 42 | // FINAL_START 43 | const {store, updateCustomers} = getStoreStub() 44 | const wrapper = mountCustomerList({store}) 45 | expect(mountToJson(wrapper)).toMatchSnapshot() 46 | updateCustomers([{name: 'Jill'}, {name: 'Fred'}]) 47 | expect(mountToJson(wrapper)).toMatchSnapshot() 48 | // FINAL_END 49 | // WORKSHOP_START 50 | // get both the store and the updateCustomers from a call to `../store/Customers.stub` 51 | // render the customer list with the store stub 52 | // take a snapshot 53 | // call updateCustomers with a few customers 54 | // take another snapshot 55 | // WORKSHOP_END 56 | }) 57 | 58 | test('unsubscribe when unmounted', () => { 59 | // FINAL_START 60 | const {unsubscribe, store} = getStoreStub() 61 | const wrapper = mountCustomerList({store}) 62 | wrapper.unmount() 63 | expect(unsubscribe).toHaveBeenCalledTimes(1) 64 | // FINAL_END 65 | // WORKSHOP_START 66 | // we want to make sure that the unsubscribe function is called on the store 67 | // so get the store stub and the unsubscribe mock function from '../store/Customers.stub' 68 | // Then use enzyme's `mount` function to mount `./CustomerList` with the store stub. 69 | // Take the resulting wrapper from that `mount` and unmount it by calling `wrapper.unmount` 70 | // Then assert that the `unsubscribe` mock was called once with toHaveBeenCalledTimes(1) 71 | // WORKSHOP_END 72 | }) 73 | 74 | // FINAL_START 75 | /** 76 | * Render the and snapshot it 77 | * @param {Object} props - the props to render with 78 | */ 79 | function snapshotCustomerList(props = {}) { 80 | const wrapper = renderCustomerList(props) 81 | expect(renderToJson(wrapper)).toMatchSnapshot() 82 | } 83 | 84 | /** 85 | * Renders with the given props 86 | * @param {Object} props - the props to render with 87 | * @return {Object} the rendered component 88 | */ 89 | function renderCustomerList({store = getStoreStub().store}) { 90 | return render() 91 | } 92 | 93 | /** 94 | * Mounts with the given props 95 | * @param {Object} props - the props to mount with 96 | * @return {Object} the rendered component 97 | */ 98 | function mountCustomerList({store = getStoreStub().store}) { 99 | return mount() 100 | } 101 | // FINAL_END 102 | // WORKSHOP_START 103 | // Create a snapshotCustomerList function that: 104 | // 1. Accepts props 105 | // 2. Creates a component with those props with a call to renderer.create (tip: you may wanna do this in a separate function) 106 | // 3. Asserts on a snapshot of that component with expect(component).toMatchSnapshot() 107 | // Create a renderCustomerList function that: 108 | // 1. Accepts props and defaults the store to the store stub 109 | // 2. Returns a render the CustomerList with those propse 110 | // Create a mountCustomerList function that: 111 | // 1. Accepts props and defaults the store to the store stub 112 | // 2. Returns a mount the CustomerList with those propse 113 | // WORKSHOP_END 114 | -------------------------------------------------------------------------------- /templates/containers/README.md: -------------------------------------------------------------------------------- 1 | # CustomerList 2 | 3 | This demonstrates how to test a component that has conditional logic in its 4 | render method. More usefully, it demonstrates how you can test components 5 | that re-render based off of updates from an external data source. In this 6 | case this is a (non-flux) Customer Store. 7 | 8 | With Redux, doing this test is simpler because you don't concern yourself 9 | with state in your components and they're simply passed the state via props. 10 | 11 | In this scenario, we're depending on a "singleton" store for our application 12 | and our component's lifecycle hooks are subscribing to this store. This can 13 | cause issues with our tests potentially mucking with the state of other tests. 14 | To combat this, if we make it possible to override the store we're using (by 15 | utilizing `defaultProps`) then we can easily pass our own stubbed version of 16 | this store to avoid issues with sharing a singleton across tests and allow 17 | us to perform assertions on properties of this stubbed store. 18 | 19 | -------------------------------------------------------------------------------- /templates/jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "testPathDirs": [ 3 | // WORKSHOP_START 4 | "exercises" 5 | // WORKSHOP_END 6 | // FINAL_START 7 | "exercises-final" 8 | // FINAL_END 9 | ], 10 | "coverageThreshold": { 11 | "global": { 12 | "branches": 100, 13 | "functions": 95, 14 | "lines": 100, 15 | "statements": 100 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /templates/store/Customers.js: -------------------------------------------------------------------------------- 1 | let _customers = [] 2 | const callbacks = [] 3 | 4 | export default { 5 | getCustomers, 6 | setCustomers, 7 | subscribe, 8 | } 9 | 10 | /** 11 | * Returns the current list of customers 12 | * @return {Array} customers 13 | */ 14 | function getCustomers() { 15 | return _customers 16 | } 17 | 18 | /** 19 | * Sets the current list of customers to the given customers 20 | * and lets all the subscribers know about the update 21 | * @param {Array} customers - An array of objects that have a name property that is a string 22 | */ 23 | function setCustomers(customers) { 24 | _customers = customers 25 | _letSubscribersKnow() 26 | } 27 | 28 | /** 29 | * Adds the given callback to a list of functions to be called when the current customers are set 30 | * @param {Function} callback - the callback to be called 31 | * @return {Function} - a function to call to unsubscribe 32 | */ 33 | function subscribe(callback) { 34 | callbacks.push(callback) 35 | return function removeCallback() { 36 | callbacks.splice(callbacks.indexOf(callback), 1) 37 | } 38 | } 39 | 40 | /** 41 | * Iterates through all callbacks and calls them 42 | */ 43 | function _letSubscribersKnow() { 44 | callbacks.forEach(cb => cb()) 45 | } 46 | -------------------------------------------------------------------------------- /templates/store/Customers.stub.js: -------------------------------------------------------------------------------- 1 | export default getStoreStub 2 | 3 | /** 4 | * Create a stub for the store which can be used for assertions 5 | * @param {Array} customers - the array of customers 6 | * @returns {Object} - ref property has customers and will haf ref.callback when 7 | * store.callback is invoked. store.getCustomers will return ref.customers 8 | */ 9 | function getStoreStub(customers = []) { 10 | let callback 11 | const unsubscribe = jest.fn() 12 | const ref = {customers} 13 | 14 | const store = { 15 | getCustomers: () => ref.customers, 16 | subscribe: cb => { 17 | callback = cb 18 | return unsubscribe 19 | }, 20 | } 21 | return {unsubscribe, store, updateCustomers} 22 | 23 | function updateCustomers(newCustomers) { 24 | ref.customers = newCustomers 25 | callback() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /templates/store/Customers.test.js: -------------------------------------------------------------------------------- 1 | test('should start with empty', () => { 2 | // FINAL_START 3 | const {store} = setup() 4 | const customers = store.getCustomers() 5 | expect(customers.length).toBe(0) 6 | // FINAL_END 7 | // WORKSHOP_START 8 | // get the store from your setup function 9 | // call getCustomers on it 10 | // assert that the lenth of customers is 0 11 | // WORKSHOP_END 12 | }) 13 | 14 | test('should allow you to set customers and get them', () => { 15 | // FINAL_START 16 | const {store} = setup() 17 | const c0 = {name: 'Bill'} 18 | const c1 = {name: 'Francine'} 19 | store.setCustomers([c0, c1]) 20 | const customers = store.getCustomers() 21 | const [sc0, sc1] = customers 22 | expect(customers.length).toBe(2) 23 | expect(c0).toBe(sc0) 24 | expect(c1).toBe(sc1) 25 | // FINAL_END 26 | // WORKSHOP_START 27 | // get the store 28 | // create two customers and set the store to them 29 | // get the customers from the store 30 | // assert that there are two customers 31 | // assert that the customers you got are the ones you set 32 | // WORKSHOP_END 33 | }) 34 | 35 | test('should allow you to subscribe to the store', () => { 36 | // FINAL_START 37 | const {store} = setup() 38 | const subscriber = jest.fn() 39 | const unsubscribe = store.subscribe(subscriber) 40 | store.setCustomers([]) 41 | expect(subscriber).toHaveBeenCalledTimes(1) 42 | subscriber.mockClear() 43 | unsubscribe() 44 | store.setCustomers([]) 45 | expect(subscriber).not.toBeCalled() 46 | // FINAL_END 47 | // WORKSHOP_START 48 | // get the store 49 | // setup a jest mock function (jest.fn()) for your subscriber 50 | // subscribe to the store with that function 51 | // call setCustomers 52 | // assert your subscriber was called once 53 | // clear your subscriber mock function (subscriber.mockClear()) 54 | // call the unsubscribe function you got when subscribing 55 | // call setCustomers 56 | // assert that your mock function was not called 57 | // WORKSHOP_END 58 | }) 59 | 60 | // FINAL_START 61 | /** 62 | * Prepares our environment for an individual test and returns whatever is needed for that test to run. 63 | * @return {Object} what is needed for tests to run. In this case it is only a fresh copy of the store 64 | */ 65 | function setup() { 66 | // clear the require cache so when we require the store we get a fresh copy 67 | jest.resetModules() 68 | const store = require('./Customers').default 69 | return {store} 70 | } 71 | // FINAL_END 72 | // WORKSHOP_START 73 | // Create a `setup` function: 74 | // clear the require cache with jest.resetModules() so you can require a fresh copy of the store 75 | // require the ./Customers module (note: because it's using `export default`, 76 | // the store is on the `default` property of what you're requiring) 77 | // return {store} 78 | // WORKSHOP_END 79 | -------------------------------------------------------------------------------- /templates/store/README.md: -------------------------------------------------------------------------------- 1 | # Old-school 2 | 3 | Not all applications use Redux 😞. I hacked together this (non-flux) Customers store 4 | to pretty much demonstrate how you test dealing with updates that happen to your 5 | components when they're updated from an external source of data (like a store). 6 | 7 | I don't recommend you write your stores this way. 8 | 9 | This store is tested, but there's not really much going on in here... 10 | --------------------------------------------------------------------------------