Share
Embed
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.
React Amsterdam
April 21, 2016
Transcript
89 slides
-
— confidence — permanently fix bugs — provide support for
refactoring — enables teams to move quicker -
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 :) -
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 }); }); }); -
Running the tests npm install --save-dev babel-tape-runner A runner for
Tape tests that will first run them through Babel. -
> 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 -
State is held in <Todos />, and other components notify
<Todos /> when data has changed. -
state-functions.js export function toggleDone(state, id) { ... } export function
addTodo(state, todo) { ... } export function deleteTodo(state, id) { ... } -
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 }); }); }); -
class Todo extends React.Component { ... } Todo.propTypes = {
todo: React.PropTypes.object.isRequired, doneChange: React.PropTypes.func.isRequired, deleteTodo: React.PropTypes.func.isRequired } -
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> ) } } -
— 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. -
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 :) -
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(); } -
{ "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) -
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'); }); }); }); -
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); }); }); }); -
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. -
DOM To test interactions we need a DOM. Enter jsdom!
npm install --save-dev jsdom -
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(); -
This has to be imported before React. import './setup'; import
React from 'react'; // and so on... -
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 }); -
TestUtils.renderIntoDocument(...) Render a component into a detached DOM node in
the document. [https://facebook.github.io/react/docs/test-utils.html] -
Firstly, find the component on the page that we want
to click on. const todoText = TestUtils.findRenderedDOMComponentWithTag(result, 'p'); -
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); }); -
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. -
The ideal test: test('descriptive name here', (t) => { //
setup // interaction / invoke the thing // assert on the results }); -
We've got it backwards! test('descriptive name here', (t) => {
// assert on the results // setup // interaction / invoke the thing }); -
github.com/jackfrankin/doubler npm install --save-dev doubler You might prefer Sinon, TestDouble.js,
the library doesn't matter :) -
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] ]; -
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); }): -
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> ) } } -
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} /> ); ... }); }); -
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); -
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' }); }); }); -
This component encapsulates most of our application, so the tests
we'll write will be more akin to integration tests. -
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> ) } } -
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? }); -
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 -
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); }); -
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. -
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. -
import { shallow } from 'enzyme'; function shallowRenderTodo(todo) { return
shallow(<Todo todo={todo} ... />); } -
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 -
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 -
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. -
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' -
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. -
Blog post: http://12devsofxmas.co.uk/2015/12/day-2- testing-react-applications/ Repo with Tests: https://github.com/jackfranklin/todo- react-testing Me:
@Jack_Franklin, javascriptplayground.com