Testing async data in Angular
This post is a follow up to Handling data with the Angular AsyncPipe, and assumes you're familiar with the service and component used there.
In a previous article I outlined a way to use the asynchronous nature of
Http
-based services to handle streams of data right through to the template.
One of the core practices of the Extreme Programming methodology we use at
Pivotal is test-driven development (TDD), so I thought it would also be helpful
to show how we've approached writing our tests.
Angular comes with some useful built-in functionality to enable testing; see
their article on the subject for more information. We're also using
Jasmine's asynchronous support, calling done()
to explicitly define when
a test is considered finished.
Testing the service
Angular includes a MockBackend
that we can inject into the Http
created for the service to give access to the connections it's creating,
exposing an interface for testing and isolating tests from the actual network.
describe('RandomUserService', () => {
let service: RandomUserService;
let backend: MockBackend;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{ provide: ConnectionBackend, useClass: MockBackend },
{ provide: RequestOptions, useClass: BaseRequestOptions },
Http,
RandomUserService,
],
});
service = TestBed.get(RandomUserService);
backend = TestBed.get(ConnectionBackend);
});
it('should make a GET to the API on fetch', done => {
backend.connections.subscribe((connection: MockConnection) => {
expect(connection.request.url).toEqual('https://randomuser.me/api/');
expect(connection.request.method)
.toEqual(RequestMethod.Get, 'expected GET request');
done();
});
service.fetchRandomUser();
});
it('should expose the first result from the response', done => {
let expectedUser = { name: { first: 'Alice' } };
backend.connections.subscribe((connection: MockConnection) => {
connection.mockRespond(new Response(new ResponseOptions({
status: 200,
body: { results: [expectedUser] },
})));
});
service.fetchRandomUser();
service.randomUser$.subscribe(user => {
expect(user).toEqual(expectedUser);
done();
});
});
});
This allows us to test both:
-
That the request is correct: we can check the URL, request method and other properties to ensure that the settings are correct. Note the non-default message on the method assertion;
RequestMethod
is an enum, and e.g. "Expected 0 to be 1." isn't a very useful failure message. -
That the response handling is correct: in this case, that the first entry in the response JSON's
results
value is exposed over the observable.
Note another advantage of the subject/observable formulation over the original
version here; as the subscription to http.get(...)
happens inside the fetch
method, you don't need to subscribe to the result in the first test, where the
response is irrelevant. In cases where the request observable is returned from
the service, no request is made unless the caller subscribes to it; GET is a
cold observable (however e.g. POST and PUT are hot, so you don't need to
subscribe unless you are actually interested in the result).
Testing the component
As components include templates, which must be compiled, the testing is
slightly more complex. The compilation is asynchronous, so the
Angular CLI creates components with a test setup like the following: two
beforeEach
calls, one with async
to run the compilation, then a second
synchronous call where the fixture is created.
describe('RandomUserComponent', () => {
let component: RandomUserComponent;
let fixture: ComponentFixture<RandomUserComponent>;
let serviceSpy: RandomUserService;
let userSubject = new ReplaySubject<User>(1);
beforeEach(async(() => {
serviceSpy = jasmine.createSpyObj('RandomUserService', ['fetchRandomUser']);
serviceSpy.randomUser$ = userSubject.asObservable();
TestBed.configureTestingModule({
providers: [
{ provide: RandomUserService, useValue: serviceSpy },
],
declarations: [RandomUserComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(RandomUserComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should fetch and render a random user', () => {
let firstName = 'Alice';
userSubject.next({ name: { first: firstName } });
fixture.detectChanges();
expect(serviceSpy.fetchRandomUser).toHaveBeenCalled();
expect(fixture.nativeElement.querySelector('span').innerText).toEqual(firstName);
});
});
Note that the subject/observable usage in the test mirrors that in the actual service implementation; this means that new data can be pushed into the test at any time. You can also do this when setting up tests for the original version of the service:
service = jasmine.createSpyObj('RandomUserService', ['getRandomUser']);
(service.getRandomUser as Spy).and.returnValue(userSubject.asObservable());
In both cases making the service
explicitly typed as a RandomUserService
means that the IDE and compiler can tell us if the wrong field and method names
are used; e.g. if I'd mistyped the assignment of the observable:
TS2339: Property 'randomUsers$' does not exist on type 'RandomUserService'.
Testing more complex components
The nice things about the TestBed
and ComponentFixture
model are that it
allows us to:
-
Be very specific about the dependencies of our components: its hooks into dependency injection system allow us to provide test doubles as required, and either:
-
explicitly provide required sub-components (real or fake) to test interactions; or
-
use the
NO_ERRORS_SCHEMA
to ignore missing sub-components and test a single component in isolation.
-
-
Test interactions between the class and template: it exposes the interface between the two parts of the component. For example, imagine the following component:
@Component({ selector: 'fetch-trigger', template: '<button (click)="triggerFetch()"></button>', }) export class FetchTriggerComponent { constructor(private service: RandomUserService) { } triggerFetch() { this.service.fetchRandomUser(); } }
It's pretty straightforward to unit test that calling the
triggerFetch
method invokes the appropriate service method, but given a correctly configuredTestBed
you can also test that clicking the button in the HTML callstriggerFetch
. Better still, to write a test less tied to the current implementation, you can test across the boundary that clicking the button callsfetchRandomUser
on a stub of the service.it('should fetch a random user when the button is clicked', () => { component.nativeElement.querySelector('button').click(); expect(serviceSpy.fetchRandomUser).toHaveBeenCalled(); });
Testing service error handling
One gotcha we've come across is with handling 4xx and 5xx response status codes
in Http
-based services. Introducing error handling into the component is as
simple as providing the second callback to subscribe:
.subscribe(
user => this.userSubject.next(user),
error => {
if (error instanceof Response) {
this.errorSubject.next(error.status);
}
}
);
Naïvely, you might think that testing it is a simple matter of responding from the mock backend with an error code:
it('should expose errors', done => {
service.error$.subscribe(status => {
expect(status).toBe(404);
done();
});
backend.connections.subscribe((connection: MockConnection) => {
connection.mockResponse(new Response(new ResponseOptions({
status: 404,
})));
});
service.fetchRandomUser();
});
(Note that, as we're not using a ReplaySubject
for errors to avoid
replaying them after the fact, you need to .subscribe
before triggering the
response.)
However, this will end with:
Error: Timeout - Async callback was not invoked within timeout specified by jasmine.DEFAULT_TIMEOUT_INTERVAL.
Instead, you may try to use another method on the connection: mockError
. This
time the TypeScript compiler has something to say:
TS2345: Argument of type 'Response' is not assignable to type 'Error'.
It turns out that, unlike mockRespond
and mockDownload
, the mockError
method takes an Error
rather than a Response
. In practice, however, the
error
on the second subscribe callback is any
, and will be a Response
in the case of a 4xx or 5xx response code. To get around this, you can adopt
the suggestion from this comment on the Angular GitHub repo:
class MockError extends Response implements Error {
name: any;
message: any;
}
which allows you to call mockError
and still test for Response
in the error
callback in the service:
it('should expose errors', done => {
service.error$.subscribe(status => {
expect(status).toBe(404);
done();
});
backend.connections.subscribe((connection: MockConnection) => {
connection.mockError(new MockError(new ResponseOptions({
status: 404,
})));
});
service.fetchRandomUser();
});
There are a few open issues related to this behaviour, so hopefully it will be fixed at some point in the near future.
Update: as of 4.3.0 the new
HttpClient
module seems to deal with this more neatly, providing an official equivalent ofMockError
; see New to Angular 4.3: HttpClient for more information.
Comments !