$30 off During Our Annual Pro Sale. View Details »

Testing React Applications: React Amsterdam 2016

Testing React Applications: React Amsterdam 2016

A talk on testing React applications.

Jack Franklin

April 16, 2016
Tweet

More Decks by Jack Franklin

Other Decks in Technology

Transcript

  1. Testing our code gives us: • confidence • permanently fix

    bugs by fixing them with a test • provide support for refactoring • enables teams to move quicker 5
  2. JavaScript Testing Libraries I prefer Tape [https://github.com/substack/tape], but there's also:

    • Mocha • Jasmine • Jest • ... This talk is test framework agnostic :) 7
  3. ES2015 + Babel Arrow functions: (t) => {...} function(t) {...}

    Imports: import foo from 'bar' ; var foo = require('bar'); import { foo } from 'bar'; var foo = require('bar').foo; 8
  4. import test from 'tape'; test('Adding numbers', (t) => { t.test('2

    + 2 is 4', (t) => { t.plan(1); t.equal(2 + 2, 4); }); t.test('some objects are equal', (t) => { t.plan(1); const obj = { a: 1 }; t.deepEqual(obj, { a: 1 }); }); }); 9
  5. • Tell tape how many tests you're expecting with t.plan(x)

    • Using t.plan means Tape has good async support out the box • Use t.equal, t.ok, t.deepEqual (and others) • Has good support for Babel • Fairly minimal :) 10
  6. Running the tests We'll use babel-register to hook Babel into

    our tests. npm install --save-dev babel-register 11
  7. > tape -r babel-register test/*-test.js TAP version 13 # Adding

    numbers # 2 + 2 is 4 ok 1 should be equal # some objects are equal ok 2 should be equivalent 1..2 # tests 2 # pass 2 # ok 13
  8. Enter Faucet npm install --save-dev faucet "scripts": { "test": "tape

    -r babel-register test/*-test.js | faucet" }, There are many, many options here for this! 15
  9. 16

  10. 18

  11. 19

  12. 21

  13. 22

  14. 23

  15. State is held in <Todos />, and other components notify

    <Todos /> when data has changed. 24
  16. state-functions.js export function toggleDone(state, id) { ... } export function

    addTodo(state, todo) { ... } export function deleteTodo(state, id) { ... } (See also: Redux and related projects) 29
  17. Testing these are easy test('addTodo', (t) => { t.test('it can

    add a new todo and set the right id', (t) => { t.plan(1); const initialState = { todos: [{ id: 1, name: 'Buy Milk', done: true }] }; const newState = addTodo(initialState, { name: 'Get bread' }); t.deepEqual(newState.todos[1], { name: 'Get bread', id: 2, done: false }); }); }); 30
  18. 32

  19. class Todo extends React.Component { ... } Todo.propTypes = {

    todo: React.PropTypes.object.isRequired, doneChange: React.PropTypes.func.isRequired, deleteTodo: React.PropTypes.func.isRequired } 33
  20. import React, { Component } from 'react'; export default class

    Todo extends Component { toggleDone() { this.props.doneChange(this.props.todo.id); } deleteTodo(e) { e.preventDefault(); this.props.deleteTodo(this.props.todo.id); } render() { const { todo } = this.props; const className = todo.done ? 'done-todo' : ''; return ( <div className={`todo ${className} todo-${todo.id}`}> <p className="toggle-todo" onClick={() => this.toggleDone() }>{ todo.name }</p> <a className="delete-todo" href="#" onClick={(e) => this.deleteTodo(e) }>Delete</a> </div> ) } } 34
  21. • The rendered div is given a class of done-todo

    if the todo is done. • Deleting a todo calls this.props.deleteTodo with the right ID. • Clicking on a todo calls this.props.toggleDone with the right ID. 35
  22. Shallow Rendering Test without a DOM; we don't actually render

    components but get a representation of what the rendered component will look like. Keeps tests nice and quick :) 37
  23. todo-test.js import React from 'react'; import Todo from '../app/todo'; import

    TestUtils from 'react-addons-test-utils'; import test from 'tape'; function shallowRenderTodo(todo) { const renderer = TestUtils.createRenderer(); const fn = () => {}; renderer.render( <Todo todo={todo} doneChange={fn} deleteTodo={fn}/> ); return renderer.getRenderOutput(); } 38
  24. { "type": "div", "props": { "className": "todo todo-1", "children": [

    { "type": "p", "props": { "className": "toggle-todo", "children": "Buy Milk" }, }, { "type": "a", "props": { "className": "delete-todo", "href": "#", "children": "Delete" }, } ] }, } (Some items removed to save space) 39
  25. test('Todo component', (t) => { t.test('rendering a not-done tweet', (t)

    => { const todo = { id: 1, name: 'Buy Milk', done: false }; const result = shallowRenderTodo(todo); t.test('It renders the text of the todo', (t) => { t.plan(1); t.equal(result.props.children[0].props.children, 'Buy Milk'); }); }); }); 40
  26. test('Todo component', (t) => { // other tests omitted t.test('rendering

    a done tweet', (t) => { const todo = { id: 1, name: 'Buy Milk', done: true }; const result = shallowRenderTodo(todo); t.test('The todo does have the done class', (t) => { t.plan(1); t.ok(result.props.className.indexOf('done-todo') > -1); }); }); }); 41
  27. React components will often have some state (like our todos)

    that users can edit. Input: User Actions (clicks, form fills, etc) Output: Rendered React components Never reach into a component to get or set state. 43
  28. import jsdom from 'jsdom'; function setupDom() { if (typeof document

    === 'undefined') { global.document = jsdom.jsdom('<html><body></body></html>'); global.window = document.defaultView; global.navigator = window.navigator; } } setupDom(); 46
  29. Rendering a component for testing TestUtils.renderIntoDocument(...) Render a component into

    a detached DOM node in the document. [https://facebook.github.io/react/docs/test- utils.html] 49
  30. t.test('toggling a TODO calls the given prop', (t) => {

    t.plan(1); // our assertion is made in the doneCallback fn // so when the click is made, it will be run const doneCallback = (id) => t.equal(id, 1); const todo = { id: 1, name: 'Buy Milk', done: false }; const result = TestUtils.renderIntoDocument( <Todo todo={todo} doneChange={doneCallback} /> ); // simulate the click here }); 50
  31. Firstly, find the component on the page that we want

    to click on. const todoText = TestUtils.findRenderedDOMComponentWithTag(result, 'p'); 51
  32. t.test('toggling a TODO calls the given prop', (t) => {

    t.plan(1); const doneCallback = (id) => t.equal(id, 1); const todo = { id: 1, name: 'Buy Milk', done: false }; const result = TestUtils.renderIntoDocument( <Todo todo={todo} doneChange={doneCallback} /> ); const todoText = TestUtils.findRenderedDOMComponentWithTag(result, 'p'); TestUtils.Simulate.click(todoText); }); 53
  33. t.test('toggling a TODO calls the given prop', (t) => {

    t.plan(1); const doneCallback = (id) => t.equal(id, 1); const todo = { id: 1, name: 'Buy Milk', done: false }; const result = TestUtils.renderIntoDocument( <Todo todo={todo} doneChange={doneCallback} /> ); const todoText = TestUtils.findRenderedDOMComponentWithTag(result, 'p'); TestUtils.Simulate.click(todoText); }); Some people don't like this callback approach to testing. 54
  34. The ideal test: test('descriptive name here', (t) => { //

    setup // interaction / invoke the thing // assert on the results }); 55
  35. We've got it backwards! test('descriptive name here', (t) => {

    // assert on the results // setup // interaction / invoke the thing }); 56
  36. A double is just a function that keeps track of

    its calls: import { Double } from 'doubler'; const x = Double.function(); x('react'); x('amsterdam'); x.callCount === 2; x.args === [ ['react'], ['amsterdam'] ]; 59
  37. t.test('toggling a TODO calls the given prop', (t) => {

    t.plan(2); const doneCallback = Double.function(); const todo = { id: 1, name: 'Buy Milk', done: false }; const result = TestUtils.renderIntoDocument( <Todo todo={todo} doneChange={doneCallback} /> ); const todoText = TestUtils.findRenderedDOMComponentWithTag(result, 'p'); TestUtils.Simulate.click(todoText); // assertions }): 60
  38. t.test('toggling a TODO calls the given prop', (t) => {

    t.plan(2); const doneCallback = Double.function(); const todo = { id: 1, name: 'Buy Milk', done: false }; const result = TestUtils.renderIntoDocument( <Todo todo={todo} doneChange={doneCallback} /> ); const todoText = TestUtils.findRenderedDOMComponentWithTag(result, 'p'); TestUtils.Simulate.click(todoText); t.equal(doneCallback.callCount, 1); t.equal(doneCallback.args[0][0], 1); }): 62
  39. The ideal test: test('descriptive name here', (t) => { //

    setup // interaction / invoke the thing // assert on the results }); 63
  40. class AddTodo extends Component { ... } AddTodo.propTypes = {

    onNewTodo: React.PropTypes.func.isRequired } 65
  41. export default class AddTodo extends Component { addTodo(e) { e.preventDefault();

    const newTodoName = this.refs.todoTitle.value; if (newTodoName) { this.props.onNewTodo({ name: newTodoName }); this.refs.todoTitle.value = ''; } } render() { return ( <div className="add-todo"> <input type="text" placeholder="Walk the dog" ref="todoTitle" /> <button onClick={(e) => this.addTodo(e) }>Add Todo</button> </div> ) } } 66
  42. We first render AddTodo, giving it a double as a

    callback: test('Add Todo component', (t) => { t.test('it calls the given callback prop with the new text', (t) => { t.plan(2); const todoCallback = Double.function(); const form = TestUtils.renderIntoDocument( <AddTodo onNewTodo={todoCallback} /> ); ... }); }); 67
  43. Then we can find and fill out the form input:

    const input = TestUtils.findRenderedDOMComponentWithTag(form, 'input'); input.value = 'Buy Milk'; Before then clicking a button: const button = TestUtils.findRenderedDOMComponentWithTag(form, 'button'); TestUtils.Simulate.click(button); 68
  44. test('Add Todo component', (t) => { t.test('it calls the given

    callback prop with the new text', (t) => { t.plan(2); const todoCallback = Double.function(); const form = TestUtils.renderIntoDocument( <AddTodo onNewTodo={todoCallback} /> ); const input = TestUtils.findRenderedDOMComponentWithTag(form, 'input'); input.value = 'Buy Milk'; const button = TestUtils.findRenderedDOMComponentWithTag(form, 'button'); TestUtils.Simulate.click(button); t.equal(todoCallback.callCount, 1); t.deepEqual(todoCallback.args[0][0], { name: 'Buy Milk' }); }); }); 70
  45. This component encapsulates most of our application, so the tests

    we'll write will be more akin to integration tests. 72
  46. export default class Todos extends Component { constructor(props) {...} toggleDone(id)

    {...} addTodo(todo) {...} deleteTodo(id) {...} renderTodos() { return this.state.todos.map((todo) => { return ( <li key={todo.id}> <Todo todo={todo} doneChange={(id) => this.toggleDone(id)} deleteTodo={(id) => this.deleteTodo(id)} /> </li> ); }); } render() { return ( <div> <p>The <em>best</em> todo app out there.</p> <h1>Things to get done:</h1> <ul className="todos-list">{ this.renderTodos() }</ul> <AddTodo onNewTodo={(todo) => this.addTodo(todo)} /> </div> ) } } 73
  47. t.test('Adding a todo', (t) => { t.plan(1); const result =

    TestUtils.renderIntoDocument(<Todos />); const formInput = TestUtils.findRenderedDOMComponentWithTag(result, 'input'); formInput.value = 'Buy Milk'; const button = TestUtils.findRenderedDOMComponentWithTag(result, 'button'); TestUtils.Simulate.click(button); // what assertion to make? }); 75
  48. scryRenderedDOMComponentsWithClass finds all components with the given class, and returns

    an array. Scrying (also called seeing or peeping) is the practice of looking into a translucent ball 77
  49. t.test('Adding a todo', (t) => { t.plan(1); const result =

    TestUtils.renderIntoDocument(<Todos />); const formInput = TestUtils.findRenderedDOMComponentWithTag(result, 'input'); formInput.value = 'Buy Milk'; const button = TestUtils.findRenderedDOMComponentWithTag(result, 'button'); TestUtils.Simulate.click(button); const todos = TestUtils.scryRenderedDOMComponentsWithClass(result, 'todo'); t.equal(todos.length, 4); }); 78
  50. Enzyme is a JavaScript Testing utility for React that makes

    it easier to assert, manipulate, and traverse your React Components' output. Enzyme's API is meant to be intuitive and flexible by mimicking jQuery's API for DOM manipulation and traversal. 81
  51. Enzyme is unopinionated regarding which test runner or assertion library

    you use, and should be compatible with all major test runners and assertion libraries out there. 82
  52. t.test('rendering a not-done tweet', (t) => { const todo =

    { id: 1, name: 'Buy Milk', done: false }; const result = shallowRenderTodo(todo); t.test('It renders the text of the todo', (t) => { t.plan(1); t.equal(result.find('p').text(), 'Buy Milk'); }); }); Shallow rendering API docs: http://airbnb.io/ enzyme/docs/api/shallow.html 86
  53. test('Add Todo component', (t) => { t.test('it calls the given

    callback prop with the new text', (t) => { t.plan(2); const todoCallback = Double.function(); const form = mount(<AddTodo onNewTodo={todoCallback} />); const input = form.find('input').get(0); input.value = 'Buy Milk'; form.find('button').simulate('click'); t.equal(todoCallback.callCount, 1); t.deepEqual(todoCallback.args[0][0], { name: 'Buy Milk' }); }); }); Full rendering API docs: http://airbnb.io/enzyme/ docs/api/mount.html 89
  54. In development it's nice to be able to run in

    the terminal, but before deploying you should run your tests across all the browsers you support. 91
  55. First, build a bundle of all your tests using Browserify

    / Webpack / your tool of choice. browserify test/*-test.js \ -t [ babelify --presets [ es2015 react ] ] \ -u 'react/lib/ReactContext' \ -u 'react/lib/ExecutionEnvironment' 92
  56. Use tape-run to run the tests in an actual browser.

    (Other libraries have similar tools available for this). 93
  57. browserify test/*-test.js \ -t [ babelify --presets [ es2015 react

    ] ] \ -u 'react/lib/ReactContext' \ -u 'react/lib/ExecutionEnvironment' \ | tape-run -b chrome Tests are run in an actual browser instance; results are sent back to the terminal. 94
  58. 95

  59. 96

  60. 100