AngularJS Integration Tests with Mocks and Magic
As a web developer I’m not a huge fan of full end-to-end tests. My opinion is changing with maturing frameworks like protractor but I still think looking for a “button” with an “id” is a fragile test that may have to change often.
I am far more interested in what happens when the button is clicked than the button itself, because what starts out as a button might end up as a hyperlink or a block of text or something entirely different.
Data-binding provides a powerful abstraction between presentation logic and the user interface. “Applying a filter” is still presentation logic, but data-binding allows me to expose that as a method on an object and then let the designer worry about the details of what it is bound to. This was an effective way to code and test in the XAML days when we were using “view models” and I find it just as effective on the web.
For that reason, instead of a true “end-to-end” test, I prefer to enhance my AngularJS unit tests with integration tests. A unit test has no dependencies. I should be able to run it and mock dependencies so that it executes regardless of network connectivity, the presence of a web service or the status of a database. On the other hand, integration tests require a little bit of setup and expect things to be there, whether it is an active service or even a hot database on the backend. I test up to the data-binding, but leave the UI untouched.
The challenge with Angular is that the ngMock library makes it really, really easy to completely abstract the HTTP layer. It also makes it easy to test in general, which is why I like to use it even with my integration tests. The problem is that, as far as I know, I can’t opt-out of the $httpBackend. (If I’m wrong and there is a way other than using the end-to-end mock library, let me know!) Don’t get me wrong, it is fantastic for unit tests. To illustrate my point …
The Unit Test
Consider the world’s almost-smallest Angular app that does nothing other than expose an API that calls a service endpoint and returns a value based on whether or not it successfully connected. This is the app:
(function (app) { app.factory("oDataSvc", ['$q', '$http', function ($q, $http) { return { checkEndPoint: function () { var deferred = $q.defer(); $http.get("http://services.odata.org/V4/TripPinServiceRW") .then(function () { deferred.resolve(true); }, function () { deferred.resolve(false); }); return deferred.promise; } }; }]); })(angular.module('test', []));
Now I can write a test. First, I’m going to wire up the latest version of Jasmine and make sure Jasmine is working.
(function() { var url = "http://services.odata.org/V4/TripPinServiceRW"; describe("jasmine", function() { it("works", function() { expect(true).toBe(true); }); }); })();
Now I can set up my unit test. First I want to capture the service and the $httpBackend and verify I’ve handled all requests after each test.
describe("angular unit test", function() { var oDataService, httpBackend; beforeEach(function() { module("test"); }); beforeEach(inject(function($httpBackend, oDataSvc) { httpBackend = $httpBackend; oDataService = oDataSvc; })); afterEach(function() { httpBackend.verifyNoOutstandingExpectation(); httpBackend.verifyNoOutstandingRequest(); }); });
Then I can make sure I was able to retrieve the service:
it("is registered with the module.", function () { expect(oDataService).not.toBeNull(); });
Now comes the fun part. I can set up my test but it will hang on the promise until I set up expectations for the backend and then flush it. I can do this all synchronously and test how my service deals with hypothetical response codes. Here’s the example that I use to set up “success”:
describe("checkEndPoint", function() { it("should return true upon successful connection", function () { oDataService.checkEndPoint() .then(function(result) { expect(result).toEqual(true); }, function() { expect(false).toBe(true); }); httpBackend.expectGET(url) .respond(200, null); httpBackend.flush(); }); });
The expectation is set in the “expectGET” method call. The service will block on returning until I call flush, which fires the result based on the expectation I set, which was to return a 200-OK status with no content. You can see the failure example in the jsFiddle source.
The Integration Test
The rub comes with an integration test. I’d love to use the mock library because it sets up my module, the injector, and other components beautifully, but I’m stuck with an $http service that relies on the backend. I want the “real” $http service. What can I do?
The answer is that I can use dependency injection to my advantage and play some tricks. In the context of my app, the injector will provide the mocked service. However, I know the core module has the live service. So how can I grab it from the main module and replace it in my mocked module without changing the mocks source?
Before I grab the service, it is important to understand dependencies. $http relies on the $q service (I promise!). The $q service, in turn, relies on the digest loop. In the strange world of a mocked test object, if I manage to call the real $http service it is not going to respond until a digest loop is called.
“Easy,” you might say. “Get the $rootScope and then call $apply.”
“Not so fast,” is my reply. The $rootScope you probably plan to grab won’t be the same $rootScope used by the $http service we get from the injector. Remember, that is a different “container” that we are accessing because it hasn’t been overwritten in our current container that has the mocks library!
This is easier to explain with code:
beforeEach(function () { var i = angular.injector(["ng"]), rs = i.get("$rootScope"); http = i.get("$http"); flush = function () { rs.$apply(); } module("test", function ($provide) { $provide.value("$http", http); $provide.value("$rootScope", rs); }); });
Angular’s modules overwrite their contents with a “last in wins” priority. If you include two module dependencies with the same service, the last one wins and you lose the original. To get the live $http, I need to create a new container. That container is completely isolated from the one I’m using to test.
Therefore I need to grab that container’s $rootScope as well. The flush method gives me a reusable function I can easily use throughout my code. When I mock the module for the integration test, I intercept the provider by using the $provide service to replace the ones already there.
The replacement of $rootScope is important. It’s not good enough to capture the flush method because that will just run a digest in the original container. By making that $rootScope part of my current container, I ensure it is the one used all of the way down the dependency chain (and for digests in my tests). If I reference $q in my tests I’ll overwrite it from the original container too.
Now my test doesn’t configure expectations but is a true integration test. I am expecting the sample service to be up, and for the service call to return “true.” Notice that I need to call this asynchronously, so I take advantage of the oh-so-easy asynchronous syntax in the latest Jasmine (“pass done, then call it when you are.”)
it("should return true to verify the service is up", function (done) { oDataService.checkEndPoint() .then(function (result) { expect(result).toEqual(true); done(); }, function () { expect(false).toBe(true); done(); }); flush(); });
That’s it. Using this method, I can take advantage all mocks has to offer while still integrating with my live web API to ensure service calls are working. This is what I call a “beneath the surface” test. I’m testing through the data bound model, ensuring that the test flows through to the database, etc., but again I’m testing what a function does, not how it is wired to the UI.
To see the unit test and integration test in action, check out the jsFiddle. Check out your network and notice the service is really being called for the integration test.
If you see several lines, it’s because it redirects to generate a “session” for the service and then makes the final call (307 ► 302 ► 200).
If you are a fan of integration tests and struggled with this in your AngularJS tests I hope this was helpful. If you have a different approach then please share it in the comments below!
Reference: | AngularJS Integration Tests with Mocks and Magic from our WCG partner Jeremy Likness at the C#er : IMage blog. |