Test Driven Development

With Jasmine and Angular

Tyson Gern @tygern

Introduction

  • Software Engineer at Pivotal Labs
  • 100% TDD and Pair Programming
  • We're new in town
  • We're hiring! (pivotaldublin.com)

Goals

  • You should be testing your javascript.
  • TDD results in clean, maintainable code.
  • Jasmine + AngularJS is a great way to start!

Tools

What is TDD?

According to Kent Beck...

  1. Don't write a line of new code unless you first have a failing automated test.
  2. Eliminate duplication.

How to practice TDD?

Red Green Refactor

Why practice TDD?

Documents behavior of code

  • Easy for new developers to join project
  • Stays up to date, unlike comments
  • 
    // adds one to the passed in value
    function increment(number) {
      return number + 2;
    }
                

Why practice TDD?

Leads to simple design

  • Tests are first client of code
  • Solution uses minimal amount of code

Why practice TDD?

Allows frequent refactoring

  • Code improves over time

Why practice TDD?

Quickly add features

  • Regressions are uncommon
  • Deploy confidently

Jasmine

javascript testing framework

  • Developed by Pivotal Labs
  • Framework independent
  • Runs anywhere

Example

Function addStrings that adds integers passed as strings.

Add a Test


describe('addStrings', function () {




});
            

Add a Test


describe('addStrings', function () {
  it('correctly adds two numbers', function () {


  });
});
            

Add a Test


describe('addStrings', function () {
  it('correctly adds two numbers', function () {
    expect(addStrings('3', '5')).toEqual(8);

  });
});
            

Add a Test


describe('addStrings', function () {
  it('correctly adds two numbers', function () {
    expect(addStrings('3', '5')).toEqual(8);
    expect(addStrings('4', '6')).toEqual(10);
  });
});
            

Add a Test


describe('addStrings', function () {
  it('correctly adds two numbers', function () {
    expect(addStrings('3', '5')).toEqual(8);
    expect(addStrings('4', '6')).toEqual(10);
  });
});
            
ReferenceError: addStrings is not defined

Make it pass


describe('addStrings', function () {
  it('correctly adds two numbers', function () {
    expect(addStrings('3', '5')).toEqual(8);
    expect(addStrings('4', '6')).toEqual(10);
  });
});
            

function addStrings() {

}
            

Make it pass


describe('addStrings', function () {
  it('correctly adds two numbers', function () {
    expect(addStrings('3', '5')).toEqual(8);
    expect(addStrings('4', '6')).toEqual(10);
  });
});
            

function addStrings() {

}
            
Expected undefined to equal 8.

Make it pass


describe('addStrings', function () {
  it('correctly adds two numbers', function () {
    expect(addStrings('3', '5')).toEqual(8);
    expect(addStrings('4', '6')).toEqual(10);
  });
});
            

function addStrings() {
  return 8;
}
            

Make it pass


describe('addStrings', function () {
  it('correctly adds two numbers', function () {
    expect(addStrings('3', '5')).toEqual(8);
    expect(addStrings('4', '6')).toEqual(10);
  });
});
            

function addStrings() {
  return 8;
}
            
Expected 8 to equal 10.

Make it pass


describe('addStrings', function () {
  it('correctly adds two numbers', function () {
    expect(addStrings('3', '5')).toEqual(8);
    expect(addStrings('4', '6')).toEqual(10);
  });
});
            

function addStrings(first, second) {
  return parseInt(first) + parseInt(second);
}
            

Make it pass


describe('addStrings', function () {
  it('correctly adds two numbers', function () {
    expect(addStrings('3', '5')).toEqual(8);
    expect(addStrings('4', '6')).toEqual(10);
  });
});
            

function addStrings(first, second) {
  return parseInt(first) + parseInt(second);
}
            

Requirement

Handle bad data. Count non-numbers as 0.

Add a test


describe('addStrings', function () {
  //...
  it('treats non-numbers as 0', function () {


  });
});
            

function addStrings(first, second) {
  return parseInt(first) + parseInt(second);
}
            

Add a test


describe('addStrings', function () {
  //...
  it('treats non-numbers as 0', function () {
    expect(addStrings('a', '6')).toEqual(6);

  });
});
            

function addStrings(first, second) {
  return parseInt(first) + parseInt(second);
}
            

Add a test


describe('addStrings', function () {
  //...
  it('treats non-numbers as 0', function () {
    expect(addStrings('a', '6')).toEqual(6);
    expect(addStrings('2', '')).toEqual(2);
  });
});
            

function addStrings(first, second) {
  return parseInt(first) + parseInt(second);
}
            

Add a test


describe('addStrings', function () {
  //...
  it('treats non-numbers as 0', function () {
    expect(addStrings('a', '6')).toEqual(6);
    expect(addStrings('2', '')).toEqual(2);
  });
});
            

function addStrings(first, second) {
  return parseInt(first) + parseInt(second);
}
            
Expected NaN to equal 6.

Add a test


describe('addStrings', function () {
  //...
  it('treats non-numbers as 0', function () {
    expect(addStrings('a', '6')).toEqual(6);
    expect(addStrings('2', '')).toEqual(2);
  });
});
            

function addStrings(first, second) {
  var firstNumber = parseInt(first);
  if(isNaN(firstNumber)) {
    firstNumber = 0;
  }

  return firstNumber + parseInt(second);
}
            

Add a test


describe('addStrings', function () {
  //...
  it('treats non-numbers as 0', function () {
    expect(addStrings('a', '6')).toEqual(6);
    expect(addStrings('2', '')).toEqual(2);
  });
});
            

function addStrings(first, second) {
  var firstNumber = parseInt(first);
  if(isNaN(firstNumber)) {
    firstNumber = 0;
  }

  return firstNumber + parseInt(second);
}
            
Expected NaN to equal 2.

Add a test


describe('addStrings', function () {
  //...
  it('treats non-numbers as 0', function () {
    expect(addStrings('a', '6')).toEqual(6);
    expect(addStrings('2', '')).toEqual(2);
  });
});
            

function addStrings(first, second) {
  var firstNumber = parseInt(first);
  if(isNaN(firstNumber)) {
    firstNumber = 0;
  }

  var secondNumber = parseInt(second);
  if(isNaN(secondNumber)) {
    secondNumber = 0;
  }

  return firstNumber + secondNumber;
}
            

Add a test


describe('addStrings', function () {
  //...
  it('treats non-numbers as 0', function () {
    expect(addStrings('a', '6')).toEqual(6);
    expect(addStrings('2', '')).toEqual(2);
  });
});
            

function addStrings(first, second) {
  var firstNumber = parseInt(first);
  if(isNaN(firstNumber)) {
    firstNumber = 0;
  }

  var secondNumber = parseInt(second);
  if(isNaN(secondNumber)) {
    secondNumber = 0;
  }

  return firstNumber + secondNumber;
}
            

Refactor


describe('addStrings', function () {
  //...
  it('treats non-numbers as 0', function () {
    expect(addStrings('a', '6')).toEqual(6);
    expect(addStrings('2', '')).toEqual(2);
  });
});
            

function addStrings(first, second) {
  var firstNumber = parseInt(first);
  if(isNaN(firstNumber)) {
    firstNumber = 0;
  }

  var secondNumber = parseInt(second);
  if(isNaN(secondNumber)) {
    secondNumber = 0;
  }

  return firstNumber + secondNumber;
}
            

Refactor


describe('addStrings', function () {
  //...
  it('treats non-numbers as 0', function () {
    expect(addStrings('a', '6')).toEqual(6);
    expect(addStrings('2', '')).toEqual(2);
  });
});
            

function addStrings(first, second) {
  function parseNumber(string) {
    var number = parseInt(string);

    return isNaN(number) ? 0 : number;
  }

  return parseNumber(first) + parseNumber(second);
}
            

Refactor


describe('addStrings', function () {
  //...
  it('treats non-numbers as 0', function () {
    expect(addStrings('a', '6')).toEqual(6);
    expect(addStrings('2', '')).toEqual(2);
  });
});
            

function addStrings(first, second) {
  function parseNumber(string) {
    var number = parseInt(string);

    return isNaN(number) ? 0 : number;
  }

  return parseNumber(first) + parseNumber(second);
}
            

Angular

javascript web framework

  • Developed by Google
  • It's testable!

2-way binding with $scope

Angular synchronizes controllers and views with $scope.


{{message}}

+


angular.module('messaging')
  .controller('messaging.flashController', function ($scope) {
    $scope.message = 'hello';
  });
            

=


hello

Dependency injection

Angular uses dependency injection to provide objects with collaborators.


angular.module('messaging')
  .controller('messaging.flashController', function($scope,
                                             messagingService) {

  });
          

Modules

Modules allow building application with modules.


angular.module('users', []);            //   greeterApplication
angular.module('twitterAdapter', []);   //         /     \
angular.module('messaging', [           //        /       \
  'twitterAdapter'                      //       /         \
]);                                     //  messaging     users
                                        //      |
angular.module('greeterApplication', [  //      |
  'users',                              //      |
  'messaging'                           // twitterAdapter
]);                                     //
          

Example!

Start to build greeterApplication

Feature

Show initial greeting when the page loads


{{message}}

Test setup


describe('messaging.flashController', function () {

  beforeEach(module('messaging'));












  // tests go here
});
            

Test setup


describe('messaging.flashController', function () {
  var $scope;
  beforeEach(module('messaging'));

  beforeEach(inject(function ($rootScope) {
    $scope = $rootScope.$new();







  }));

  // tests go here
});
            

Test setup


describe('messaging.flashController', function () {
  var $scope;
  beforeEach(module('messaging'));

  beforeEach(inject(function ($rootScope, $controller) {
    $scope = $rootScope.$new();



    $controller('messaging.flashController', {
      $scope: $scope

    });
  }));

  // tests go here
});
            

Test setup


describe('messaging.flashController', function () {
  var $scope, messagingService;
  beforeEach(module('messaging'));

  beforeEach(inject(function (..., _messagingService_) {
    $scope = $rootScope.$new();
    messagingService = _messagingService_;
    spyOn(messagingService, 'getMessage').and.returnValue('Hi!');

    $controller('messaging.flashController', {
      $scope: $scope,
      messagingService: messagingService
    });
  }));

  // tests go here
});
            

Add a Test


describe('messaging.flashController', function () {
  // setup

  describe('when the controller loads', function () {
    it('sets the message', function () {



    });
  });
});
            

Add a Test


describe('messaging.flashController', function () {
  // setup

  describe('when the controller loads', function () {
    it('sets the message', function () {
      expect($scope.message).toEqual('Hi!');


    });
  });
});
            

Add a Test


describe('messaging.flashController', function () {
  // setup

  describe('when the controller loads', function () {
    it('sets the message', function () {
      expect($scope.message).toEqual('Hi!');
      expect(messagingService.getMessage)
              .toHaveBeenCalledWith('initial');
    });
  });
});
            

Add a Test


describe('messaging.flashController', function () {
  // setup

  describe('when the controller loads', function () {
    it('sets the message', function () {
      expect($scope.message).toEqual('Hi!');
      expect(messagingService.getMessage)
              .toHaveBeenCalledWith('initial');
    });
  });
});
            
Expected undefined to equal 'Hi!'.

Add a Test


describe('messaging.flashController', function () {
  // setup

  describe('when the controller loads', function () {
    it('sets the message', function () {
      expect($scope.message).toEqual('Hi!');
      expect(messagingService.getMessage)
              .toHaveBeenCalledWith('initial');
    });
  });
});
            
Expected spy getMessage to have been called with [ 'initial' ] but it was never called.

Make it pass!


angular.module('messaging')
  .controller('messaging.flashController', function($scope,
                                             messagingService) {

  });
          

Make it pass!


angular.module('messaging')
  .controller('messaging.flashController', function($scope,
                                             messagingService) {
    $scope.message = messagingService.getMessage('initial');
  });
          

Make it pass!


angular.module('messaging')
  .controller('messaging.flashController', function($scope,
                                             messagingService) {
    $scope.message = messagingService.getMessage('initial');
  });
          

Refactor?


angular.module('messaging')
  .controller('messaging.flashController', function($scope,
                                             messagingService) {
    $scope.message = messagingService.getMessage('initial');
  });
          

Refactor?


angular.module('messaging')
  .controller('messaging.flashController', function($scope,
                                             messagingService) {
    $scope.message = messagingService.getMessage('initial');
  });
          

Feature

Change the message to prompt the user after 5 seconds


{{message}}

Add a Test


describe('messaging.flashController', function () {
  // tests and setup

  describe('after waiting 5 seconds', function () {
    it('shows the \'prompt\' message', function () {










    });
  });
});
            

Add a Test


describe('messaging.flashController', function () {
  // tests and setup

  describe('after waiting 5 seconds', function () {
    it('shows the \'prompt\' message', function () {
      $scope.message = '';

      $timeout.flush(4900);
      expect($scope.message).toEqual('');






    });
  });
});
            

Add a Test


describe('messaging.flashController', function () {
  // tests and setup

  describe('after waiting 5 seconds', function () {
    it('shows the \'prompt\' message', function () {
      $scope.message = '';

      $timeout.flush(4900);
      expect($scope.message).toEqual('');

      $timeout.flush(200);

      expect($scope.message).toEqual('Hi!');
      expect(messagingService.getMessage)
              .toHaveBeenCalledWith('prompt');
    });
  });
});
            

Add a Test


describe('messaging.flashController', function () {
  // tests and setup

  describe('after waiting 5 seconds', function () {
    it('shows the \'prompt\' message', function () {
      $scope.message = '';

      $timeout.flush(4900);
      expect($scope.message).toEqual('');

      $timeout.flush(200);

      expect($scope.message).toEqual('Hi!');
      expect(messagingService.getMessage)
              .toHaveBeenCalledWith('prompt');
    });
  });
});
            
Expected '' to equal 'Hi!'.

Add a Test


describe('messaging.flashController', function () {
  // tests and setup

  describe('after waiting 5 seconds', function () {
    it('shows the \'prompt\' message', function () {
      $scope.message = '';

      $timeout.flush(4900);
      expect($scope.message).toEqual('');

      $timeout.flush(200);

      expect($scope.message).toEqual('Hi!');
      expect(messagingService.getMessage)
              .toHaveBeenCalledWith('prompt');
    });
  });
});
            
Expected spy getMessage to have been...

Make it pass!


angular.module('messaging')
  .controller('messaging.flashController', function($scope,
                                             $timeout,
                                             messagingService) {
    $scope.message = messagingService.getMessage('initial');




  });
          

Make it pass!


angular.module('messaging')
  .controller('messaging.flashController', function($scope,
                                             $timeout,
                                             messagingService) {
    $scope.message = messagingService.getMessage('initial');

    $timeout(function () {
      $scope.message = messagingService.getMessage('prompt');
    }, 5000);
  });
          

Make it pass!


angular.module('messaging')
  .controller('messaging.flashController', function($scope,
                                             $timeout,
                                             messagingService) {
    $scope.message = messagingService.getMessage('initial');

    $timeout(function () {
      $scope.message = messagingService.getMessage('prompt');
    }, 5000);
  });
          

Refactor?


angular.module('messaging')
  .controller('messaging.flashController', function($scope,
                                             $timeout,
                                             messagingService) {
    $scope.message = messagingService.getMessage('initial');

    $timeout(function () {
      $scope.message = messagingService.getMessage('prompt');
    }, 5000);
  });
          

Refactor!


angular.module('messaging')
  .controller('messaging.flashController', function($scope,
                                             $timeout,
                                             messagingService) {
    function setMessage(type) {
      $scope.message = messagingService.getMessage(type);
    }

    setMessage('initial');

    $timeout(function () {
      setMessage('prompt');
    }, 5000);
  });
          

Refactor!


angular.module('messaging')
  .controller('messaging.flashController', function($scope,
                                             $timeout,
                                             messagingService) {
    function setMessage(type) {
      $scope.message = messagingService.getMessage(type);
    }

    setMessage('initial');

    $timeout(function () {
      setMessage('prompt');
    }, 5000);
  });
          

Feature

Show the current user


Welcome {{currentUser}}!

{{message}}

Setup


describe('users.currentController', function () {
  var $scope, usersService, deferred;
  beforeEach(module('users'));

  beforeEach(inject(function ($rootScope, $controller, $q,
                              _usersService_) {
    $scope = $rootScope.$new();
    usersService = _usersService_;





    $controller('users.currentController', {
      $scope: $scope,
      usersService: usersService
    });
  }));

  // tests go here
});
            

Setup


describe('users.currentController', function () {
  var $scope, usersService, deferred;
  beforeEach(module('users'));

  beforeEach(inject(function ($rootScope, $controller, $q,
                              _usersService_) {
    $scope = $rootScope.$new();
    usersService = _usersService_;

    deferred = $q.defer();



    $controller('users.currentController', {
      $scope: $scope,
      usersService: usersService
    });
  }));

  // tests go here
});
            

Setup


describe('users.currentController', function () {
  var $scope, usersService, deferred;
  beforeEach(module('users'));

  beforeEach(inject(function ($rootScope, $controller, $q,
                              _usersService_) {
    $scope = $rootScope.$new();
    usersService = _usersService_;

    deferred = $q.defer();
    spyOn(usersService, 'getCurrent')
      .and.returnValue(deferred.promise);

    $controller('users.currentController', {
      $scope: $scope,
      usersService: usersService
    });
  }));

  // tests go here
});
            

Add a Test


describe('when the controller loads', function () {
  it('sets the current user', function () {






  });
});
            

Add a Test


describe('when the controller loads', function () {
  it('sets the current user', function () {
    expect($scope.currentUser).toEqual('Loading');





  });
});
            

Add a Test


describe('when the controller loads', function () {
  it('sets the current user', function () {
    expect($scope.currentUser).toEqual('Loading');

    deferred.resolve('@walken20');



  });
});
            

Add a Test


describe('when the controller loads', function () {
  it('sets the current user', function () {
    expect($scope.currentUser).toEqual('Loading');

    deferred.resolve('@walken20');
    $scope.$apply();


  });
});
            

Add a Test


describe('when the controller loads', function () {
  it('sets the current user', function () {
    expect($scope.currentUser).toEqual('Loading');

    deferred.resolve('@walken20');
    $scope.$apply();

    expect($scope.currentUser).toEqual('@walken20');
  });
});
            

Add a Test


describe('when the controller loads', function () {
  it('sets the current user', function () {
    expect($scope.currentUser).toEqual('Loading');

    deferred.resolve('@walken20');
    $scope.$apply();

    expect($scope.currentUser).toEqual('@walken20');
  });
});
            
Expected undefined to equal 'Loading'.

Add a Test


describe('when the controller loads', function () {
  it('sets the current user', function () {
    expect($scope.currentUser).toEqual('Loading');

    deferred.resolve('@walken20');
    $scope.$apply();

    expect($scope.currentUser).toEqual('@walken20');
  });
});
            
Expected undefined to equal '@walken20'.

Make it Pass


angular.module('users')
  .controller('users.currentController', function($scope,
                                               usersService) {





  });
            

Make it Pass


angular.module('users')
  .controller('users.currentController', function($scope,
                                               usersService) {
    $scope.currentUser = 'Loading';




  });
            

Make it Pass


angular.module('users')
  .controller('users.currentController', function($scope,
                                               usersService) {
    $scope.currentUser = 'Loading';

    usersService.getCurrent().then(function (result) {
      $scope.currentUser = result;
    });
  });
            

Make it Pass


angular.module('users')
  .controller('users.currentController', function($scope,
                                               usersService) {
    $scope.currentUser = 'Loading';

    usersService.getCurrent().then(function (result) {
      $scope.currentUser = result;
    });
  });
            

Refactor?


angular.module('users')
  .controller('users.currentController', function($scope,
                                               usersService) {
    $scope.currentUser = 'Loading';

    usersService.getCurrent().then(function (result) {
      $scope.currentUser = result;
    });
  });
            

Refactor!


angular.module('users')
  .controller('users.currentController', function($scope,
                                               usersService) {
    function setCurrentUser(user) {
      $scope.currentUser = user;
    }

    setCurrentUser('Loading');

    usersService.getCurrent().then(setCurrentUser);
  });
            

Refactor!


angular.module('users')
  .controller('users.currentController', function($scope,
                                               usersService) {
    function setCurrentUser(user) {
      $scope.currentUser = user;
    }

    setCurrentUser('Loading');

    usersService.getCurrent().then(setCurrentUser);
  });
            
  • You should be testing your javascript.
  • TDD results in clean, maintainable code.
  • Jasmine + AngularJS is a great way to start!

Thanks!