Chris Moultrie
- Basic Jasmine Overview
- Karma
- Loading Modules
- Dependency Injection
- Controller Construction
- ngMock
- $httpBackend
- $log
- $timeout
- dump
- BDD Framework (Fits well into TDD as well)
- Simple ideas, lots of expandability
- Angular's Default Testing Framework
- Used to test as you develop
- Commit any sins necessary
- Live for the GREEN
- Tells a Story
- Test the 'intent' of the code
- Avoid having deep knowledge of code being tested
- Change to implementation should not affect test
- Test functionality on a micro-level
- Use deep knowlege to test all code paths
- Implementation change typically breaks test
describe("A Suite of Tests", function() {
beforeEach( function() {
some.setup();
this.myVar = true;
});
afterEach( function() {
some.teardown();
});
it("Verifies that we did some setup", function() {
expect(this.myVar).toBeTruthy();
});
});
- Matchers
- Spies
- Mock Clock
- Async Tests
-
functions on the object that
expect
returns -
object equality:
.toEqual()
-
truthiness/falsiness:
.toBeTruthy()
/.toBeFalsy()
-
Array Contains:
.toContain('abc')
-
['def', 'abc', '123']
-
Fuzzy value matching
-
.toBeGreaterThan()
/.toBeLessThan()
-
.toBeCloseTo()
-
jasmine.any()
-
Number
-
Object
-
Function
spyOn()
createSpy()
&&createSpyObj()
.andCallThrough()
.andReturn()
.andCallFake()
- Mock Clock (
$timeout
in Angular.js) - Async Support (
$httpBackend
solves most of this)
- Framework Agnostic TestRunner
- Allows code testing in multiple browsers
- Integrates with PhantomJS (great for background test running)
- Great for CI Servers (like Jenkins or Travis)
- Add a grunt task via grunt-karma
basePath = '../';
files = [
JASMINE,
JASMINE_ADAPTER,
'static/src/vendor/angular/angular-*.js',
'static/js/code.min.js',
'static/js/spec.min.js'
];
autoWatch = true;
reporters = ['progress']
browsers = ['PhantomJS'];
Karma can do some cool preprocessing like analyzing code coverage with Istanbul or auto-compile coffeescript before running the tests.
Let's go look at that real quick.
- Load the module needed
- Ask for your dependencies
- Spy on your dependencies
- Construct your controller
It's so simple!
Load up whatever module you're about to test
describe("Navbar Testing", function() {
beforeEach(module("EG.navbar"));
});
module()
calls out to angular to pull in the module you want plus any
dependencies.
describe('Timeline Controller', function() {
beforeEach( inject( function($rootScope, $controller) {
this.scope = $rootScope.$new();
this.controller = $controller;
});
});
This works for any dependency that is available after loading your module(s), even ones you've created and registered as a factory.
Inject within beforeEach is a great time to set up some spies.
beforeEach(inject( function($rootScope, $controller) {
this.scope = $rootScope.$new();
spyOn(this.scope, '$emit');
this.controller = $controller;
});
it("Emits 'reposition' whenever the alert is opened", function() {
var controller = this.controller('AlertInfoCtrl',
{$scope:this.scope});
this.scope.toggleOpen();
expect(this.scope.$emit).toHaveBeenCalledWith('reposition');
});
Now every time we create a controller with our @scope
our spy will be hanging
in the shadows listening for calls to $emit
var NavbarCtrl = function($scope, $http, $log, mapService) {
// Some Logic goes here
$scope.scrollMap = function(direction) {
mapService.scrollLeft();
}
});
angular.module('EG.navbar', ['EG.navbar.directives'])
.controller('NavbarCtrl',
['$scope', '$http', '$log', 'mapService', NavbarCtrl]);
beforeEach(inject(function($rootScope, $controller) {
this.scope = $rootScope.$new();
this.controller = $controller;
this.mapService = jasmine.createSpyObj('mapService',
['resize', 'scrollLeft', 'scrollRight']);
});
it("Emits 'reposition' whenever the alert is opened", function() {
var controller = this.controller('NavbarCtrl',
{$scope:this.scope, mapService:this.mapService});
this.scope.scrollMap('left');
expect(this.mapService.scrollLeft).toHaveBeenCalled();
});
We only manually provided $scope
and mapService
, Angular's DI service filled in
the rest of the gaps.
Angular will not provide $scope
, you must provide that yourself, everything
else that it has, it will provide.
By injecting the $controller
service you can ask Angular to construct any
controller it knows about, but using your own spies.
- $httpBackend
- $log
- $timeout
- angular.mock.dump
Testing web calls sucks, so no one does it. Thankfully with Angular's DI service and the $http service our async web calls are crazy easy to test.
Another advantage of using the $http service is that Angular is promise aware,
so it knows when your request is done and will then run a $digest/$apply
across your scope to update anyone watching variables in scope
Consider the following Controller:
var NavbarCtrl = function($scope, $http, $log) {
success = function(data, status, headers, config) {
// If we don't have data, bail
if (data == null) {
$log.error("Issue with data", data,
status, headers, config);
return;
}
// Save the current chats
$scope.currentChats = data;
}
// If things go horribly wrong, we should tell someone
// but don't ask me, I'm not in charge here.
fail = function(data, status, headers, config) {
$log.error("ChatsError", data, status, headers, config);
}
$http.get("api/chats").success(success).error(fail);
}
To Test:
describe("NavbarCtrl", function() {
beforeEach(inject(function($controller, $rootScope, $httpBackend) {
this.httpBackend = $httpBackend;
this.scope = $rootScope.$new();
this.controller = $controller;
}));
it("Creates a navbar controller", function() {
var expected = {test: true};
this.httpBackend.expectGET('api/chats')
.respond(expected);
var controller = this.controller('NavbarCtrl',
{$scope:this.scope});
this.httpBackend.flush();
expect(this.scope.currentChats).toEqual(expected);
});
});
Now we have control over when the response fires and what the response is.
console.log
is ugly when testing. You have to wade through a whole bunch of
lines that just muck up your reporter's status. $log
wraps console.log
for you
(and .error
, .info
, etc).
When using ngMock
a mock version of $log
is automatically injected instead
of the regular one. This is great for a few reasons.
- No more mucking up the console where your test runner is
- You can ask
$log
how many and what messages were logged. This can be another test. - You can actually see what was logged, especially if you want to test that a specific message was logged to a logger.
Example:
describe("NavbarCtrl", function() {
beforeEach(inject(function($controller, $rootScope, $httpBackend, $log) {
this.httpBackend = $httpBackend;
this.scope = $rootScope.$new();
this.log = $log;
this.controller = $controller;
}));
it("Creates a navbar controller", function() {
var expected = {test: true};
this.httpBackend.expectGET('api/chats')
.respond(expected);
var controller = this.controller('NavbarCtrl', {$scope:this.scope});
this.httpBackend.flush();
// If we have errors, something was unexpected!
expect(this.log.error.logs.length).toEqual(0);
});
});
Often we use window.setTimeout
to postpone a task to run at a later time.
Angular provides the $timeout service which follows the promise model like that
used with $http.
Benefits of $timeout:
- Provides a
cancel()
method on the returned object - Will do scope dirty checks after $timeout has returned
- Has its own mock!
Almost the same as $timeout, except it adds flush()
. This is just like
$httpBackend.flush()
in that all calls can then be synchronously run without
needing callbacks or async testing code.
Ever tried to JSON.stringify a scope object? Test runner can't do it.
Consider the following:
console.log(JSON.stringify(this.scope));
TypeError: JSON.stringify cannot serialize cyclic structures.
Better:
dump(this.scope);
PhantomJS 1.9 (Mac) LOG: """ Scope(00E): {\n
currentChats: {"unread":0}\n chatsUnread: ""\n }"""
ALL GLORY TO THE HYPNOTOAD