Jack Franklin, Pusher, London — Testing React Applications

Jack Franklin, Pusher, London — Testing React Applications

Jack Franklin, Pusher, London — Testing React Applications

We'll discuss the best tooling and approaches for building ReactJS applications in the NodeJS environment and in the browser using React TestUtils and other third party libraries that make it easy to test your components. We'll see how to build components in a way that makes them more maintainable, testable and easier to work with.

5799a3c0434b91ef7e00e730629390f0?s=128

React Amsterdam

April 21, 2016
Tweet

Transcript

  1. 2.
  2. 4.
  3. 5.
  4. 6.

    — confidence — permanently fix bugs — provide support for

    refactoring — enables teams to move quicker
  5. 7.
  6. 9.

    Testing Libraries I've taken to using Tape [https://github.com/substack/ tape], but

    you can use whichever framework you'd like. This talk is test framework agnostic :)
  7. 10.

    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 }); }); });
  8. 11.

    Running the tests npm install --save-dev babel-tape-runner A runner for

    Tape tests that will first run them through Babel.
  9. 12.

    > babel-tape-runner 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
  10. 15.
  11. 16.
  12. 17.
  13. 18.
  14. 23.

    state-functions.js export function toggleDone(state, id) { ... } export function

    addTodo(state, todo) { ... } export function deleteTodo(state, id) { ... }
  15. 24.

    Testing these are easy and require no set up at

    all! 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 }); }); });
  16. 26.
  17. 27.

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

    todo: React.PropTypes.object.isRequired, doneChange: React.PropTypes.func.isRequired, deleteTodo: React.PropTypes.func.isRequired }
  18. 28.

    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> ) } }
  19. 29.

    — 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.
  20. 31.

    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 :)
  21. 32.

    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(); }
  22. 33.

    { "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)
  23. 34.

    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'); }); }); });
  24. 35.

    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); }); }); });
  25. 37.

    React components will often have some state (like our todos)

    that users can edit. You should never directly call React component methods in tests. Your input should be user interaction, and the output that's tested should be the rendered component.
  26. 38.
  27. 39.

    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();
  28. 40.
  29. 42.

    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 });
  30. 43.

    TestUtils.renderIntoDocument(...) Render a component into a detached DOM node in

    the document. [https://facebook.github.io/react/docs/test-utils.html]
  31. 44.

    Firstly, find the component on the page that we want

    to click on. const todoText = TestUtils.findRenderedDOMComponentWithTag(result, 'p');
  32. 46.

    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); });
  33. 47.

    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.
  34. 48.

    The ideal test: test('descriptive name here', (t) => { //

    setup // interaction / invoke the thing // assert on the results });
  35. 49.

    We've got it backwards! test('descriptive name here', (t) => {

    // assert on the results // setup // interaction / invoke the thing });
  36. 50.
  37. 53.

    A double is just a function that keeps track of

    its calls: import { Double } from 'doubler'; const x = Double.function(); x(2); x(3); x.callCount === 2; x.args === [ [2], [3] ];
  38. 54.

    import { Double } from 'doubler'; 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 = mount( <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); }):
  39. 56.

    import React, { Component } from 'react'; 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> ) } }
  40. 57.

    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} /> ); ... }); });
  41. 58.

    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);
  42. 60.

    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' }); }); });
  43. 62.

    This component encapsulates most of our application, so the tests

    we'll write will be more akin to integration tests.
  44. 63.

    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> ) } }
  45. 65.

    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? });
  46. 67.

    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
  47. 68.

    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); });
  48. 71.

    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.
  49. 72.

    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.
  50. 76.

    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
  51. 79.

    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
  52. 81.

    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.
  53. 82.

    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'
  54. 84.

    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.
  55. 85.
  56. 88.
  57. 89.