Converting to the Angular HttpClient

As I mentioned in a previous article, Angular 4.3 introduced a new API for making HTTP calls, supplementing the HttpModule in @angular/http with the new HttpClientModule in @angular/common/http. I experimented with the new interceptors and request progress API, but didn't actually try it out in a working application.

So below is the results of an experiment to switch an existing project, Salary Stats, over to the HttpClient and test drive the new API for both the client and the tests. Despite the clear warnings in the documentation I'm using the Angular in-memory web API to allow the user to make CRUD operations without setting up a backend; this needed an upgrade to v0.4.0 to support the new client.

Setup

Firstly, there's configuration. Using Http, the best way to test a service was to replace the ConnectionBackend with a special MockBackend that provides access to active connections. Using Angular's dependency injection (DI) through the TestBed, this was reasonably straightforward:

beforeEach(() => {
  TestBed.configureTestingModule({
    providers: [
      { provide: ConnectionBackend, useClass: MockBackend },
      { provide: RequestOptions, useClass: BaseRequestOptions },
      Http,
      PersonService,
    ]
  });

  service = TestBed.get(PersonService);
  backend = TestBed.get(ConnectionBackend);
});

The Http class has two injected dependencies, so we can either import the whole HttpModule and override ConnectionBackend or just provide both directly. Things are much simpler with the HttpClientTestingModule, however, which can be added to the imports array and will mock everything for you:

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

  service = TestBed.get(PersonService);
  httpMock = TestBed.get(HttpTestingController);
});

afterEach(() => {
    httpMock.verify();
})

Now instead of the MockBackend we get access to the HttpTestingController, which gives access to a more synchronous API; rather than subscribing to a stream of connections you can assert on the existence of specific requests, flush them with a response as needed then verify that no unexpected requests have been made.

Checking the request

A simple first test for a new service method would be to check that it's making the appropriate request to the backend.

it('should get the people from the API', done => {
  backend.connections.subscribe((connection: MockConnection) => {
    expect(connection.request.url).toMatch(/\/people$/);
    expect(connection.request.method).toBe(RequestMethod.Get, 'expected GET request');
    done();
  });

  service.fetch();
});

Rewriting this with the new test API:

it('should get the people from the API', () => {
  service.fetch();

  httpMock.expectOne('/app/people');
});

This demonstrates the value of the synchronous API; no more worrying about the DoneFn and which order to make the request and set up the expectations. Personally I find the second version much more readable, as it's a better fit for the common "Arrange, Act, Assert" (or "Given, When, Then") testing pattern.

Sadly, one thing that seems to be missing here is the ability to provide a regular expression to match the request URL. We have found this very useful in combination with the Angular CLI's environment settings, allowing us to use different profiles for local testing and our various deployment environments, without the root URLs bleeding into the test setup.

Instead, you can match just the request method to get access to the TestRequest:

const req  = httpMock.expectOne({ method: 'GET' });
expect(req.request.url).toMatch(/\/people$/);

Also note that the API now uses string representations of the request method rather than the enumerator I mentioned in Testing async data in Angular; "Expected 'GET' to be 'POST'." is a definite improvement over "Expected 0 to be 1.", although you don't get the IDE support to autocomplete it.

Testing with response data

The next test would drive out what happens with the data in the response. As the new client parses the JSON for you by default you may not need to do anything further, but in this case I want the raw object converted to a class with some business logic in it.

it('should expose the people as an observable', done => {
  let people = [{ name: 'Alice', salary: 12345, cohort: 'A' }];

  backend.connections.subscribe((connection: MockConnection) => {
    connection.mockRespond(new Response(new ResponseOptions({
      status: 200,
      body: { data: people },
    })));
  });

  service.fetch();

  service.people$.subscribe(received => {
    expect(received).toEqual([new Person(people[0].name, people[0].salary, people[0].cohort)]);
    done();
  });
});

I have generally been avoiding testing the request and the response handling in the same method because that leads to having assertions before and after the point at which the action is taken, which seems very confusing ("Arrange & Assert, Act, Assert Again"?) But with the new API, the assertions all come after the action:

it('should expose the people as an observable', done => {
  const people = [{ name: 'Alice', salary: 12345, cohort: 'A' }];

  service.fetch();

  const req = httpMock.expectOne({ method: 'GET' });
  expect(req.request.url).toMatch(/\/people$/);
  req.flush({ data: people });

  service.people$.subscribe(received => {
    expect(received).toEqual([new Person(people[0].name, people[0].salary, people[0].cohort)]);
    done();
  });
});

Given that some expectation on the request is needed to get access to the TestRequest to flush the response data through it, and given that this all happens after the service call, it now seems sensible to combine two tests into a single one.

The DoneFn is still required, because I'm exposing the data over an observable as discussed in Handling data with the Angular AsyncPipe, but the HTTP part of the test is still handled synchronously. people$ has replay behaviour in this case, so we can subscribe after the service call and still receive the latest data.

Testing a POST

The in-memory web API also provides create and delete functionality.

it('should post a new person to the API', done => {
  let name = 'Lynn';
  let salary = 123;
  let cohort = 'Q';

  backend.connections.subscribe((connection: MockConnection) => {
    expect(connection.request.url).toMatch(/\/people$/);
    expect(connection.request.method).toBe(RequestMethod.Post, 'expected POST request');
    expect(connection.request.json()).toEqual({ name, salary, cohort });
    done();
  });

  service.addPerson(new Person(name, salary, cohort));
});

Converting this to the new testing API is reasonably straightforward again, giving the test much clearer structure:

it('should post a new person to the API', () => {
  let name = 'Lynn';
  let salary = 123;
  let cohort = 'Q';

  service
      .addPerson(new Person(name, salary, cohort))
      .subscribe(() => {});

  const req = httpMock.expectOne({ method: 'POST' });
  expect(req.request.url).toMatch(/\/people$/);
  expect(req.request.body).toEqual({ name, salary, cohort });
});

One big gotcha here is that HttpClient.post, unlike Http.post, seems to be a cold observable; you need to subscribe for the request to actually take place. This may mean refactoring usages of these methods if you aren't already returning and subscribing to the request observable.

Service implementation

For the POSTs and DELETEs not much changed, I just had to replace private http: Http with private http: HttpClient and the code continued to work perfectly; the generic types on request methods are optional, and if you're not dealing with a response you can ignore them completely. Actually using the generic types, and with the default JSON unwrapping, the fetch method went from:

fetch() {
  this.http
      .get(this.personRoute)
      .subscribe(response => {
        this.personSubject.next(this.deserialise(response.json().data));
      });
}

to:

fetch() {
  this.httpClient
      .get<{ data: RawPerson[] }>(this.personRoute)
      .subscribe(json => {
        this.personSubject.next(this.deserialise(json.data));
      });
}

A subtle improvement, but it helps to document your expectations of the API and means you get IDE support for the resulting object.

Http or HttpClient?

For new code, adopting the HttpClient is a no-brainer; even the documentation has been switched over. So the big question is, is it worth switching an application over to HttpClient? In the application code, the switching process is not terribly complicated; anything that doesn't involve the response may already work. And if you are, the new API will likely allow you to remove a bunch of .json() calls and benefit from better type support.

The downside is that the new testing API, much as I prefer it to the previous system, means a lot of test refactoring. And as much of your code will already be working, using a technique like "refactoring against the red bar" means you may spend a while breaking working code to ensure that the refactored tests will fail in a useful way. So, unless you need some of the new functionality (the interceptors seem particularly useful), it is probably not worth the conversion unless:

  • you're still in the earlier stages of development, so you're likely to get the benefits in the additional services you write; or

  • you're planning to keep upgrading Angular into v5 (@angular/http is deprecated from 5.0.0-beta.6).

See the full commit switching salary-stats over to the new API here.

Comments !