ruk·si

🍦 JavaScript Testing

Updated at 2013-11-16 13:14

This note contains guidelines and examples about how to write tests in JavaScript. Related to testing in general.

When you are developing bigger applications with JavaScript, it is a good idea to apply few forms of testing e.g. unit testing and integration testing. Advanced testing frameworks allow you to even fake AJAX calls so you can test what will happen if your application receives invalid input from server.

Testing Libraries

You must decide your stack of libraries you are going to use in your testing. Testing library stack I use:

You can also use Jasmine which has little less flexible syntax than Chai, does not offer all mocking features provided by Sinon and has had less asynchronous testing support in the past. I suggest using Testem with Jasmin also.

And then there is QUnit, which I do not personally like. Too messy.

You might want to give Cucumber-style testing a try with Mocha Cakes or Jasmine Given.

With Mocha (and Jasmin), you describe() components and then write what it should do with it(). Then you run console command mocha to run them if you have it installed.

// Specify what we are testing.
describe('MyClass', function() {

  var subject
  var result;

  // Run before each test.
  // There are also: afterEach, before, after
  beforeEach(function() {
    subject = new MyClass;
  });

  it('should be initialized with settings x and y', function() {
    // Check the subject.
  });

  // Describe add-function inside the MyClass.
  describe('#add()', function() {
    beforeEach(function() {
      result = subject.add(4, 5);
    });
    it('should add together 4 and 5 to 9', function() {
      // Assert that 4 and 5 is 9.
    });
  });

  // Describe save-function inside MyClass.
  describe('#save()', function() {
    it('should save without error', function(done){
      // Some asserts, possibly a stub.
    })
  });

})

Chai is used to validate your assumptions inside the testing framework. It has three syntax variants. These notes use should primarily.

// Should
chai.should();
foo.should.be.a('string');
foo.should.equal('bar');
foo.should.have.length(3);
tea.should.have.property('flavors').with.length(3);

// Expect
var expect = chai.expect;
expect(foo).to.be.a('string');
expect(foo).to.equal('bar');
expect(foo).to.have.length(3);
expect(tea).to.have.property('flavors').with.length(3);

// Assert
var assert = chai.assert;
assert.typeOf(foo, 'string');
assert.equal(foo, 'bar');
assert.lengthOf(foo, 3)
assert.property(tea, 'favors');
assert.lengthOf(tea.flavors, 3);

Sinon is used to create mockup objects so you can encapsulate tests not to rely on other parts of your code.

// In the following examples, `once` is the function under test.

// Spy: makes and observers function calls from outside the code under test.
it('calls the original function only once', function () {
  var callback = sinon.spy();
  var proxy = once(callback);
  proxy();
  proxy();
  assert(callback.calledOnce);
  // ...or:
  // assert.equals(callback.callCount, 1);
});

// Stub: provides behaviourless end points to the code under test.
it('returns the return value from the original function', function () {
  var callback = sinon.stub().returns(42);
  var proxy = once(callback);
  assert.equals(proxy(), 42);
});
describe('toggleVisible', function() {
    it('should call toggleClass', function() {
        var elementSpy = sinon.spy(this.view.$el, 'toggleClass');
        var viewStub = sinon.stub(this.view, 'isHidden', function() {
            return true;
        });
        this.view.toggleVisible();
        elementSpy.should.be.calledWithExactly('hidden', true);
    });
});

// Mock: mimics services outside the code under test.
describe('.toggle', function() {

    beforeEach(function() {
        this.mock = sinon.mock(TodoModel.prototype);
        this.expect = this.mock.expects('sync');
        this.model = new TodoModel();
    });

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

    it('should call sync', function() {
        this.expect.once();
        this.model.toggle();
        this.mock.verify();
    });

});

Sinon + Chai provides Sinon related assertion for Chai.

spy.should.have.been.called
spy.should.have.been.calledOnce
spy.should.always.have.been.calledWithExactly(...args)

Testem runs your tests. You can run command testem in project directory and after that Testem will search test folder for tests to run. You can also use testem ci so it tests runs tests on all browsers you have.

// Example testem.json configuration file in project root.
{
  "framework": "mocha",
  "src_files": [
    "lib/jquery-1.9.0.min.js",
    "lib/underscore-min.js",
    "lib/backbone-min.js",
    "lib/chai.js",
    "lib/sinon-chai.js",
    "lib/sinon-1.5.2.js",
    "src/*.js",
    "test/*.js"
  ]
}

Test Examples

Consider using test-driven development for getting all out of testing, write tests first, then write the implementation for them.

// test
var should = chai.should();
describe('Application', function() {

  it('should create a global variable for the name space', function () {
    should.exist(todoApp);
  })

})

// implementation
if (typeof todoApp === 'undefined') {
  todoApp = {};
}

Testing AJAX requests with Sinon stubs.

describe('Collection interaction with REST API', function() {

  it('should load using the API', function() {

    // All calls to $.ajax() will now return the following.
    this.ajaxStub = sinon.stub($, 'ajax').yieldsTo('success', [
      { id: 1, title: 'Mock Summary 1', complete: false },
      { id: 2, title: 'Mock Summary 2', complete: true  }
    ]);

    this.todos = new todoApp.Todos();
    this.todos.fetch(); // This should internally call $.ajax().

    this.todos.should.have.length(2);
    this.todos.at(0).get('title').should.equal('Mock Summary 1');
    this.todos.at(1).get('title').should.equal('Mock Summary 2');

    // Return $.ajax() to normal for future tests.
    this.ajaxStub.restore();
  });

});

Backbone Examples

Testing Backbone models.

// test
describe('Todo Model', function(){

  describe('Initialization', function() {

    beforeEach(function() {
      this.todo = new todoApp.Todo();
    });

    it('should default the status to pending', function() {
      this.todo.get('complete').should.be.false;
    });

    it('should default the title to an empty string', function() {
      this.todo.get('title').should.equal('');
    });

  });

});

// implementation
todoApp.Todo = Backbone.Model.extend({
  defaults: {
    title: '',
    complete:  false
  }
});

Testing Backbone model persistence.

// test
describe('Persistence', function() {

  beforeEach(function() {
    this.todo = new todoApp.Todo();
    this.saveStub = sinon.stub(this.todo, 'save');
  });

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

  it('should update server when title is changed', function() {
    this.todo.set('title', 'New Summary');
    this.saveStub.should.have.been.calledOnce;
  });

  it('should update server when status is changed', function() {
    this.todo.set('complete', true);
    this.saveStub.should.have.been.calledOnce;
  });

});

// implementation
todoApp.Todo = Backbone.Model.extend({
  defaults: {
    title: '',
    complete:  false
  },
  initialize: function() {
    this.on(
      'change',
      function() {
        this.save();
      }
    );
  }
});

Testing Backbone views.

// test
describe('Todo List Item View', function() {

  beforeEach(function(){
    this.todo = new todoApp.Todo({title: 'Summary'});
    this.item = new todoApp.TodoListItem({model: this.todo});
  })

  describe('Template', function() {

    beforeEach(function(){
      this.item.render();
    });

    it('should contain the todo title as text', function() {
      this.item.$el.text()
        .should.have.string('Summary');
    });

    it('should include a label for the status', function() {
        this.item.$el
          .find('label')
            .should.have.length(1);
    });

    it('should include an <input> checkbox', function() {
        this.item.$el
          .find('label > input[type="checkbox"]')
            .should.have.length(1);
    });

    it('should set checkbox off by default, pending', function() {
        this.item.$el
          .find('label > input[type="checkbox"]')
            .is(':checked').should.be.false;
    });

    it('should set checkbox on for 'complete' todos', function() {
        this.saveStub = sinon.stub(this.todo, 'save');
        this.todo.set('complete', true);
        this.item.render();
        this.item.$el
          .find('label>input[type="checkbox"]')
            .is(':checked').should.be.true;
        this.saveStub.restore();
    });

  });
});

// implementation
todoApp.TodoListItem = Backbone.View.extend({
  tagName: 'li',
  template: _.template(
    '<label>'
    +   '<input type="checkbox" <% if(complete) print('checked') %>/>'
    +   ' <%= title %> '
    + '</label>'
  ),
  render: function() {
    this.$el.html(this.template(this.model.attributes));
    return this;
  }
});

Testing interaction between Backbone models and views.

// test
describe('Todo List Item View', function() {

  beforeEach(function(){
    this.todo = new todoApp.Todo({title: 'Summary'});
    this.item = new todoApp.TodoListItem({model: this.todo});
    this.saveStub = sinon.stub(this.todo, 'save');
  });

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

  describe('Model Interaction', function() {

    it('should update model when checkbox is clicked', function() {

      var $fixture = $('<div>').attr('id', 'fixture');
      $fixture.css('display','none').appendTo('body');

      this.item.render();
      $fixture.append(this.item.$el);

      this.item.$el.find('input').click();
      this.todo.get('complete').should.be.true;
      $fixture.remove();

    });

  });

});

// implementation
todoApp.Todo = Backbone.Model.extend({
  defaults: {
    title: '',
    complete:  false
  },
  initialize: function() {
    this.on('change', function() { this.save(); });
  },
  toggleStatus: function() {
    this.set('complete', ! this.get('complete'));
  }
});

todoApp.TodoListItem = Backbone.View.extend({
  tagName: 'li',
  template: _.template(
    '<label>'
    +   '<input type='checkbox' <% if(complete) print('checked') %>/>'
    +   ' <%= title %> '
    + '</label>'
  ),
  events: {
    'click input': 'statusChanged'
  },
  render: function() {
    this.$el.html(this.template(this.model.attributes));
    return this;
  },
  statusChanged: function() {
    this.model.toggleStatus();
  }
})

Testing Backbone collections.

// test
describe('Todos List View', function() {

  beforeEach(function() {
    this.todos = new todoApp.Todos([
      {title: 'Todo 1'},
      {title: 'Todo 2'}
    ]);
    this.list = new todoApp.TodosList({collection: this.todos});
  });

  it('render should return the view object', function() {
    this.list.render().should.equal(this.list);
  });

  it('should render as an unordered list', function() {
    this.list.render().el.nodeName.should.equal('UL');
  });

  it('should include list items for all models in collection', function() {
      this.list.render();
      var modelCount = this.list.models.length;
      this.list.$el.find('li').should.have.length(modelCount);
    }
  );

});

// implementation
todoApp.TodosList = Backbone.View.extend({

  tagName: 'ul',

  initialize: function() {
    this.listenTo(
      this.collection,
      'add',
      this._renderOne
    )
  },

  render: function() {
    this._renderAll();
    return this;
  },

  _renderAll: function() {
    this.collection.each(this._renderOne, this);
  },

  _renderOne: function(todo) {
    var item = new todoApp.TodoListItem({model: todo});
    this.$el.append( item.render().el );
  }

})

Sources