Testing Http Services in Angular 2 with Jasmine (RC1+)

by
Tags: , , , ,
Category:

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.