DenverScript: Unit Testing React Components

Ken Hoff, mailto:[email protected]

2017-01-24

Hi! I'm Ken.

A little about me:

Tonight's agenda:

Things that you should already know

Things that we're going to learn tonight

Things that we're not going to focus on tonight

Unit Testing React Components, summed up:

(from the Enzyme docs)

"Full DOM rendering is ideal for use cases where you have components that may interact with DOM APIs, or may require the full lifecycle in order to fully test the component (i.e., componentDidMount etc.)

Full DOM rendering requires that a full DOM API be available at the global scope. This means that it must be run in an environment that at least "looks like" a browser environment. If you do not want to run your tests inside of a browser, the recommended approach to using mount is to depend on a library called jsdom which is essentially a headless browser implemented completely in JS."

(emphasis mine)

You'll find that most of unit testing React components is just an exercise in managing global scope.

You can unit test any kind of frontend JavaScript using the same principles. (React and other frameworks usually come with some utilities to make it a little bit easier.)

A simple TodoList app

You can find all of the code for this presentation at https://github.com/kenhoff/unit-testing-react-components

First, let's get our index.html and webpack config set up:

<!DOCTYPE html>
<html>

<head>
</head>

<body>
    <div id="app">
        This is where our app goes!
    </div>
    <!-- our app script needs to go after #app, so that it can mount the react component there  -->
    <script src="./build/app.js" charset="utf-8"></script>
</body>

</html>
// webpack.config.js

module.exports = {
    entry: './src/app.jsx',
    output: {
        filename: './build/app.js'
    },
    module: {
        loaders: [{
            test: /\.jsx/,
            exclude: /(node_modules|bower_components)/,
            loader: 'babel-loader',
            query: {
                presets: ['es2015', 'react']
            }
        }]
    }
}

Rendering TodoList onto the browser page - the bundle entry point

Then, let's create the entry point for our bundle, that renders the TodoList onto the page.

We're keeping the component separate from the code that mounts the component onto the page - so that we can test the component later, without rendering the component using ReactDOM.

// src/app.jsx

var React = require("react")
var ReactDOM = require("react-dom");

var TodoListReactComponent = require("./TodoListReactComponent.jsx")

ReactDOM.render((
    <TodoListReactComponent></TodoListReactComponent>
), document.getElementById('app'));

The actual TodoList component

(I've tried to keep it simple by using ES5 features only, but I'll use ES6 arrow functions where it makes sense)

// src/TodoListReactComponent.jsx

var React = require("react");

var TodoListReactComponent = React.createClass({
    getInitialState: function() {
        return {todos: [], todoInput: ""};
    },
    render: function () {
        return (
            <div>
                <h1>Your todos:</h1>
                <ul>
                    {this.state.todos.map((todo) => {
                        return (
                            <li key={Math.random()}>
                                <span>{todo}</span>
                                <button onClick={(e) => {
                                    var newTodos = this.state.todos.slice();
                                    newTodos.splice(newTodos.indexOf(todo), 1);
                                    this.setState({todos: newTodos});
                                }}>remove todo</button>
                            </li>
                        )
                    })}
                </ul>
                <form ref="newTodoForm" onSubmit={(e) => {
                    e.preventDefault();
                    var newTodos = this.state.todos.slice();
                    newTodos.push(this.state.todoInput);
                    this.setState({todos: newTodos, todoInput: ""});
                }}>
                    <input ref="newTodoField" onChange={(e) => {
                        this.setState({todoInput: e.target.value});
                    }} value={this.state.todoInput}></input>
                    <input type="submit" value="Add Todo"></input>
                </form>
            </div>
        );
        // a couple notes -
        // when the input text box is changed, change the state to reflect the new change. you _can_ use uncontrolled inputs, but in almost every situation, I've been much happier using controlled inputs. (check out https://facebook.github.io/react/docs/forms.html and https://facebook.github.io/react/docs/uncontrolled-components.html for more info.)
        // have to create a new array for the todos, push the new one on, then set the state with the new todos. have to treat existing state as immutable!
        // using a form instead of just inputs and buttons. builtin support for hitting enter, and works better with mobile web keyboards! however, we have to use e.preventDefault() to prevent the browser from navigating to a new page.
        // ES2015 gives us all sorts of fun things, like class definitions and arrow functions, but I'm trying to limit their use here. generally, I'll use an arrow function wherever I want to avoid using `.bind(this)` on a function.
    }
});

module.exports = TodoListReactComponent;

Does it work?

Let's find out!

(this is the part where you hook all of the code together, and find out if you have a simple React TodoList that works as expected)

Hooray! It works!

(hopefully)

Next: testing our TodoList component

If this was pure JS class, without any connections to the browser API (like document.createElement), this would be pretty straightforward - we'd just require(./TodoList.js), call some methods and use some assert calls.

(but this isn't a pure JS class - it's a React component, which has a lot of React function calls, which in turn call the browser API a lot)

In the days of Ye Olde Web 2.0 Application Development, you might have had a full testing pipeline set up with Selenium and PhantomJS. You'd have to run the PhantomJS binary, which would listen on a port that Selenium would connect to, and then you'd make calls to Selenium using a driver, and that would get the browser to query and manipulate the page.

(I know it's possible to do, but in my personal experience, I've never been able to get it to work correctly. Getting PhantomJS and Selenium to play nicely consistently requires just a bit more sysadmin knowledge than I currently have.)

So! What do we do?

Creating a fake browser API

When a React component mounts, renders, and updates, it calls the browser API - a bunch of methods attached to the window.document element in the browser's global scope. Things like document.createElement() and document.getElementsByTagName().

So, if we can "fake" ("stubbing", in testing lingo) the document.createElement method, we can theoretically render our TodoList element into the browser - React can call the document.createElement method, and it won't throw an error.

(Of course, React calls other methods besides document.createElement - we would need to stub out all the other methods too.)

And, of course, we'd want to be sure that after calling those methods (document.createElement), that calls to other methods (for example, document.getElementsByTagName) returns back the right information.

Essentially, this requires us to build a DOM, along with all of the API methods to manipulate it. Which is basically like building a full browser! And I don't get paid enough (yet) to do that!

Enter JSDOM

JSDOM stands for JavaScript Document Object Model. It's basically a 1:1 representation of a DOM, plus the full browser API to back - we can make calls to document.createElement, then use document.getElementsByTagName to get those new elements back.

JSDOM doesn't render anything! It's written entirely in JavaScript, which means that it's trivial to download from npm and run just as you would any other JavaScript file, with node.

Setting up JSDOM

In our new test directory:

// test/setupJSDOM.js
// liberally borrowed from http://jaketrent.com/post/testing-react-with-jsdom/

console.log("Setting up jsdom...");
var jsdom = require('jsdom');
global.document = jsdom.jsdom('<!doctype html><html><body></body></html>');
global.window = document.defaultView;
global.window.localStorage = {
    getItem: function() {},
    setItem: function() {}
};

// take all properties of the window object and also attach it to the global object
// (like being able to call `document` on its own, without using `window.document`)
propagateToGlobal(global.window);

// from mocha-jsdom https://github.com/rstacruz/mocha-jsdom/blob/master/index.js#L80
function propagateToGlobal(window) {
    for (var key in window) {
        if (!window.hasOwnProperty(key)) continue;
        if (key in global) continue;

        global[key] = window[key];
    }
}

console.log("Done setting up jsdom");

Testing with Mocha

Running mocha by default runs all files and tests in the test directory.

> mocha
Setting up jsdom...
Done setting up jsdom


  0 passing (0ms)

We don't have any tests yet, and that's expected. But if we were to try to console.log out document or some of its methods, we would see roughly the same stuff that we would see in a browser!

Bundling up our tests and running it in JSDOM

Just like our normal app, we need to render our TodoList component into the synthetic DOM.

Normally I'd use ReactDOM.render(), but ReactTestUtils gives us a bunch of fun methods that make it easier to test React components. Neat!

In test-jsx, I'm going to create the entry point for my test bundle, and use ReactTestUtils.renderIntoDocument().

// test-jsx/TodoListReactComponent.jsx

const React = require("react");
const ReactTestUtils = require("react-addons-test-utils");
const assert = require("assert");

const TodoListReactComponent = require("../src/TodoListReactComponent.jsx");

let renderedComponent;

define("tests for our TodoList component", function() {
    console.log("Rendering component into DOM...");
    renderedComponent = ReactTestUtils.renderIntoDocument(
        <TodoListReactComponent></TodoListReactComponent>
    );
    console.log("Done rendering component into DOM");
})

We'll also need a second webpack config, just for our test suite. (The horror!)

// webpack.config.js

module.exports = {
    entry: './test-jsx/TodoListReactComponent.test.jsx',
    output: {
        filename: './test/test-bundle.js'
    },
    module: {
        loaders: [{
            test: /\.jsx/,
            exclude: /(node_modules|bower_components)/,
            loader: 'babel-loader',
            query: {
                presets: ['react', 'es2015']
            }
        }]
    }
}

(it may be possible to generate both the app and test bundle with a single webpack config, but I'll leave that as an exercise to the reader.)

Does it work?

Let's find out!

Build the test bundle with webpack --config webpack.test.config.js, and run the tests with mocha

(but Ken, what about race conditions?)

Hooray! It works!

If you see something like this, you're doing it right:

mocha -w test

Setting up jsdom...
Done setting up jsdom
Rendering component into DOM...
Done rendering component into DOM


  0 passing (1ms)

Querying the synthetic DOM to get information about what's been rendered

Now that we've rendered our component into our DOM, we can query it to make sure that the right stuff has been rendered.

I'd recommend using the ReactTestUtils methods for this kind of stuff - you can even query by React Component types, not just tags and class names!

it("renders an h1 with \"Your todos:\"", function() {
    assert.equal(ReactTestUtils.findRenderedDOMComponentWithTag(renderedComponent, "h1").innerHTML, "Your todos:");
})

(Note: You actually have to use ReactTestUtils. For some reason, I've never been able to get document.getElementsByTagName to work. If you think you know why and you want to show off your JS kung fu, give it a shot during the questions section!)

Sending clicks and keypresses into your rendered component

Next, we want to make sure that any interaction with the component results in the proper manipulation of the component:

it("adds a new todolist item", function() {
    // using refs to get/set underlying HTML elements. Not a fan - probably possible to work around it

    let newTodoField = renderedComponent.refs.newTodoField;
    newTodoField.value = "This is a new todolist item!"
    ReactTestUtils.Simulate.change(newTodoField);

    let newTodoForm = renderedComponent.refs.newTodoForm
    ReactTestUtils.Simulate.submit(newTodoForm);
    let todoListItems = ReactTestUtils.scryRenderedDOMComponentsWithTag(renderedComponent, "li")
    assert.equal(todoListItems.length, 1);
    assert.equal(todoListItems[0].innerHTML, "<span>This is a new todolist item!</span><button>remove todo</button>");
})

Stubbing, Spying, and Mocking out functions and other components

In true Stack Overflow fashion, the biggest question is "how do I do this with jQuery?"

First, let's have our TodoList check the weather when it mounts, so that we can add a "bring a jacket" todo if necessary. We'll also install jQuery via CDN.

componentDidMount: function() {
    $.ajax("http://api.openweathermap.org/data/2.5/weather?zip=80206,us&appid=d7080b0bfa9ef49bad4a168e685ed2f0").done((data) => {
        if (data.main.temp < 288.706) { // because kelvin!
            let newTodos = this.state.todos.slice()
            newTodos.push("Bring a jacket!")
            this.setState({todos: newTodos});
        }
    })
}
<script src="https://code.jquery.com/jquery-3.1.1.min.js"></script>

This works great when manually testing in the browser, but it breaks our JSDOM tests :(

ReferenceError: $ is not defined
    at componentDidMount (/Users/ken/dev/unit-testing-react-components/test/test-bundle.js:23290:4)
    at /Users/ken/dev/unit-testing-react-components/test/test-bundle.js:15764:26
    (etc)

Stubbing, Spying, and Mocking out functions and other components

We're going to switch up our testing file, so that we can isolate each one of our tests, and provide an initial stubbing of jQuery:

// test-jsx/TodoListReactComponent.jsx

const React = require("react");
const ReactTestUtils = require("react-addons-test-utils");
const assert = require("assert");

const TodoListReactComponent = require("../src/TodoListReactComponent.jsx");

let renderedComponent;

define("tests for our TodoList component", function() {
    beforeEach(function() {
        $ = {
            ajax: function() {
                return {
                    done: function(cb) {
                        cb({
                            main: {
                                temp: 0
                            }
                        })
                    }
                }
            }
        }

        renderedComponent = ReactTestUtils.renderIntoDocument(
            <TodoListReactComponent></TodoListReactComponent>
        );
    })
    it("renders an h1 with \"Your todos:\"", function() {
        assert.equal(ReactTestUtils.findRenderedDOMComponentWithTag(renderedComponent, "h1").innerHTML, "Your todos:");
    })
    it("adds a new todolist item", function() {
        // using refs to get/set underlying HTML elements. Not a fan - probably possible to work around it
        let newTodoField = renderedComponent.refs.newTodoField;
        newTodoField.value = "This is a new todolist item!"
        ReactTestUtils.Simulate.change(newTodoField);
        let newTodoForm = renderedComponent.refs.newTodoForm
        ReactTestUtils.Simulate.submit(newTodoForm);
        let todoListItems = ReactTestUtils.scryRenderedDOMComponentsWithTag(renderedComponent, "li");
        assert.equal(todoListItems.length, 2);
        assert.equal(todoListItems[1].innerHTML, "<span>This is a new todolist item!</span><button>remove todo</button>");
    })
    it("adds a \"Bring a jacket!\" todo after making a call to jQuery", function() {
        let todoListItems = ReactTestUtils.scryRenderedDOMComponentsWithTag(renderedComponent, "li");
        assert.equal(todoListItems.length, 1);
        assert.equal(todoListItems[0].innerHTML, "<span>Bring a jacket!</span><button>remove todo</button>");
    })
})

This technically works. But there's some considerations you have to make:

  1. We'll have to adjust all of our other tests to account for the "Bring a jacket!" todolist item getting added.
  2. Our fake jQuery object is a stub, but does not include any of the super-convenient inspection/manipulation methods that a full-featured library like Sinon offers.
  3. You, Savvy Full Stack JavaScript Developer, wouldn't be caught dead serving assets from a CDN, or even from another file. You'd be bundling them together! You should probably check out rewire, which is sort of like standard require, but allows you to manipulate all of the member variables and methods when you import stuff.
  4. There might be times when you want to render a component into JSDOM, but not render all of the sub-components and sub-sub-components that it renders by default. You used to have to mock this stuff out using rewire, but React has recently introduced the [mockComponent()](https://facebook.github.io/react/docs/test-utils.html#mockcomponent) function into ReactTestUtils - check it out.

In true Frontend Web Dev fashion, this technique may already be obsolete

Like the comings and goings of our favorite frameworks, there may already be a better solution for this!

sorry :(

But don't worry! Now you know how it works, which is probably more important if you're going to implement/debug it in the future.

If you're interested in picking up the latest and greatest JavaScript testing libraries and frameworks, check out Ava.js and Enzyme! I believe Enzyme already bundles in JSDOM, which makes it super easy to use! :O

Obligatory Hofftech Pitch

If you or someone you know wants an app built, let me know! I'm starting to book clients for 2017.

If you want to go grab coffee, chat about the industry, or get some eyes on your code, hit me up! Totally down to hang out with other people in the industry.

And if you're looking for work, get in touch! I'm always looking for reliable subcontractors with diverse skills that can assist on projects.

Contact is best at mailto:[email protected].

What questions are there?

(remind me to repeat every question so the mic can pick it up)

Afterwards - come find me and ask me questions! I've got business cards! Say hi at mailto:[email protected]!