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;
RequestMethodis 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
resultsvalue 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_SCHEMAto 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
triggerFetchmethod invokes the appropriate service method, but given a correctly configuredTestBedyou 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 callsfetchRandomUseron 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
HttpClientmodule seems to deal with this more neatly, providing an official equivalent ofMockError; see New to Angular 4.3: HttpClient for more information.
Comments !