Catch the highlights of GraphQLConf 2023! Click for recordings. Or check out our recap blog post.
v2
Development and Testing
Testing

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.

An 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: `
    <div *ngIf="loading$">Loading ...</div>
    <div *ngIf="error$">Error!</div>
    <p *ngIf="dog$ | async as dog">{{ dog.name }} is a {{ dog.breed }}</p>
  `,
})
export class DogComponent implements OnInit {
  @Input() name: string;
 
  loading$: Observable<boolean>;
  error$: Observable<any>;
  dog$: Observable<any>;
 
  constructor(private 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.

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 instance
  • networkError(error: Error): void - to flush an operation with a network error
  • graphqlErrors(errors: GraphQLError[]): void - to flush an operation with graphql errors

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.