Angular Testing

Presented By

Jimmy Tu

@jimee_02

Trends

  • New frameworks are testable
  • Testing skills on job postings
  • Open source packages include tests
  • Companies downsizing their QA

What the Industry realized

with test coverage

without test coverage

Complexity / Time

TDD teams experienced a 15-35% increase in initial development time after adopting TDD

http://research.microsoft.com/en-us/groups/ese/nagappan_tdd.pdf

Defects dropped 40% - 90%

What the Industry realized

Testing Saves Time

Time === $$$

Main Objective

Git Er Done

What Should You Test?

Rit: YES

Manager: Everything! ...but don't miss a deadline.  AGILE!

Blogs: All the things

Spouse: What does A/B testing have to do with our marriage?!

Jasmine Structure

// xdescribe() {...}  disables a test suite
// fdescribe() {...}  focuses on a test suite
describe( 'A logical block/context with similar setup data to test', function() {

    beforeEach(function(){
        // Given
    });

    afterEach(function(){
        // Cleanup or a consistent expectation across the test scenarios
    });

    // xit() {...}  disable test
    // fit() {...}  focuses on a test
    it('the situation that is being tested', function () {
        // Some code that causes behavior / changes state
        // an expectation of the result future state 
        expect(object.parameter).toEqual(true);  
    });

});
describe('How to load your modules and retrieve your instances', function() {
    var $httpBackend;

    beforeEach(function(){
        angular.mock.module('myAppModule'); // load the module and all its providers
        angular.mock.module('anEntirelySeperateModuleNotRequiredByTheFirst');
    });

    // retrieve providers from module
    beforeEach( angular.mock.inject( function (_$httpBackend_, _$http_, $injector) { 
        // either of these techniques will retrieve your instance
        $httpBackend = _$httpBackend_;
        $httpBackend = $injector.get('$httpBackend');
    }));

    afterEach(function(){
        $httpBackend.verifyNoOutstandingRequest()
    });
});

Angular Structure

describe('Title Case Filter', function () {
    var titleCaseFilter;
    beforeEach(module('myModuleWithMyTestSubject'));

    beforeEach(inject(function (_titleCaseFilter_) {
        // Setup - Retrieve reference of your filter
        titleCaseFilter = _titleCaseFilter_;
    }));

    it('should capitalize the first character of each word', function  () {
        var testText = 'the cake is a lie';  // Setup
        var filteredText = titleCaseFilter(testText); // Execute behavior being tested

        expect(filteredText).toBe('The Cake Is A Lie'); // Expect the outcome
    });

    it('should convert hyphens and underscores to spaces', function () {
        // streamlined test cases into 1-liners
        expect(titleCaseFilter('my-4-star-reivew')).toBe('My 1 Star Review');
        expect(titleCaseFilter('my_1_star')).toBe('My 1 Star');
    });
});

Filters

describe('User Service', function () {
    var userService;
    var $httpBackend;
    beforeEach(module('myModuleWithMyTestSubject'));

    beforeEach(inject(function (_userService_, _$httpBackend_) {
        userService = _userService_;
        $httpBackend = _$httpBackend_;
    }));

    // async test
    it('#getCurrentUser() should retrieve the current user profile', function  (done) {
        // Mocking request (Setup)
        // use a regex to cut down on changes in the path, but make it descriptive enough 
        // to understand what endpoint it is trying to hit
        $httpBackend.expectGET(/self/)).respond({id: 'test-id', username: 'test-username'};

        // Execute behavior
        userService.getCurrentUser()
            .then(function(userProfile){
                // Expectation
                expect(userProfile.id).toEqual('test-id');
                done();  // end the test
            });
        $httpBackend.flush();
    });
});

Service

Recap

Expectations

Mocking external dependencies

  1. Setup the test
  2. Cause the behavior
  3. Test for expected behavior

Test structure:

describe('User', function () {
    it("profile", function () {
        var testUserProfile = {"test-param", ''};

        // mock call and return a promise
        spyOn(userService, 'getCurrentUser').and.callFake(function() {
            var deferred = $q.defer();
            deferred.resolve(testUserProfile);
            return deferred.promise;
        });
        $scope = $rootScope.$new();
        userController = $controller('UserController',
                {$scope: $scope, userService: userService});

        $scope.$apply(); // Cause a digest loop to resolve promises
        expect(userService.getCurrentUser).toHaveBeenCalled();
        expect($scope.user).toEqual(testUserProfile);
    });
})

The Man of few Words


it('should retrieve user information from the api', function(){
    $httpBackend.whenGet('/users').respond({});

    // ...a bunch of commented out code...

    expect(true).toEqual(true);
});

it('should handle api errors gracefully', function(){
    $httpBackend.whenGet('/users').respond(404);

    // ...a bunch of commented out code...

    expect(true).toEqual(true);
});

The Liar


it('...', function(){
    $httpBackend
        .whenGet('http://domain.com:3000/users/uncles/aunts/brother-in-law').respond({});

});

it('...', function(){
    $element.find('div>div>div>ul>li>a.profile-link').click();
});

Mr. Glass

describe('User', function () {
    it('user profile should display all their information and handles errors' +
           'and does this... and...', function(done){

        userService.getUsers()
            .then(checkUserProfile)
            .then(checkUsersLength)
            .finally(done)

        $httpBackend.flush(); // resolve pending http requests
        $scope.$apply();  // q$ promises only resolve in a digest loop

        function checkUserProfile (users) {    
            expect(users.status).toLessThanEqualTo('.99');
            return users
        }
    
        function checkUsersLength (error) {
            expect(users.length).toEqual(5);        
        }
    });
});

All-in-One

describe('Users Types', function () {
    var userTypes;
    beforeEach(function  () {
        dataForTest1And2 = DATA;
        dataForTest1And3 = DATA;
        dataForTest2And3And4 = DATA;
    });
    it('Test 1 user should ...', function(){};
    it('Test 2 user should ...', function(){};
    it('Test 3 user should ...', function(){};
    it('Test 4 user should ...', function(){};
});

One for All

it('...', function () {
    var data = {
        "inner": {
            "max_position": "685025353593696256",
            "has_more_items": false,
            "items_html": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",
            "new_latent_count": 0
        },
        "note": {
            "d": { "status": "ok", "response": null },
            "n": { "status": "ok", "response": { "latest": 1449624367, "toasts": [] } },
            "b": {
                "status": "ok",
                "response": {
                    "count": 1,
                    "localized_count": "1",
                    "timestamp": -1,
                    "new_timestamp": -1,
                    "show_badge_highlighting": true,
                    "success": false
                }
            },
            "t": { "status": "ok", "response": null }
        }
    }
    expect(data.note.d.status).toEqual('ok');
});

Potential Post-coding-test-writing Poser

  it("The 'toBeDefined' matcher compares against `undefined`", function() {
    var a = {
      foo: "foo"
    };

    expect(a.foo).toBeDefined();
    expect(a.bar).not.toBeDefined();
  });

  it("The `toBeUndefined` matcher compares against `undefined`", function() {
    var a = {
      foo: "foo"
    };

    expect(a.foo).not.toBeUndefined();
    expect(a.bar).toBeUndefined();
  });

  it("The 'toBeNull' matcher compares against null", function() {
    var a = null;
    var foo = "foo";

    expect(null).toBeNull();
    expect(a).toBeNull();
    expect(foo).not.toBeNull();
  });

Your Father's Unit Test

describe('User Controller', function () {
  var userService, $httpBackend, $controller, $scope;
  beforeEach(module('myModuleWithMyTestSubject'));
  beforeEach(inject(function (_userService_, _$httpBackend_, _$controller_, $rootScope) {
    // ..save providers into variables
  }));

  describe('on initialization', function () {
    var userController;

    it("retrieves the current user's profile to display it", function () {
      var testUserProfile = { "test-param", '' };
      $httpBackend.expectGET(/self/).respond(testUserProfile);
      $scope = $rootScope.$new();
      userController = $controller('UserController', { $scope: $scope });

      $httpBackend.flush();

      expect($scope.user).toEqual(testUserProfile);
    });
  });
});

Not your father's unit test (controller)

var user;
function getUser (id) {
  if(!user) {
    return userService.getUser(id)
      .then(function  (userProfile) {
        user = userProfile;

        return userProfile;
      })
  } else {
    return user;
  }
}

Cludge

How To Test Reference

describe('User Controller', function () {
    var userService, $httpBackend, $controller, $scope;
    beforeEach(module('myModuleWithMyTestSubject'));
    beforeEach(inject(function (_userService_, _$httpBackend_, _$controller_, $rootScope) {
        // ..save providers into variables
    }));

    describe('on initialization', function () {
        var userController;
        it("should retrieve the current user's profile and save it to the vm", function () {
            var testUserProfile = {"test-param", ''};

            // mock call and return a promise
            spyOn(userService, 'getCurrentUser').and.callFake(function() {
                var deferred = $q.defer();
                deferred.resolve(testUserProfile);
                return deferred.promise;
            });
            $scope = $rootScope.$new();
            userController = $controller('UserController', 
                                {$scope: $scope, userService: userService});

            $scope.$apply(); // Cause a digest loop to resolve promises
            expect(userService.getCurrentUser).toHaveBeenCalled();
            expect($scope.user).toEqual(testUserProfile);
        });
    });
});

Controller using a spy

Directive

describe('User Profile Directive', function () {
    // .. variables
    // beforeEach( ... );

    // interaction tests
    describe('on initialization', function () {
        it("should display the user name when it is available", function () {
            var $scope = $rootScope.$new();
            var element = angular.element('<div user-profile="user"></div>');
            $scope.user = {
                name: 'test-name'
            };
            $compile(element)($scope);
            $scope.$digest();
            expect(element.html()).toContain($scope.user.name);
        });
    });
});

Directive external function

describe('User Profile Directive', function () {
    // .. variables
    // beforeEach( ... );

    // interaction tests
    describe('after initialization', function () {
        it("should execute the callback when the user has changed", function () {
            var $scope = $rootScope.$new();
            var element = angular.element('<div user-profile="user" ' + 
                                                'user-on-changed="updateStuff()"></div>');
            $scope.user = {};

            $scope.updateStuff = jasmine.createSpy('updateStuffSpy');
            $compile(element)($scope);
            $scope.$digest();
            el.querySelector('.change-user').click();
            expect($scope.updateStuff).toHaveBeenCalled();
        });
    });
});

Directive with service (bad)

describe('User Profile Directive', function () {
    // .. variables
    beforeEach(function  () {
        angular.mock.module('myAppsModule', function($provide) {
            $provide.decorator('userService', function($delegate) {
                spyOn($delegate, 'getUser');
                return $delegate;
            });
        })
    });
    // beforeEach( ... ); // injector

    // interaction tests
    describe('on initialization', function () {
        it("updateUser() should retrieve new user information from the api", function () {
            var $scope = $rootScope.$new();
            var element = angular.element('<div user-profile="user"></div>');
            $scope.user = {
                name: 'test-name'
            };

            $compile(element)($scope);
            $scope.$apply();

            // ..logic to cause userService.getUser() to be executed

            expect(userService.getUser).toHaveBeenCalled();
     });});});

Directive with service v2

describe('User Profile Directive', function () {
    // .. variables
    // beforeEach( ... );

    // interaction tests
    describe('on initialization', function () {
        it("updateUser() should retrieve new user information from the api", function () {
            var $scope = $rootScope.$new();
            var element = angular.element('<div user-profile="user"></div>');
            
            spyOn(userService, 'getUser').and.callFake(function() {
              var deferred = $q.defer();
              deferred.resolve({});
              return deferred.promise;
            });
            $scope.user = {
                name: 'test-name'
            };

            $compile(element)($scope);
            $scope.$apply();

            // ..logic to cause userService.getUser() to be executed

            expect(userService.getUser).toHaveBeenCalled();
        });
    });
});

Directive controller method

describe('User Profile Directive', function () {
    // .. variables
    // beforeEach( ... );

    // interaction tests
    describe('on initialization', function () {
        it("addNote() should add a note", function () {
            var $scope = $rootScope.$new();
            var element = angular.element('<div user-profile="user" user-on-changed="updateStuff()"></div>');
            $scope.user = {};
            $scope.updateStuff = jasmine.createSpy('updateStuffSpy');

            $compile(element)($scope);
            $scope.$digest();
            
            controller = element.controller('UserProfile');
            controller.addNote('');
            expect($scope.notes.length).toBeGreaterThan(0);
        });
    });
});

Directive requiring another directive

describe('User Profile Directive', function () {
    // beforeEach( ... );
    describe('when changing account tab', function () {
        it("should not change the tab if the user is editing her profile", function () {
            var $scope = $rootScope.$new();

            // Wrap the directive in a containing element
            var element = angular.element('<div><div user-profile="user"></div></div>');
            var mockTabsController = jasmine.createSpyObj('mockTabsController', ['changeTab']);
            var elementScope;

            // Set tabsController in the containing element
            // assumes that user-profile has a require: '^tabs'
            element.data("$tabsController", mockTabsController);

            $compile(element)($scope);
            $scope.$apply();
            elementScope = element.isolateScope();
            elementScope.edit();  // turn on edit mode

            // ...logic to cause a change in tab...
            element.find('button').click();

            expect(mockTabsController.changeTab).not.toHaveBeenCalled();
        });
    });
});

Testing is an Art

There are no clear right vs wrong as long as your test covers the behavior properly.

Resilient

Protects

Informs

Coverage

Readable

Less Work

References/Resouces

Angular Testing

By jimee02

Angular Testing

A walkthrough of the path of least resistance when writing tests in Angular.js app.

  • 1,240