InteractiveFrameworks

Testing a GraphQL API

Write the end-to-end tests of the cats schema: a fresh application per test, queries and mutations posted to /graphql with supertest, answers compared as data and errors as codes, until four bugs planted in the code are all caught.

What you'll learn

  • Boot the GraphQL application in a test with @nestjs/testing, a fresh one per test so that mutations cannot leak between tests
  • Send queries and mutations as POST /graphql with supertest, variables included
  • Assert on data, and on errors by their extensions.code, knowing that the HTTP status is 200 either way
  • Check a mutation by its effect, querying afterwards, not only by what it answers

The cats schema has grown through eight lessons, and every change so far was checked by hand in GraphiQL. That stops working the day someone refactors the service: a create that forgets to store, a lookup that finds the wrong cat, a validation rule deleted along with an unused import. Each still answers every query with a 200 and a plausible body. Tests that send real operations to the real application catch them. This time the code is given and correct, and you write the tests. Four bugs have been planted in copies of the code, and your tests pass only when they fail against every one.

A fresh application per test

The Testing course's end-to-end setup carries over unchanged. A testing module imports AppModule, and the application is created from it and initialized:

beforeEach(async () => {
  const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
  app = moduleRef.createNestApplication();
  app.useGlobalPipes(new ValidationPipe());
  await app.init();
});

afterEach(() => app.close());

main.ts does not run in a test, so whatever it configures must be repeated here: the ValidationPipe above, or input validation silently disappears from every test. Creating the application in beforeEach gives every test its own services and data. A mutation in one test cannot leak into the next, so the tests pass in any order. That matters more with GraphQL than with REST, because a single mutation can touch anything.

Operations over HTTP

Every operation is a POST /graphql with a JSON body, so one helper covers them all:

const gql = (query: string, variables?: Record<string, unknown>) =>
  request(app.getHttpServer()).post('/graphql').send({ query, variables }).expect(200);

The .expect(200) is not a check that the operation worked. GraphQL answers 200 for a query that failed in a resolver, as the earlier lessons showed. It only asserts that the server answered at all. The outcome is in the body. A successful answer is { data }, compared whole with toEqual, which also proves that no unexpected field or error came along. A refused one has data set to null (or the failed field set to null) and an errors array. Test errors by their extensions.code, BAD_REQUEST or FORBIDDEN, which is what clients branch on. Messages can change with a library version; codes are part of the contract.

const res = await gql('mutation { bookShift(input: { hours: 40 }) { id } }');
expect(res.body.data).toBeNull();
expect(res.body.errors[0].extensions.code).toBe('BAD_REQUEST');

A query with a syntax error or an unknown field is a 400, not a 200, so a typo in a test's own query fails loudly at .expect(200).

Checking effects, not answers

A mutation's answer is what the resolver returned, not what the service did. A create that builds the object and forgets to store it answers perfectly. So after a mutation, query again and check the state: the new cat in the list, the removed cat gone. The same goes for lookups: finding cat 2 proves little if the implementation happens to return the second cat of the list. The test data (Tom is id 1, Luna id 2, and so on) must make a wrong answer look different from a right one.

Your task

test/cats.e2e-spec.ts has the outline and a TODO for each test. The application has two cats, Tom (id 1, age 3) and Luna (id 2, age 2). cat(id) answers null for an unknown id, createCat refuses a negative age, and new cats get ids from 3.

  1. Create a fresh, validated application before each test, and close it after.
  2. lists the cats: both cats with their ids, names and ages.
  3. finds a cat by id: cat(id: 2) is Luna, and cat(id: 9) is null.
  4. creates a cat: createCat with Milo, age 5, answers id 3, and the cats query lists Milo afterwards.
  5. refuses a negative age: data is null and the error's code is BAD_REQUEST.
  6. removes a cat: removeCat(id: 1) answers true, and only Luna is listed afterwards.

Run the tests, then read which bugs they caught; each bug missed is a behaviour no test checks.

When it fails

  • Cannot read properties of undefined (reading 'getHttpServer'): app was never created; the setup still has its TODO.
  • The negative age is accepted in the test but refused by the running app: the test did not apply the ValidationPipe.
  • Tests pass alone and fail together, with a cat missing or an id off by one: the application was created once, in beforeAll, and one test's mutation leaked into another.
  • A test passes against every bug: it checks only what a mutation answered. Query the state afterwards.

Remember

  • A fresh application per test keeps mutations from leaking; repeat what main.ts configures.
  • Every operation is a POST /graphql; 200 means answered, not succeeded.
  • Compare data whole, and test errors by extensions.code.
  • Check a mutation by its effect: query again.
Stuck? Show a hint

beforeEach: const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); app = moduleRef.createNestApplication(); app.useGlobalPipes(new ValidationPipe()); await app.init(). A helper: (query, variables) => request(app.getHttpServer()).post('/graphql').send({ query, variables }).expect(200). Compare res.body with toEqual; for a refusal, check res.body.data is null and res.body.errors[0].extensions.code. After createCat and removeCat, query cats again.