Testing JS

A tour through testing concepts and popular tooling

@peterjwest

Continuum of testing

Unit tests

  • Test individual methods, objects and modules
  • Tests isolate from other systems and services with mocks and stubs
  • Aim for black box testing

Mocha

  • Powerful and robust testing library
  • Very similar to Jasmine
  • Uses a hierarchical structure of helper functions to define test structure

Mocha example

Simple


describe('numberAdder', function() {
  it('adds numbers', function() {
    var result = numberAdder(1, 3);
    assert.equal(result, 4);
  });
});
              

Nested


describe('SmartMaths', function() {
  describe('numberAdder', function() {
    it('adds numbers', function() {
      var result = SmartMaths.numberAdder(1, 3);
      assert.equal(result, 4);
    });
  });
});
              

Async


describe('SmartMaths', function() {
  describe('numberAdder', function() {
    it('adds numbers', function(done) {
      SmartMaths.numberAdder(1, 3, function(err, result) {
        assert.equal(err, null);
        assert.equal(result, 4);
        done();
      });
    });
  });
});
              

Skip & Only


describe('numberAdder', function() {
  it.only('adds numbers', function(done) {
    ...
  });

  it.skip("doesn't add strings", function(done) {
    ...
  });
});
              

Before & after


describe('numberAdder', function() {
  beforeEach(function() {
    this.lookup = mathsDb.lookup;
    mathsDb.lookup = function(a, b, callback) {
      assert.equal(a, 1);
      assert.equal(b, 3);
      callback(4);
    }
  });

  afterEach(function() {
    mathsDb.lookup = this.lookup;
  });

  it('adds numbers', function(done) {
    ...
  });
});
              

Sinon

  • Utility to create and manage mocks, spies and stubs
  • Spies add debugging to functions without altering their behaviour
  • Stubs replace functions with simple behaviours
  • Mocks are similar to stubs but can include assertions

Sinon example

Manual stub


describe('numberAdder', function() {
  beforeEach(function() {
    this.lookup = mathsDb.lookup;
    mathsDb.lookup = function(a, b, callback) {
      assert.equal(a, 1);
      assert.equal(b, 3);
      callback(4);
    }
  });

  afterEach(function() {
    mathsDb.lookup = this.lookup;
  });

  it('adds numbers', function(done) {
    ...
  });
});
              

With sinon


describe('numberAdder', function() {
  beforeEach(function() {
    sinon.stub(mathsDb, 'lookup').calledWith(1, 3).yields(4);
  });

  afterEach(function() {
    mathsDb.lookup.restore();
  });

  it('adds numbers', function(done) {
    assert(mathsDb.lookup.calledOnce);
    ...
  });
});
              

Sandboxed


describe('numberAdder', function() {
  beforeEach(function() {
    this.sinon = sinon.sandbox.create();
    this.sinon.stub(mathsDb, 'lookup').calledWith(1, 3).yields(4);
  });

  afterEach(function() {
    this.sinon.restore();
  });

  it('adds numbers', function(done) {
    assert(mathsDb.lookup.calledOnce);
    ...
  });
});
              

Gulp

  • Task runner, similar to Grunt
  • Code as configuration (unlike Grunt)
  • Uses Node streams to wire up tasks

Gulp example


// gulpfile.js
var gulp = require('gulp');
var uglify = require('gulp-uglify');

gulp.task('compress', function() {
  return gulp.src(['lib/**/*.js', '!lib/jquery-lol.js'])
  .pipe(uglify())
  .pipe(gulp.dest('dist'));
});
                

$ gulp compress
[13:19:38] Using gulpfile ~/projects/mouserat/gulpfile.js
[13:19:38] Starting 'compress'...
[13:19:39] Finished 'compress' after 150 ms
              

Gulp & Mocha


var gulp = require('gulp');
var mocha = require('gulp-mocha');
gulp.task('test', function() {
  return gulp.src(['test/server/**/*.js']).pipe(mocha());
});
            

Frontend tests - Karma


var gulp = require('gulp');
var karma = require('gulp-karma');
gulp.task('test-client', function(cb) {
  return gulp.src(['public/js/**/*.js', 'test/client/**/*.js'])
    .pipe(karma({
      configFile: 'karma.conf.js',
      action: 'run'
    }))
    .on('error', cb);
});
            

// karma.conf.js
module.exports = function(config) {
  config.set({
    browsers: ['Chrome', 'PhantomJS'],
    frameworks: ['mocha']
  });
};
            

Angular testing with Karma


beforeEach(module('ThingController'));
beforeEach(inject(function($rootScope, $controller, $http) {
  this.$controller = $controller;
  this.$scope = $rootScope.$new();

  $controller('dashboard', {
    $scope: this.$scope,
    $http: $http
  });
});

it('should set a welcome message', function(){
    assert.equal(this.$scope.text, 'Hello World!');
});
            

Integration tests

  • Tests the integration between components or services
  • May use a combination of mocks and real services

Example: routing layer

Server


// app.js
var request = require('supertest');
var app = require('express')();

var controllers = {
  user: function(req, res) {
    res.send(200, {});
  }
}

app.get('/user', controllers);
            

Example: routing layer

Test


describe('GET /user', function() {
  beforeEach(function() {
    sinon.spy(controllers, 'user')
  });

  it('responds correctly', function(done) {
    request(app)
      .get('/user')
      .set('Accept', 'application/json')
      .expect('Content-Type', /json/)
      .expect(200, function(err) {
        assert(controllers.user.calledOnce);
        done(err);
      });
  });
});
            

Database layer testing

  • Use fixtures to build and destroy data for tests
  • Keep tests independent where possible

Functional & acceptance tests

  • Functional tests check input/output through the whole application
  • User acceptance tests check a series of user steps through the system

Protractor

  • Wrapper around Selenium and WebDriverJs
  • Runs tests through a real web browser and a real server

Gulp & Protractor


var protractor = require("gulp-protractor").protractor;

gulp.task('acceptance', function(cb) {
  return gulp.src(["test/acceptance/**/*.js"])
    .pipe(protractor({
      configFile: "protractor.config.js",
      args: ['--baseUrl', 'http://localhost:3000']
    }));
});
              

// protractor.config.js
exports.config = {
  framework: 'mocha',
  seleniumServerJar: 'selenium-server-standalone.jar'
};
              

Testing with protractor


var chai = require('chai');
chai.use(require('chai-as-promised'));

describe('the homepage', function() {
  it('should allow you to write a message', function() {
    browser.get('');

    element(by.model('message')).sendKeys("Ima typing ma keyboard");
    element(by.css('[value="add"]')).click();
    element(by.id('button')).click();

    var messages = element.all(by.repeater('message in messages'));
    chai.expect(messages.count()).to.eventually.equal(0);
    chai.expect(messages.get(0).getText()).to.eventually.equal("Ima typing ma keyboard");
  });
});
            

What's missing?

  • Run the server somewhere
  • Set up an in-memory database for your test server
  • Think about fixtures for your database

Smoke tests

  • Tests run on a live server, usually as a post-deploy check
  • Same approach as acceptance tests
  • Designed to verify the server and services are run correctly

Smoke tests with protractor


var protractor = require("gulp-protractor").protractor;

gulp.task('smoke', function(cb) {
  return gulp.src(["test/acceptance/**/*.js"])
    .pipe(protractor({
      configFile: "protractor.config.js",
      args: ['--baseUrl', 'http://www.bensmindpalace.co.uk']
    }));
});
            

Smoke test considerations

  • Try to cover critical paths for users
  • Try to cover all services e.g. authentication, database, email
  • Either:
    • Write reversable, non-destructive tests
    • or create a test account for smoke tests, separated from other users

Bonus: code coverage

  • Normally used with unit tests
  • Tells you which parts of your code aren't being covered by tests
  • Great for tracking improvements to your test suite
  • Not a perfect tool - use with cynicism

Istanbul.js

  • Awesome code coverage tool
  • Instruments seamlessly in Node
  • Bundled with Karma
  • Parses code to provide branch coverage

Gulp & Istanbul


var gulp = require('gulp');
var gulpIstanbul = require('gulp-istanbul');

gulp.task('coverage', function(cb) {
  return gulp.src(['lib/**/*.js'])
    .pipe(gulpIstanbul({ includeUntested: true }))
    .pipe(gulpIstanbul.hookRequire())
    .on('end', function() {
      gulp.src(['test/lib/**/*.js'])
        .pipe(mocha())
        .on('error', cb)
        .pipe(gulpIstanbul.writeReports({ reporters: ['lcov', 'text-summary'] }))
        .pipe(gulpIstanbul.enforceThresholds({ thresholds: { global: 1 }}))
        .on('error', cb);
    });
});
            

Istanbul thresholds

  • Provides coverage for statements, branches, lines and functions
  • Enforce coverage by percentage or omissions

Wrapping up

  • Choose the right tests for your project, tests take a lot of time to build
  • High level tests are great for checking your project against a spec, making sure it actually works, and they work great with Agile
  • Low level tests are great for making your project robust and maintainable

K thx

@peterjwest