Typescript

Testing HttpClient Requests in Angular

Today, I’d like to show migrating the tests to the new HttpClient introduced with Angular. The story is divided into two parts:...

Written by Luci · 7 min read >

Today, I’d like to show migrating the tests to the new HttpClient introduced with Angular. The story is divided into two parts: first, we will take a look at the basics of the new HttpClient and play around with it in some tests. In the second part, we will implement a service that talks to a HTTP backend. We will then verify the behaviour of our service by faking HTTP traffic in the unit tests.

The new HttpClient

Migration to Angular’s new HttpClient is super straight-forward for the most common use cases: HttpModule is superseded by HttpClientModule and Http by HttpClient. There’s one notable change in the API, however!

In the “old” Http, we used to work with Observable<Response> by default and had to convert response bodies on our own with statements like http.get('/foo/bar').map(res => res.json()). The “new” HttpClient now does response body conversion and, by default, it assumes a JSON response returning an Observable<Object>.

import { NgModule } from '@angular/core';
import { HttpClientModule } from '@angular/common/http';
import { FooBarService } from './foo-bar.service';

@NgModule({
  imports: [ HttpClientModule ],
  providers: [ FooBarService ]
})
export class FooBarModule {}
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

// Migration to the new HttpClient is straightforward thanks to dependency injection
@Injectable()
export class FooBar {

  constructor(
    // This used to be `http: Http` - Replace it!
    private http: HttpClient
  ) {}

  public foo(): Observable<Object> {
    // CAUTION
    // In the "old" `@angular/http`, this returned an `Observable<Response>` by default
    // With `@angular/common/http`, we now get an `Observable<Object>` on the response body
    // In effect, we can basically remove all our `.map(res => res.json())` lines :-)
    this.http.get('/foo/bar').subscribe();
  }
}

Beside that, the new API has renamed a few classes: it’s now called a HttpResponse<T>HttpRequest<T>HttpHeaders, and HttpParams.

Notice the type argument for the outgoing/incoming HTTP bodies. When sending a request, you now declare the expected response type (one of arraybufferblobtextjson) and the type of the response body will be either an ArrayBuffer or Blob or string. For JSON responses, you either work with a generic Object or pass an interface describing the structure of the JSON document.

You will then work with an HttpResponse<MyInterface> and Observable<MyInterface> on the expected data structure. Especially for RPC-like APIs where entities and data structures are often specified and known in advance (“contract first”) this seems helpful.

For more advanced APIs — e.g., when working with custom content types like application/foo+json or when the client does not know the returned content type in advance — this could require a little bit more work. Since I don’t have an example that addresses this directly, I can’t say anything about the migration costs here.

The APIs of HttpHeaders and HttpParams haven’t changed too much compared to the “old” Angular Http module. I have found that migrating to them is merely a “find-and-replace” of the class names. But make sure that your code works well with the copy-on-write behaviour! The latter, HttpParams, can be used for both query string parameters as well as form-encoded response bodies.

Verifying and faking HTTP traffic

Now, let’s write some unit tests and see the HTTP testing API in action. First thing to notice is that the amount of boilerplate code for the test setup has significantly reduced. In the “old” Http API, we had to set up custom providers. Now, it’s as simple as importing both HttpClientModule and HttpClientTestingModule and we’re ready to go:

import { TestBed, async, inject } from '@angular/core/testing';
import { HttpClientModule, HttpClient } from '@angular/common/http';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

describe(`FakeHttpClientResponses`, () => {

  beforeEach(() => {
    // 0. set up the test environment
    TestBed.configureTestingModule({
      imports: [
        // no more boilerplate code w/ custom providers needed :-)
        HttpClientModule,
        HttpClientTestingModule
      ]
    });
  });

  it(`should issue a request`,
    // 1. declare as async test since the HttpClient works with Observables
    async(
      // 2. inject HttpClient and HttpTestingController into the test
      inject([HttpClient, HttpTestingController], (http: HttpClient, backend: HttpTestingController) => {
        // 3. send a simple request
        http.get('/foo/bar').subscribe();

        // 4. HttpTestingController supersedes `MockBackend` from the "old" Http package
        // here two, it's significantly less boilerplate code needed to verify an expected request
        backend.expectOne({
          url: '/foo/bar',
          method: 'GET'
        });
      })
    )
  );

});

We still have to use the async() and inject() test helpers since we’re dealing with asynchronous operations of the HttpClient module.

The API of HttpTestingController is the successor of the MockBackend in the “old” Http module. Again, the amount of boilerplate has been greatly reduced and it has become an one-liner to verify for an expected HTTP request (ok, I’ve written the above example in super verbose multi lines but you can write it in just a single line anyway).

The API for matching requests is build around three methods:

  • expectOne(expr): expect exactly one request that matches
  • expectNone(expr): expect that no requests matches
  • match(expr): match the request but do not verify / assert

The last point is noteworthy. When expectOne() or expectNone() are not matched, it will cause the test to fail. In effect, both expect methods match HTTP requests and verify if the expectations were met. In contradiction, match() only matches but does not automatically verify — we’ll see later why that’s useful. If we want to verify with match(), we have to call verify() explicitly. Here are the code examples:

describe(`FakeHttpClientResponses`, () => {

  it(`should expect a GET /foo/bar`, async(inject([HttpClient, HttpTestingController],
    (http: HttpClient, backend: HttpTestingController) => {
      http.get('/foo/bar').subscribe();

      backend.expectOne({
        url: '/foo/bar',
        method: 'GET'
      });
  })));
  
  it(`should not issue a PUT request`, async(inject([HttpClient, HttpTestingController],
    (http: HttpClient, backend: HttpTestingController) => {
      http.post('/allez', { value: 123 }).subscribe();
      http.get('/allez').subscribe();
      http.delete('/allez').subscribe();

      backend.expectNone((req: HttpRequest<any>) => {
        return req.method === 'PUT';
      });
  })));

  it(`should NOT fail when sending an un-matched request`, async(inject([HttpClient, HttpTestingController],
    (http: HttpClient, backend: HttpTestingController) => {
      http.get('/foo/bar').subscribe();

      backend.match('/foo');
  })));

  it(`should fail when verifying an un-matched request`, async(inject([HttpClient, HttpTestingController],
    (http: HttpClient, backend: HttpTestingController) => {
      http.get('/foo/bar').subscribe();

      backend.match('/foo');
      backend.verify();
  })));

  it(`should fail when not sending an expected request`, async(inject([HttpClient, HttpTestingController],
    (http: HttpClient, backend: HttpTestingController) => {
      http.get('/foo/bar').subscribe();

      backend.expectOne('/foo');
  })));

  it(`should fail when sending an non-expected request`, async(inject([HttpClient, HttpTestingController],
    (http: HttpClient, backend: HttpTestingController) => {
      http.get('/foo/bar').subscribe();

      backend.expectNone('/foo/bar');
  })));
  
});

Notice that the last three tests fail by intention. The test failures are:

As the last basic example let’s see how to fake HTTP responses. With all three methods — expectOne()expectNone() and match() — it’s possible to respond with successful responses or errors (error responses or network errors) and even with progress events (e.g. an upload progress). Here’s the simple example that responds with a object literal (i.e. JSON response from the server) and expects something in the next subscriber:

describe(`FakeHttpClientResponses`, () => {

  it(`should respond with fake data`, async(inject([HttpClient, HttpTestingController],
    (http: HttpClient, backend: HttpTestingController) => {
      http.get('/foo/bar').subscribe((next) => {
        expect(next).toEqual({ baz: '123' });
      });

      backend.match({
        url: '/foo/bar',
        method: 'GET'
      })[0].flush({ baz: '123' });
  })));

});

Testing HTTP-based services

Let’s now write a service that works over HTTP and then write a unit test for the service. As a simple example, let’s take a straight-forward form-based login. Given a username and password, it sends a form-encoded POST and returns a boolean flag indicating the login result based on the HTTP response code (200–299 are treated as success in this example). Here’s the implementation:

import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';

/** This class implements some features that should be tested. */
@Injectable()
export class HttpClientFeatureService {

  constructor(
    private http: HttpClient
  ) {}

  login(user: string, password: string): Observable<boolean> {
    const body = new HttpParams()
      .set(`user`, user)
      .set(`password`, password);
    const headers = new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' });

    return this.http.post(`auth/login`, body.toString(), { headers, observe: 'response' })
      .map((res: HttpResponse<Object>) => res.ok)
      .catch((err: any) => Observable.of(false));
  }

}

Then the tests for that service. In our example we can safely assume that our tests capture all the HTTP traffic and thus the following snippet uses an afterEach() hook to verify() that there are no pending or un-matched HTTP requests — as explained before, this means that every expected HTTP requests needs to be matched. When the service being test leaves a request un-matched, it will result in test failure. Whether to use or not to use such an afterEach() hook is in the developer’s choice and should be a discrete decision for every test case.

The expectation for the correct login request uses a matcher function in the following code snippet, allowing to write more complex and advanced assertions whether a request is considered a match. In the example, we specifically check for the Content-Type header and whether it’s a form-encoded entity:

import { TestBed, async, inject } from '@angular/core/testing';
import { HttpClientModule, HttpRequest, HttpParams } from '@angular/common/http';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { HttpClientFeatureService } from './http-client-feature.service';

describe(`HttpClientFeatureService`, () => {

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [
        HttpClientModule,
        HttpClientTestingModule
      ],
      providers: [
        HttpClientFeatureService
      ]
    });
  });

  afterEach(inject([HttpTestingController], (backend: HttpTestingController) => {
    backend.verify();
  }));

  it(`should send an expected login request`, async(inject([HttpClientFeatureService, HttpTestingController],
    (service: HttpClientFeatureService, backend: HttpTestingController) => {
      service.login('foo', 'bar').subscribe();

      backend.expectOne((req: HttpRequest<any>) => {
        const body = new HttpParams({ fromString: req.body });

        return req.url === 'auth/login'
          && req.method === 'POST'
          && req.headers.get('Content-Type') === 'application/x-www-form-urlencoded'
          && body.get('user') === 'foo'
          && body.get('password') === 'bar';
      }, `POST to 'auth/login' with form-encoded user and password`);
  })));

  it(`should emit 'false' for 401 Unauthorized`, async(inject([HttpClientFeatureService, HttpTestingController],
    (service: HttpClientFeatureService, backend: HttpTestingController) => {
      service.login('foo', 'bar').subscribe((next) => {
        expect(next).toBeFalsy();
      });

      backend.expectOne('auth/login').flush(null, { status: 401, statusText: 'Unauthorized' });
  })));

  it(`should emit 'true' for 200 Ok`, async(inject([HttpClientFeatureService, HttpTestingController],
    (service: HttpClientFeatureService, backend: HttpTestingController) => {
      service.login('foo', 'bar').subscribe((next) => {
        expect(next).toBeTruthy();
      });

      backend.expectOne('auth/login').flush(null, { status: 200, statusText: 'Ok' });
  })));

});

For asserting the results returned that are returned by the service, we’re flush()‘ing either a 401 Unauthorized or a 200 Ok — each in its own test case. The HTTP response is then handled by our service implementation (which checks for the response.ok flag) and emits a boolean to the subscriber. Obviously, we expect the value to be true for the 200 Ok response and to be false for the 401 Unauthorized case.

Conclusions

Well, that finishes up our testing snippets for the new HttpClient! The above code snippets serve as a good starting point for writing more sophisticated and advanced test cases for Angular applications that do some sort of HTTP communication with the new HttpClient API.

Not only can we use it to assert that services conform to the “contract” of a web API but also can we use it to test components. Imagine a file upload component showing a progress indicator — when we fake out the upload progress in a unit test, it’s possible to assert the visual appearance of that progress indicator.

Such an example would be a good follow-up to this story. Another key take-away is that the new HttpClient API greatly reduces the amount of boilerplate code needed for testing. From “developer experience” this is a very very good thing and — fingers crossed — should result in broader acceptance of tests and willingness to write tests!

Written by Luci
I am a multidisciplinary designer and developer with a main focus on Digital Design and Branding, located in Cluj Napoca, Romania. Profile

How To Create A Queue In TypeScript?

Luci in Typescript
  ·   1 min read
0 0 votes
Article Rating
Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments
0
Would love your thoughts, please comment.x
()
x