Testing
Running tests against code meant for production has long been a best practice. It provides
additional security for the code that’s already written, and prevents accidental regressions in the
future. Components utilizing apollo-angular
, the Angular implementation of Apollo Client, are no
exception.
Although apollo-angular
has a lot going on under the hood, the library provides multiple tools for
testing that simplify those abstractions, and allows complete focus on the component logic.
Introduction
This guide will explain step-by-step how to test apollo-angular
code. The following examples use
the Jest testing framework, but most
concepts should be reusable with other libraries.
Consider the component below, which makes a basic query, and displays its results:
import { Apollo, gql } from 'apollo-angular';
import { Observable } from 'rxjs';
import { pluck, shareReplay } from 'rxjs/operators';
import { Component, Input, OnInit } from '@angular/core';
// Make sure the query is also exported -- not just the component
export const GET_DOG_QUERY = gql`
query getDog($name: String) {
dog(name: $name) {
id
name
breed
}
}
`;
@Component({
selector: 'dog',
template: `
@if (loading$ | async) {
<div>Loading ...</div>
}
@if (error$ | async) {
<div>Error!</div>
}
@if (dog$ | async; as dog) {
<p>{{ dog.name }} is a {{ dog.breed }}</p>
}
`,
})
export class DogComponent implements OnInit {
@Input({ required: true }) name!: string;
loading$!: Observable<boolean>;
error$!: Observable<any>;
dog$!: Observable<any>;
constructor(private readonly apollo: Apollo) {}
ngOnInit() {
const source$ = this.getDog();
this.loading$ = source$.pipe(pluck('loading'));
this.error$ = source$.pipe(pluck('errors'));
this.dog$ = source$.pipe(pluck('data', 'dog'));
}
getDog() {
return this.apollo
.watchQuery({
query: GET_DOG_QUERY,
variables: {
name: this.name,
},
})
.valueChanges.pipe(shareReplay(1));
}
}
ApolloTestingModule
The apollo-angular/testing
module exports a ApolloTestingModule
module and
ApolloTestingController
service which simplifies the testing of Angular components by mocking
calls to the GraphQL endpoint. This allows the tests to be run in isolation and provides consistent
results on every run by removing the dependence on remote data.
By using this ApolloTestingController
service, it’s possible to specify the exact results that
should be returned for a certain query.
Here’s an example of a test for the above Dog
component using ApolloTestingController
, which
shows how to define the mocked response for GET_DOG_QUERY
.
But first, we need to set everything up.
import { ApolloTestingController, ApolloTestingModule } from 'apollo-angular/testing';
describe('DogComponent', () => {
let controller: ApolloTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ApolloTestingModule],
});
controller = TestBed.inject(ApolloTestingController);
});
afterEach(() => {
controller.verify();
});
});
As you can see, it feels a lot like HttpTestingController
, it has pretty much the same API so
nothing new for you!
We recommend you to read “Testing HTTP requests” chapter of Angular docs.
In this configuration, we get mock Apollo
service by importing ApolloTestingModule
and we make
sure there is no open operations thanks to controller.verify()
.
Because ApolloTestingController
is similar to HttpTestingController
we won’t get into details of
unit testing components, we’re going to focus mostly on Apollo service and explaining the API of the
testing utility service.
Expecting and Answering Operations
With all that we can write a test that expects an operation to occur and provides a mock response.
test('expect and answer', () => {
//Scaffold the component
TestBed.createComponent(DogComponent);
component = fixture.componentInstance;
//Call the relevant method
component.getDog().subscribe(dog => {
//Make some assertion about the result;
expect(dog.id).toEqual(0);
expect(dog.name).toEqual('Mr Apollo');
});
// The following `expectOne()` will match the operation's document.
// If no requests or multiple requests matched that document
// `expectOne()` would throw.
const op = controller.expectOne(GET_DOG_QUERY);
// Assert that one of variables is Mr Apollo.
expect(op.operation.variables.name).toEqual('Mr Apollo');
// Respond with mock data, causing Observable to resolve.
op.flush({
data: {
dog: {
id: 0,
name: 'Mr Apollo',
breed: 'foo',
},
},
});
// Finally, assert that there are no outstanding operations.
controller.verify();
});
When it receives a GET_DOG_QUERY
with matching variables
, it returns the corresponding object
that has been flushed. If the query is using fragments the mock data additionally needs to specify
the __typename
. Otherwise an empty object is received from the query.
For mutation, expectOne
should use a function to check the query definitions and return a boolean:
const op = controller.expectOne(operation => {
expect(operation.query.definitions).toEqual(MODIFY_DOG_QUERY.definitions);
return true;
});
expectOne
You can do a lot more with expectOne
than showed in the example.
Important thing, it accepts two arguments. First is different for different use cases, the second one stays always the same, it’s a string with a description of your assertion. In case of failing assertion, the error is thrown with an error message including the given description.
Let’s explore all those possible cases expectOne
accepts:
- you can match an operation by its name, simply by passing a string as a first argument.
- by passing the whole Operation object the expectOne method compares: operation’s name, variables, document and extensions.
- the first argument can also be a function that provides an Operation object and expect a boolean in return
- or passing a GraphQL Document
expectNone
It accepts the same arguments as expectOne
but it’s a negation of it.
match
Search for operations that match the given parameters, without any expectations.
verify
Verify that no unmatched operations are outstanding. If any operations are outstanding, fail with an error message indicating which operations were not handled.
TestOperation
It’s an object returned by expectOne
and match
methods.
TestOperation
has three available methods:
flush(result: ExecutionResult | ApolloError): void
- it accepts a result object or ApolloError instancenetworkError(error: Error): void
- to flush an operation with a network errorgraphqlErrors(errors: GraphQLError[]): void
- to flush an operation with graphql errorscomplete(): void
- manually complete the connection, useful for subscription based testing
Using Named Clients
The process is pretty much the same as using a default client but the setup is a bit different:
import { ApolloTestingController, ApolloTestingModule } from 'apollo-angular/testing';
describe('DogComponent', () => {
let controller: ApolloTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ApolloTestingModule.withClients(['clientA', 'clientB'])],
});
controller = TestBed.inject(ApolloTestingController);
});
afterEach(() => {
controller.verify();
});
});
Now you’re able to test named clients.
If you want to check which client was called to perform a graphql operation:
test('expect to call clientA', () => {
// Scaffold the component
TestBed.createComponent(DogComponent);
component = fixture.componentInstance;
// Call the relevant method
component.getDog().subscribe();
const op = controller.expectOne(GET_DOG_QUERY);
// Check what is the name of a client that performed the query
expect(op.operation.clientName).toEqual('clientA');
// Respond with mock data, causing Observable to resolve.
op.flush({
data: {
dog: {
id: 0,
name: 'Mr Apollo',
breed: 'foo',
},
},
});
// Finally, assert that there are no outstanding operations.
controller.verify();
});
Using a custom cache
By default, every ApolloCache is created with these options:
{
"addTypename": false
}
If you would like to change it in the default client, do the following:
import { APOLLO_TESTING_CACHE } from 'apollo-angular/testing';
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ApolloTestingModule],
providers: [
{
provide: APOLLO_TESTING_CACHE,
useValue: {
addTypename: true,
},
},
],
});
// ...
});
For named clients:
import { APOLLO_TESTING_NAMED_CACHE } from 'apollo-angular/testing';
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ApolloTestingModule],
providers: [
{
provide: APOLLO_TESTING_NAMED_CACHE,
useValue: {
clientA: {
addTypename: true,
},
clientB: {
addTypename: true,
},
},
},
],
});
// ...
});
Summary
For the sake of simplicity, we didn’t show how to test loading state, errors and so on, but it’s similar to what we showed above.
Testing UI components isn’t a simple issue, but hopefully these tools will create confidence when testing components that are dependent on data.