From zero to code hero

Just another page about javaScript, me, cats and general programming things.

Unit tests for React app - Webpack2 + Enzyme + Mocha + Sinon + JsDom

I think it's about the time for another update !
This time it'll be something different. Not so long ago I had to set up a testing environment for unit-testing a React web application. There are numerous tutorials on how to do it but none of them really solved it for me. Even though our stack back then was more or less a market standard it was either SinonJs throwing errors, or absolute imports not working or something else. So here's a quick guide on how I set it up for my use-case.

The requirements.

  • React and Redux support
  • Babel
  • Webpack 2.x
  • MochaJS
  • SinonJs
  • Enzyme
  • JsDom

Packages.json

Let's start with our packages.json. Since I've learned (the hard way) that all these testing frameworks are really susceptible if it comes to versioning, I'll just stick to the versions I'm using (and can confirm that they work). I assume you already have Webpack 2.x and Babel (with your favorite config) set-up correctly. Ok, so first the devDependencies section :

  "devDependencies": {
    "babel-plugin-add-module-exports": "~0.2.1",
    "enzyme": "2.6.0",
    "imports-loader": "~0.7.0",
    "json-loader": "~0.5.4",
    "karma-jsdom-launcher": "~3.0.0",
    "legacy-loader": "~0.0.2",
    "mocha": "2.4.5",
    "mocha-webpack": "~0.7.0",
    "null-loader": "~0.1.1",
    "react-addons-test-utils": "~15.4.1",
    "redux-mock-store": "~1.0.2",
    "sinon": "~1.17.6",
  },

I'm using karma-jsdom-launcher instead of just installing jsdom directly because the new version was not working for me and I didn't yet got a chance to debug this problem.

Now two scripts you'll need to run tests. One for a single run and one for a watcher that will rerun the suite whenever changes in files happen.

"scripts": {
  "test": "cross-env NODE_ENV=test mocha-webpack --require setup.js --webpack-config webpack.config.test.babel.js \"tests/**/*.test.js\"",
  "test:watch": "npm test -- --watch --full-trace",
},

I won't go into too much details here. What you need to know:

  • setup.js is the JsDom config file we'll create in a moment
  • webpack.config.test.babel.js is self-explanatory I think
  • \"tests/**/*.test.js\" our test files should be named *.test.js to be picked up by our suite

JsDom

The setup.js file mention above configures JsDom browser for our needs.

var jsdom = require('jsdom').jsdom;
var exposedProperties = ['window', 'navigator', 'document'];

global.document = jsdom('');
global.window = document.defaultView;
Object.keys(document.defaultView).forEach((property) => {
  if (typeof global[property] === 'undefined') {
    exposedProperties.push(property);
    global[property] = document.defaultView[property];
  }
});

global.navigator = {
  userAgent: 'node.js'
};

If you want to extend the browser object here are two sample rules I'm also using. One creates a global Raven variable, and the other specifies window.URL for handling HTML5 file uploads:

global.window.URL = {
  createObjectURL: function(file) {
    return file.preview;
  }
};

global.Raven = {
  captureException: function() { }
};

Webpack config

Finally let's create a webpack config for tests.

const webpack = require('webpack');

module.exports = {
  target: 'node',
  devtool: "eval",
  module: {
    rules: [
      {
        test: /\.js$|\.jsx$/,
        exclude: [
          'node_modules',
        ],
        loaders: [{
          loader: 'babel-loader',
          query: {
            compact: false,
            presets: ['es2015', 'react'],
          },
        }]
      },
      { test: /\.json$/, loader: 'json-loader' },
      {
        test: /\.(png|jpg|jpeg)$/,
        loader: 'url-loader',
        query: {
          name: '[hash].[ext]',
          limit: 10000,
        }
      },
      { test: /\.css$/, loader: 'null-loader' },
      {
        test: /sinon\/pkg\/sinon\.js/,
        loader: 'legacy-loader!imports-loader?define=>false,require=>false',
      },
    ],
  },
  externals: {
    jsdom: 'window',
    'react/addons': 'react',
    'react/lib/ExecutionEnvironment': 'react',
    'react/lib/ReactContext': 'react',
  },
  resolve: {
    extensions: ['.js', '.jsx', '.css', '.json'],
    modules: [
      'app', 'node_modules'
    ],
    alias: {
      sinon: 'sinon/pkg/sinon',
    }
  },
  plugins: [
    new webpack.IgnorePlugin(/vertx/),
    new webpack.ProvidePlugin({ React: 'react' }),
  ],
};

The only not-so-obvious things are IgnorePlugin (used because otherwise vertx - which is some dependency lib - would throw errors in the console) and some magic to get SinonJs working.
With this config all your css and image imports should also work.

Summarize

As I said in the foreword - this config may not be exactly what you need, but it should unblock you and help fix some problems that you may stumble upon while configuring your test environment.

If you're looking for great experience when writing tests I'm also using two other libraries in my stack - expect is my assertions library and great axios for handling XHR requests.

Here's a simple test case that show how you can leverage all these libraries and frameworks installed in your node_modules :)

import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { polyfill } from 'es6-promise';
import promise from 'redux-promise';
import axios from 'axios';
import expect from 'expect';
import sinon from 'sinon';

polyfill();

const middlewares = [thunk, promise];
const mockStore = configureStore(middlewares);

describe('Test something', () => {
  let sandbox;

  beforeEach(() => {
    sandbox = sinon.sandbox.create();
  });

  afterEach(() => {
    sandbox.restore();
  });

  it('Fetches something', done => {
    const data = {
      id: 1,
    };
    const expectedActions = [
      {
        type: 'FETCH_REQUEST',
      }, {
        type: 'FETCH_SUCCESS',
        payload: { ...data },
        status: 200,
      }
    ];

    sandbox.stub(axios, 'get').returns(
      new Promise((resolve, reject) => {
        resolve({ status: 200, data: { ...data } });
      })
    );

    const store = mockStore({
      project: {},
    });
    store.dispatch(fetchSomething({ url: `project_${data.id}` }))
      .then(() => {
        expect(store.getActions()).toEqual(expectedActions);
      }).then(done)
      .catch(done);
  });
});