Indy.js

Testing MEAN Apps with Javascript

(Mongo, Express, Angular, Node)

Presentation by Anthony Panozzo / @panozzaj

Hello

Your Background?

I like you even if you don't test one bit :)

About me

(Current) Haven App

  • MongoDB
  • Express
  • Angular
  • Node
  • Cordova (formerly known as Phonegap)
  • Eventually native?

Overview

  • What is testing?
  • Why Test?
  • Why not test?
  • Unit Testing
  • Testing Angular
  • End-to-end testing

What is testing?

Why test?

Verify code works as expected

Document expecations and assumptions

Ensure code continues to work as expected

Increased feedback

Increased confidence in changes

Better Design

Development speed

Help Debugging

Toward Continuous Deployment

Always be deployable

Know when to stop

Why Not Test?

Challenges in Testing

Must be valuable

Learning curve

Culture

Intermittent Tests

ntimes


#!/bin/bash

successes=0
failures=0

for ((n=0; n < $1; n++)); do
  # run all arguments except for the count as new command
  "${@:2}"

  if [[ $? == 0 ]]; then
    successes=$[successes + 1]
  else
    failures=$[failures + 1]
    say -v Zarvox -r400 "failure"
  fi

  echo -e "\nSuccesses: $successes"
  echo -e "Failures: $failures\n"
done

# usage:
# $ ntimes grunt test:mocha --grep 'test I want to run'

Test fragility

We're not sure what we are building...

Cannot prove a negative

Execution time

100% Test Coverage...

for 50 line shell script

for payment-related code

for nuclear sub software

Takeaways:

  • depends on impact of failure
  • can be different even in the same system!

Test type overview

Names are hard...

Unit tests

  • test one specific function / module / interaction
  • small
  • fast
  • not likely to fail
  • probably don't hit network / database?

Front-end tests

  • small
  • fast
  • ideally stub out network
  • test how we deal with data we receive

End-to-end tests

Integration? System? Functional?
  • potentially large
  • slow
  • hit network / database
  • test app as a user would see it
  • catch integration / usability issues
  • combinatorially large space

Unit testing Node

Some Options:

  • TAP
  • Jasmine
  • Mocha

TAP

Test Anything Protocol

Options: node-tap, tape
  • Perl-ish
  • many adapters with other systems (CI, reporters, etc.)
  • plain javascript, no runners needed

TAP Example


var test = require('tape').test;

test('equivalence', function(t) {
    t.equal(1, 1, 'these two numbers are equal');
    t.end();
});

test('async', function (t) {
    t.plan(2);

    t.equal(2 + 3, 5);

    setTimeout(function() {
        t.equal(5 + 5, 10);
    }, 500);
});
                        

Jasmine

  • BDD "expect" syntax
  • similar to Rspec 3.0
  • batteries-included (mocks, stubs, clock)
  • decent async support
  • very usable, good option

Jasmine


describe("A Jasmine suite", function() {
    var foo = 0;

    beforeEach(function() {
        foo += 1;
    });

    it("is just a function, so it can contain any code", function() {
        expect(foo).toEqual(1);
    });

    it("can be pending");

    it("can have more than one expectation, and async too!", function(done) {
        expect(foo).toEqual(2);
        setTimeout(function() {
            expect(true).toEqual(true);
            done();
        }, 500);
    });
});
                        

Mocha

  • choose your own adventure
  • configurable test syntax
  • best async support

Mocha Assertions

You need to import some!

Decent options:

Chai Assert syntax


var assert = chai.assert;

assert.typeOf(foo, 'string');
assert.equal(foo, 'bar');
assert.lengthOf(foo, 3)
assert.property(tea, 'flavors');
assert.lengthOf(tea.flavors, 3);
                        

Chai Should syntax


chai.should(); // var should = require('chai').should();

foo.should.be.a('string');
foo.should.equal('bar', 'it is not right for some reason');
foo.should.have.length(3);
tea.should.have.property('flavors').with.length(3);
                        

Chai Expect syntax


var expect = chai.expect;

expect(foo).to.be.a('string');
expect(foo).to.equal('bar', 'it is not right for some reason');
expect(foo).to.have.length(3);
expect(tea).to.have.property('flavors').with.length(3);

// talk about undefined
// talk about jshint
                        

Back to Mocha...

Please, just write a test...

Well, first we need to pick a test DSL!

Mocha calls these interfaces

Mocha TDD Interface


suite('Array', function() {
    setup(function() {
        // ...
    });

    suite('#indexOf()', function() {
        test('should return -1 when not present', function() {
          assert.equal(-1, [1, 2, 3].indexOf(4));
        });
    });
});
                        

Mocha BDD Interface


describe('Array', function() {
    beforeEach(function() {
        // ...
    });

    describe('#indexOf()', function() {
        context('when not present', function() {
            it('should not throw an error', function() {
                (function() {
                  [1,2,3].indexOf(4);
                }).should.not.throw();
            });

            it('should return -1', function() {
                [1,2,3].indexOf(4).should.equal(-1);
            });
        });
    });
});
                        

Supporting code

  • factories
  • database cleaner
  • mocking / stubbing
  • changing dependencies
  • stubbing network calls

Why Factories?

  • less brittle
  • specify exactly what you need for test
  • can easily construct objects in a certain state

Defining Factories

factory-lady

var Factory = require('factory-lady'),
    User = require('../../app/models/user'),
    Post = require('../../app/models/post');

var emailCounter = 1;

Factory.define('User', User, {
    email: function(cb) { cb('user' + emailCounter++ + '@example.com'); },
    state: 'activated',
    password: '123456',
});

Factory.define('Post', Post, {
    user: Factory.assoc('User', 'id'),
    subject: 'Hello World',
    content: 'Lorem ipsum dolor sit amet...',
});
                        

Using Factories

factory-lady

Factory.build('Post', function(post) {
    // post is a Post instance that is not saved
});

Factory.build('Post', {
    user: myUser,
    title: 'Foo'
}, function(post) {
    // build a post and override user and title
});

Factory.create('Post', function(post) {
    // post is a saved Post instance
});
                        

Demo?

Database cleaner


'use strict';

var _ = require('lodash'),
    BBPromise = require('bluebird'),
    mongoose = BBPromise.promisifyAll(require('mongoose'));

// Clear collections before each test to avoid pollution.
beforeEach(function() {
    // Dropping the whole collection will drop indexes,
    // which is not what we want.
    return BBPromise.map(_.keys(mongoose.models), function(modelName) {
        return mongoose.model(modelName).removeAsync({});
    });
});
                        

Sinon.js - Stubs


it("should stub method differently based on arguments", function() {
    var callback = sinon.stub();
    callback.withArgs(42).returns(1);
    callback.withArgs(1).throws("TypeError");

    callback();   // No return value, no exception
    callback(42); // Returns 1
    callback(1);  // Throws TypeError
});
                        

Sinon.js - Spies and time manipulation


// given this function:
function throttle(callback) {
    var timer;
    return function() {
        clearTimeout(timer);
        var args = [].slice.call(arguments);
        timer = setTimeout(function() {
            callback.apply(this, args);
        }, 1000);
    };
}
                        

Sinon.js - Spies and time manipulation


var clock;

before(function() { clock = sinon.useFakeTimers(); });
after(function() { clock.restore(); });

it("calls callback after 100ms", function () {
    var callback = sinon.spy();
    throttle(callback)();

    clock.tick(999);
    assert(callback.notCalled);

    clock.tick(1);
    assert(callback.calledOnce);

    // Also:
    // assert.equals(new Date().getTime(), 1000);
}
                        

Changing Dependencies

rewire

// With rewire you can change these variables
var fs = require("fs"),
    path = "/somewhere/on/the/disk";

function readSomethingFromFileSystem(cb) {
    fs.readFile(path, "utf8", function(err, data) {
        if (err) {
            cb('error reading file!');
        } else {
            cb('file contents: ' + data);
        }
    });
}

exports.readSomethingFromFileSystem = readSomethingFromFileSystem;
                        

Changing Dependencies


var rewire = require("rewire");
var myModule = rewire("../lib/myModule.js");

describe('readSomethingFromFileSystem', function() {
    context('normal operation', function() {
        beforeEach(function(done) {
            var fsStub = {
                readFile: function(path, encoding, cb) {
                    expect(path).to.equal("/somewhere/on/the/disk");
                    cb(null, "stubbed file contents");
                }
            };
            myModule.__set__("fs", fsStub);
            done();
        });

        it('should respond with the file contents', function(done) {
            myModule.readSomethingFromFileSystem(function(result) {
                expect(data).to.equal('file contents: stubbed file contents');
                done();
            });
        });
    });
});
                        

Changing Dependencies


...
    context('error case', function() {
        beforeEach(function(done) {
            var fsStub = {
                readFile: function(path, encoding, cb) {
                    expect(path).to.equal("/somewhere/on/the/disk");
                    cb(new Error(''));
                }
            };
            myModule.__set__("fs", fsStub);
            done();
        });

        it('should respond with the file contents', function(done) {
            myModule.readSomethingFromFileSystem(function(result) {
                expect(data).to.equal('error reading file!');
                done();
            });
        });
    });
...
                        

fs-mock

Mocking network requests

Nock

var nock = require('nock');

var weatherStub = nock('http://weatherapi.com')
    .get('/london')
    .reply(200, 'rainy, 50˚F');
                        
  • overrides Node's http.request function and http.ClientRequest
  • nockBack - record and potentially re-record

Testing Express Controllers

superagent

'use strict';

var _ = require('lodash');
var Factory = require('factory-lady');
var BBPromise = require('bluebird');
var request = BBPromise.promisifyAll(require('superagent'));

var config = require('../../../config/config');

var categories, user;


describe('Categories controller', function() {
    describe('list', function() {
        beforeEach(function createUser(done) {
            Factory.create('User').then(function(_user) {
                user = _user;
                done();
            });
        });

        beforeEach(function createCategories(done) {
            Factory.createList('Category', 3, [
                { name: 'Cheeses' },
                { name: 'Apples', },
                { name: 'Bananas', },
            ], function(_categories) {
                categories = _categories;
                done();
            });
        });

        it('should respond with a sorted list of the categories',
            request.get(config.baseUrl + '/api/v1/categories')
            .set('Authorization', 'Bearer ' + user.createLoginToken())
            .endAsync().then(function(response) {

                response.status.should.equal(200);

                response.headers['content-range'].should.eql('1-1/3');

                response.body.should.eql([
                    'Apples',
                    'Bananas',
                    'Cheeses',
                ]);

                done();
            });
        });
    });
});
                        

Karma Testing

What is it?

Test runner, developed for Angular

What can I test?

  • Modules
  • Angular Services / Factories
  • Angular Controllers
  • Filters
  • Directives!
  • Animations
  • Angular Routes
  • Requests / Pages
  • Templates, Partials & Views
  • etc.

Dependency injection


someModule.controller('MyController', [
    '$scope',
    'dep1',
    'dep2',
    'moment',
    'q',
    '_',
    '$omg',
    '$wtf', function(
        $scope, dep1, dep2,
        moment, q, _, $omg, $wtf) {
  ...
                        

Example

100% lifted from here

userService
.getSubredditsSubmittedToBy("yoitsnate")
.then(function(subreddits) {
    $scope.subreddits = subreddits;
});
                        

Example


angular.module("reddit").service("userService",
function($http) {
  return {
    getSubredditsSubmittedToBy: function(user) {
      return $http.get("http://api.reddit.com/user/" + user + "/submitted.json")
      .then(function(response) {
        var posts, subreddits;

        posts = response.data.data.children;

        // transform data to be only subreddit strings
        subreddits = posts.map(function(post) {
          return post.data.subreddit;
        });

        // de-dupe
        subreddits = subreddits.filter(function(element, position) {
          return subreddits.indexOf(element) === position;
        });

        return subreddits;
      });
    }
  };
});
                        

Example


"use strict";

describe("reddit api service", function() {
  var redditService, httpBackend;

  beforeEach(module("reddit"));

  beforeEach(inject(function(_redditService_, $httpBackend) {
    // https://docs.angularjs.org/api/ngMock/function/angular.mock.inject
    // "injected parameters can, optionally, be enclosed
    // with underscores. These are ignored by the injector
    // when the reference name is resolved."

    redditService = _redditService_;

    // https://docs.angularjs.org/api/ngMock/service/$httpBackend
    // Fake HTTP backend implementation suitable for unit
    // testing applications that use the $http service.

    httpBackend = $httpBackend;
  }));

  it("should do something", function () {
    httpBackend.whenGET("http://api.reddit.com/user/yoitsnate/submitted.json").respond({
        data: {
          children: [
            { data: { subreddit: "golang" } },
            { data: { subreddit: "javascript" } },
            { data: { subreddit: "golang" } },
            { data: { subreddit: "javascript" } }
          ]
        }
    });

    redditService.getSubredditsSubmittedToBy("yoitsnate").then(function(subreddits) {
      expect(subreddits).toEqual(["golang", "javascript"]);
    });

    httpBackend.flush();
  });
});
                        

Protractor

End-to-end testing

Why Protractor?

Protractor Example


describe('angularjs homepage todo list', function() {
    it('should add a todo', function() {
        browser.get('http://www.angularjs.org');

        element(by.model('todoText')).sendKeys('write a protractor test');
        $('[value="add"]').click();

        var todoList = element.all(by.repeater('todo in todos'));
        expect(todoList.count()).toEqual(3);
        expect(todoList.get(2).getText()).toEqual('write a protractor test');
    });
});
                        

Protractor Demo?

That's basically it...

Potential bonuses:

  • Appium
  • API Blueprints

END OF PRESENTATION

Indy.js

Testing MEAN Apps with Javascript

(Mongo, Express, Angular, Node)

Presentation by Anthony Panozzo / @panozzaj