Please read the current version of this tutorial, along with a completely revamped build and test platform courtesy Angular CLI.
Note – I’ve updated this article to use my newer Angular-CLI-based seed and fixed all imports for the newer Angular2 RC1 release.
In this blog entry I'm going to show you a test from my current test repository at https://github.com/krimple/angular2-unittest-samples-rc involving the Http service and observables.
Right now it's hard to find samples of unit tests for developers in the current RC phase, though it is getting a bit better. Some of the things you have to watch out for are non-obvious. I expect this will improve vastly over time, but for now hopefully you'll get some working code samples from me to get you going.
Originally, I used the Angular Class Webpack starter project as a base for my current efforts, and hats off to that team for wrestling WebPack, TypeScript and Karma to the ground to give everyone a sample to work off of. It is MIT-licensed, so you can use it as a starter for whatever project you need right now. However, I’ve switched to the Angular CLI for my seed projects and that is what I’m pointing to now.
Setting up an Angular2 Jasmine test
Angular2 requires you to import your test methods from the angular2/testing
module. This includes your regular methods, such as beforeEach
, describe
, it
, and the like. So at the top of your test you can define:
import { describe, expect, beforeEach, it, inject, injectAsync, beforeEachProviders } from '@angular/core/testing';
We are including three new functions – inject
, injectAsync
, and beforeEachProviders
, which may sound alien to you. In fact, they are part of what replaces the module
and inject
functions of Angular 1. But you also need Angular's standard method and class configuring injection, so that's:
import {provide} from '@angular/core';We also will import our unit under test, our
BlogService
, and the data value returned from the service, BlogEntry
:
import {BlogService} from "../services/blog-service"; import {BlogEntry} from '../domain/blog-entry';
The unit under test - a Blog Service
Ok, it's a lame example... But it will serve. Here is a simple service that returns data from a web service. I've skipped the actual imports for the moment:
@Injectable() export class BlogService { opts: RequestOptions; constructor(private http: Http) { var headers: Headers = new Headers(); // this automatically asks for JSON responses headers.append('content-type', 'application/json; charset=utf-8'); this.opts = new RequestOptions(); this.opts.headers = headers; } getBlogs(): ... } saveBlog(blog: BlogEntry): ... } }
Of note here, we're going to inject the Http
service, and set up some default request options, namely the content-type
header. This asks the server to request an actual JSON
response, so we can parse it as a Javascript object.
Wiring the Jasmine injector
We want to start by testing getBlogs()
. But first we have to do some setup, starting with the configuration of our test injector. This is similar to the module
function in Angular 1, but of course more complex because now we have to set up a real dependency injector based on ES6 modules.
describe('Blog Service', () => { beforeEachProviders(() => { return [ HTTP_PROVIDERS, provide(XHRBackend, {useClass: MockBackend}), BlogService ]; }); ...
The beforeEachProviders
executes before each test like beforeEach
, but this function configures a test injector with whatever providers (think services) you give it. The HTTP_PROVIDERS
is required to bootstrap the overall HTTP framework. Next we provide the XHRBackend
(XHR stands for Xml Http Request, the name of the object that performs Javascript-based AJAX requests). But the way we provide it is by using the provide
function as a factory - it says that instead of just providing the XHRBackend
class, we replace it with a fake, the MockBackend
. By doing this (and not just directly injecting MockBackend
), Angular can wire up the mock properly.
Our first test
Now we can write a test against the getBlogs
method. Our method under test is:
getBlogs(): Observable<any> { return this.http.get('/api/blogs') .map((res: Response) => { return BlogEntry.asBlogEntries(res.json()); }); }
We discussed this BlogEntry
class in a previous blog post, as well as the service itself. Bottom line is that this method will return an Observable which sends back the response when it arrives.
The test for this method can be done several ways. First, we can use the inject
function and treat the response as if it came back synchronously. Comments are inline,
and you'll note we start by injecting the mockBackend
and blogService
into the
test using the inject
function (which requires an array of classes to look up the
injectables, then an arrow function to inject the instances from the injector):
it('should get blogs', inject([XHRBackend, BlogService], (mockBackend, blogService) => { // first we register a mock response - when a connection // comes in, we will respond by giving it an array of (one) // blog entries mockBackend.connections.subscribe( (connection: MockConnection) => { connection.mockRespond(new Response( new ResponseOptions({ body: [ { id: 26, contentRendered: "<p><b>Hi there</b></p>", contentMarkdown: "*Hi there*" }] } ))); }); // with our mock response configured, we now can // ask the blog service to get our blog entries // and then test them blogService.getBlogs().subscribe((blogs: BlogEntry[]) => { expect(blogs.length).toBe(1); expect(blogs[0].id).toBe(26); }); }));
This test works, and for a single result, and one where you don't have to manually fail a test based on a failed response, it's all you need. However, Angular 2 does have an asynchronous test injector that can give you the ability to reject test results based on a promise.
Alternative implementation using injectAsync
Here is the same test, but using a version of the inject
keyword that allows you to pass or fail a test in a response asynchronously:
it('should get blogs async', injectAsync([XHRBackend, BlogService], (mockBackend, blogService) => { // injectAsync requires us to return a promise - if we let it complete, // or if we call pass, it passes. If we call fail, we can fail the test. return new Promise((pass, fail) => { // the same implementation here mockBackend.connections.subscribe( (connection: MockConnection) => { connection.mockRespond(new Response( new ResponseOptions({ body: [ { id: 26, contentRendered: "<p><b>Hi there</b></p>", contentMarkdown: "*Hi there*" }] } ))); }); blogService.getBlogs().subscribe( (data) => { expect(data.length).toBe(1); expect(data[0].id).toBe(26); expect(data[0].contentMarkdown).toBe('*Hi there*'); }, (error) => { // we can call a failure case here... fail(error); }); }); }));
Since the first case is easier, I'd recommend you do anything with a single response the first way, and back yourself into this method.
Testing updates
Here is an update operation - saveBlog()
:
saveBlog(blog: BlogEntry): Observable<Response> { if (blog.id) { return this.http.put( '/api/blogs/' + blog.id, blog.json(), this.opts); } else { return this.http.post( '/api/blogs', blog.json(), this.opts); } }
And the test:
it('should save updates to an existing blog entry', injectAsync([XHRBackend, BlogService], (mockBackend, blogService) => { return new Promise((resolve, reject) => { mockBackend.connections.subscribe(connection => { connection.mockRespond(new ResponseOptions({status: 200})); }); let data: BlogEntry = new BlogEntry( "Blog Entry", "<p><b>Hi</b></p>", "*Hi*", 10); blogService.saveBlog(data).subscribe( (successResult) => { expect(successResult).toBeDefined(); expect(successResult.status).toBe(200); }); }); }));
Gotchas - miswiring
I spent two whole days debugging this code, as I could not simulate a failure case. The upshot: don't ever, ever provide MockBackend
- only provide XHRBackend, { useClass: MockBackend}
- currently it silently fails by NOT running your subscriptions.
I put in an issue on the website (https://github.com/angular/angular/issues/6375) so we'll see if the Angular team takes up better error handling when the mock backend is misused in this way. I also have a plunker that shows the condition at http://plnkr.co/edit/vUjNLCVG8SVRYK6ZnKG0 if you're curious how that might happen and what it looks like.