Testing AngularJS Part 3 - Controllers with Dependencies
This post builds off of the previous post which covers setting up your Angular project with Karma, Jasmine, and ngMock, as well as testing a basic controller with no external dependencies. If you haven't gone through that one yet, you'll probably want to start there first.
For repetition's sake, and for setting up our testing of more complex controllers, we'll start with another basic controller example with no external dependencies.
Testing a basic controller with no dependencies:
Usually a controller will have its own functions, will depend on external services, etc. We'll get to those later, but for now we'll just focus on the syntax of injecting the controller into our test and making sure the right $scope
came with it. Below we're testing 2 variables on the scope, which isn't strictly necessary since if one variable is working correctly we can probably assume the other variables are too (we don't need to test every single property on the $scope object unless that property is a function).
Controller:
var app = angular.module("ControllerTest", []);
app.controller("TestController", ["$scope", function ($scope) {
$scope.test = "Test";
$scope.things = ["baseball", "keys", "lint", "orange"];
}]);
Test for Controller:
If any of this looks unfamiliar to you, please revisit
describe("Controller Testing Suite", function () {
var $scope;
beforeEach(module("ControllerTest"));
beforeEach(inject(function ($controller, $rootScope) {
$scope = $rootScope.$new();
$controller("TestController", {$scope: $scope});
}));
it("should have the right text for $scope.test", function () {
expect($scope.test).toBe("Test");
});
it("should have keys in $scope.things", function () {
expect($scope.things).toContain("keys");
});
});
Hopefully this isn't too unfamiliar, since we've already covered this ground before, but it's always good to review.
Testing a controller that uses an outside service
As you should know by now, your controller should mostly only be used to communicate things between services and the view. This is sometimes called "view logic", since it's mostly in charge of manipulating the HTML page and not in charge of performing other "business logic", such as saving a token to local storage, crunching numbers in a calculator, making $http
calls, etc. Those operations should be performed in a service/factory.
We'll cover unit testing services in another post, but for now we need to learn how to test a controller function that relies (or depends... as in "dependecy injection") on an external service. So let's create a basic service that does some string manipulation and a controller that uses that service:
var app = angular.module("ControllerTest", []);
app.service("StringService", function() {
this.addExcitement = function (str) {
return str + "!!!";
};
});
app.controller("ServiceTestController", ["$scope", "StringService", function ($scope, StringService) {
$scope.boringString = "Hello world";
$scope.excitement = function (boringString) {
$scope.resultingString = StringService.addExcitement(boringString);
};
}]);
We should always mock a service's methods and properties
It's important to keep in mind that our current focus is testing controllers. We're not interested in unit testing the service at the same time as testing our controller.
In the above service, let's say we decided to change the way StringService.addExcitement()
works - maybe it only adds two exclamation points instead of three. If we used the real addExcitement()
method in our testing of the controller, we'd start having broken controller tests even though the controller may be working correctly.
Enter mocking. Instead of injecting StringService
and using its real methods, we can simply create a fake version of the service's methods that our test controller will call. This allows us to control exactly what the mock service returns, and then we can test that the control is acting as expected based on what the mock service returned.
Thanks to the Jasmine framework's API and the ngMock module, there are a bunch of ways we can do this mocking. We'll discuss two primary approaches:
- Inject the real service into the test and use either Jasmine's
and.callFake()
orand.returnValue()
method to make the method do something different than what it really does. - Mock the service from scratch and create Jasmine spies (which are most easily understood as "objects with fake functions") and write them to return whatever we actually want them to return.
There's debate among those in the testing community as to which one is better. We're going to cover the first approach here, for one main reason: to avoid something you might call API drift. API drift is when the method names, function names, service names, code implementations, etc. (everything that makes up your application's API) change over time, but your tests remain the same. If you change the name of a service's method, for example, your controller that calls that method will be broken, but your test will continue to pass.
If you inject the real service and just return fake values from it, you get the added benefit of forcing yourself to keep your tests as up-to-date as possible. If you change a method name in your service, your test will break until you fix the reference to that method name in your controller and in your tests. This way, you can completely sidestep API drift in your code and tests.
Let's walk through how to do this:
Inject the real service, but use and.callFake()
or and.returnValue()
to make a fake function
describe("ServiceTestController Testing Suite", function () {
var $scope, mockStringService;
beforeEach(module("ControllerTest"));
// Inject the real StringService
beforeEach(inject(function ($controller, $rootScope, StringService) {
$scope = $rootScope.$new();
// Set mockStringService equal to the real service code.
mockStringService = StringService;
// Turn the mockStringService into a Jasmine spy and call a fake function that
// basically does the same thing as the real one, but now this one is isolated
// to just this test.
spyOn(mockStringService, "addExcitement").and.returnValue("Doesn't matter what this says, we're not testing the result");
$controller("ServiceTestController", {$scope: $scope, StringService: mockStringService});
}));
it("should make a string more exciting", function () {
var boringString = "Hey";
$scope.excitement(boringString);
// We only care that the function was called, we don't need to test the value that got returned.
expect(mockStringService.addExcitement).toHaveBeenCalled();
});
});
Tests using $http
A controller shouldn't be where you make calls out to the internet to get data (that should be done in a service), but for the sake of understanding, we're going to show a controller that does use $http
, and then we'll cover it again later when we talk about how to unit test services.
From the AngularJS ngMock Docs:
"During unit testing, we want our unit tests to run quickly and have no external dependencies so we don’t want to send XHR or JSONP requests to a real server. All we really need is to verify whether a certain request has been sent or not, or alternatively just let the application make requests, respond with pre-trained responses and assert that the end result is what we expect it to be."
So basically instead of really sending out requests to the internet, we're going to fake the request to the internet, and also possibly respond with fake data. We'll be using ngMock's $httpBackend
service to do this.
The $httpBackend
service is pretty great, in fact. It gives us a couple ways to set things up - we can either expect that a specific call will be made by our controller, or we can set up ways to respond when a call to any given endpoint is made. The .expect()
method will actually run a test to make sure that a call made to the specified url and with a specified method actually was made. If it wasn't, the test will fail. Alternatively, the .when()
block will just set up ways for the $httpBackend
to respond when a certain call is made to the specified url with the specified method. In other words, using the .expect()
method is actually running a test, whereas using the .when()
method helps you create a fake backend right there inside your test so that if it does make a request, it knows how to respond correctly.
Let's cover a couple examples of using this: 1) when a page loads and the controller immediately runs an $http
call (perhaps to gather some data that needs to be displayed right away), and 2) when a function calls the $http
method on command, perhaps when the user clicks a button.
Immediately executed $http
call
// controller:
var app = angular.module("ControllerTest", []);
app.controller("HttpTestController", ["$scope", "$http", function ($scope, $http) {
$scope.getReindeer = function () {
$http.get("http://localhost:9000/reindeer").then(function (response) {
$scope.reindeer = response.data;
});
};
}]);
In the above controller, you can see there's a server that our controller is reaching out to for data, specifically an array of reindeer. If you're familiar with Node.js/Express, a super basic server that would handle this kind of request could look like this:
// server.js
...
var reindeer = ["Dasher", "Dancer", "Prancer", "Vixen", "Comet", "Cupid", "Donner", "Blitzen", "Rudolph"];
app.get("/reindeer", function (req, res) {
res.send(reindeer);
});
...
In keeping with the philosophy that we need to be very exclusive with what we're testing in any given test (since these are unit tests, after all), we don't want to actually make a call out to a server. If we did, and perhaps the organization of data or the actual data itself from the server changed someday, our tests would begin failing even though our controller is still receiving data correctly. Instead, we can use the $httpBackend
service to intercept the outgoing request and respond with mock data, then make sure that our $scope.reindeer
got set accordingly. Let's set everything up:
// test file:
describe("HttpTestController Suite", function () {
// Since we could be using $httpBackend in every test, we add a reference here.
var $scope, $httpBackend;
beforeEach(module("ControllerTest"));
// inject the $httpBackend service. Remember, if you want to use the same name
// ($httpBackend) instead of renaming it, you'll need to wrap the injected service
// in underscores so there isn't a naming conflict.
beforeEach(inject(function ($controller, $rootScope, _$httpBackend_) {
$scope = $rootScope.$new();
$httpBackend = _$httpBackend_;
$controller("HttpTestController", {$scope: $scope});
}));
// These get run after each it() block and just make sure that everything happened as expected,
// and makes sure everything is set up correctly to run for the next test.
// It's a good idea to make sure this is included anytime you're going to be using the $httpBackend mock service
afterEach(function () {
$httpBackend.verifyNoOutstandingExpectation();
$httpBackend.verifyNoOutstandingRequest();
});
it("should have all the reindeer available on $scope.reindeer", function () {
// Since we're running the $http.get call immediately when the controller loads,
// we are safe to expect that it will happen. This line below both sets the expectation
// that the $http.get call will be made, AND responds with an array of data. It's not
// quite as important WHAT data it responds with, because that's not really the important
// part of our controller.
$httpBackend.expectGET("http://localhost:9000/reindeer").respond(["Dasher", "Dancer"]);
// The $httpBackend.flush() method basically "runs" the request it intercepted. This has to
// do with the importance of our tests being synchronous, but it's more important to know
// that you NEED to run this method if you're ever going to get a passing test.
$httpBackend.flush();
// Since the $http.get ran on controller load, it should have set $scope.reindeer to what got returned
// We told the fake backend to return ["Dasher", "Dancer"], so we can assume that $scope.reindeer
// contains "Dasher".
expect($scope.reindeer).toContain("Dasher");
});
});
$http
call executed by a function
Let's assume for the purposes of demonstration that we're not really that interested in exactly when the $http
call is made, but just that whenever it is made, we respond correctly.
The $httpBackend
service gives us the ability to set up a mock API where we can specify certain return values if any given API call is made at any particular time. To do this, we use the whenGET()
method instead. (Each HTTP method has its corresponding $httpBackend
method too: whenPOST()
, whenPUT()
, etc.)
Considering our new controller where the $http
call isn't made on page load:
app.controller("HttpTestController", ["$scope", "$http", function ($scope, $http) {
$scope.getReindeer = function () {
$http.get("http://localhost:9000/reindeer").then(function (response) {
$scope.reindeer = response.data;
});
}
}]);
we can now set up our fake API in our beforeEach method, so that it can be used in any future tests we may want to write dealing with making $http
calls:
beforeEach(inject(function ($controller, $rootScope, _$httpBackend_) {
$scope = $rootScope.$new();
$httpBackend = _$httpBackend_;
$controller("HttpTestController", {$scope: $scope});
$httpBackend.whenGET("http://localhost:9000/reindeer").respond(["Dasher", "Dancer"]);
}));
it("should make an HTTP call when $scope.getReindeer is triggered", function () {
// Trigger the function that makes the $http call, then use the flush() method to actually make the call.
// This will call the previously-defined "whenGET" method we wrote in the beforeEach block
$scope.getReindeer();
$httpBackend.flush();
expect($scope.reindeer).toContain("Dasher");
});
In this instance you might see how the whenGET()
method is more flexible. (We could have used it for the previous example on immediately-executed $http
calls as well). My personal preference is to use the .when
methods to define a mock backend API, but if I ever need to make sure that HTTP requests are going out in a specific order, the .expect
methods come in most handy then.
Conclusion
There's a lot of ground that could be covered with testing controllers that have dependencies to other services/factories, etc. The above information should be an excellent beginning to learning how to test services. If you haven't read through it yet, check out the post about efficient unit testing strategies and how to become a unit testing minimalist.