axelhodler

Testing Asynchronous Code in Angular

If you have ever wondered how to test promises in Angular this post is for you.

Assume we have a function which in turn invokes two other functions. Here submitReport will invoke two services. Our report will be persisted via reportsGateway and, if successful, the router will navigate the user to a list of reports.

submitReport(report: string) {
  return this.reportsGateway.saveReport(report).then(() => {
    return this.router.navigate(['reports']);
  });
};

A developer not used to testing asynchronous code might write a unit test as follows

it('navigates to reports after submitting a report', () => {
  spyOn(reportsGateway, 'saveReport')
    .and.returnValue(Promise.resolve());
  spyOn(router, 'navigate');

  component.submitReport('a report');

  expect(reportsGateway.saveReport).toHaveBeenCalledWith('a report');
  expect(router.navigate).toHaveBeenCalledWith(['reports']);
});

Running the test leads to

Expected spy navigate to have been called with [ [ 'reports' ] ] but
it was never called.

Why?

We can add a log statement into the anonymous then function and another one above our assertions. When running the tests we witness

LOG: 'Start assertions'
LOG: 'Promise resolved'

We have a timing issue. The assertion happens before the promise is resolved.

The quick and dirty solution is to add a timeout to the test after calling submitReport. It gives the promise enough time to resolve before checking the assertions.

setTimeout(() => {
  expect(reportsGateway.saveReport)
    .toHaveBeenCalledWith('a report');
  expect(router.navigate).toHaveBeenCalledWith(['reports']);
}, 10);

Run the test and its passing. Sweet. Let’s continue with the next feature…

Wait

Change the assertion on the router to state the opposite. Look at the added not

expect(router.navigate).not.toHaveBeenCalledWith(['reports']);

Run it. The tests are still passing. How?

Because our test finishes without ever running the anonymous function in setTimeout. Thus the test has 0 assertions.

We can fix it by telling the test case to wait. Jasmine offers Asynchronous Support via done. A test will not complete until done is called.

it('navigates to reports after submitting a report', (done) => {
  // [...]
  setTimeout(() => {
    expect(reportsGateway.saveReport)
      .toHaveBeenCalledWith('a report');
    expect(router.navigate).toHaveBeenCalledWith(['reports']);
    done();
  }, 10);
});

Our test is back to green and fails as soon as we try to swap toHaveBeenCalledWith(['reports']) with not.toHaveBeenCalledWith(['reports']).

Sadly, by going for the timeout solution, we have made our test unnecessarily slow. The test will wait these 10ms even if less would be fine too.

We can give the test exactly the amount it needs by using the Promise already returned by submitReport

component.submitReport('a report').then(() => {
  expect(reportsGateway.saveReport).toHaveBeenCalledWith('a report');
  expect(router.navigate).toHaveBeenCalledWith(['reports']);
  done();
});

One issue I have with the above is the fact that submitReport has to return a Promise for it to work. What if we do not care about the return value? Additionally it intermingles the Act and Assert part in Arrange Act Assert and adds another level of indentation which decreases the readability.

Thankfully Angular offers fakeAsync to test code as if it were synchronous. The promise is fulfilled immediately after you call tick()

it('navigates to reports after submitting a report', fakeAsync(() => {
  spyOn(reportsGateway, 'saveReport')
    .and.returnValue(Promise.resolve());
  spyOn(router, 'navigate');

  component.submitReport('a report');
  tick();

  expect(reportsGateway.saveReport).toHaveBeenCalledWith('a report');
  expect(router.navigate).toHaveBeenCalledWith(['reports']);
}));

The test is readable, not flaky, not wasting time and passes. Great.