GraphQL Integration Testing with Supertest and Node.js

GraphQL Integration Testing with Supertest and Node.js

Apollo Server's executeOperation is excellent for most GraphQL testing. But sometimes you need to test the full HTTP stack — middleware, authentication headers, CORS, rate limiting, and HTTP error codes. Supertest lets you do that: it sends real HTTP requests against your Express/Fastify application without starting a network listener.

This guide covers GraphQL integration testing with Supertest, including auth flows, pagination, complex error handling, and CI setup.

When to Use Supertest vs executeOperation

Use executeOperation when:

  • Testing resolver logic and data transformation
  • Testing authorization in context
  • Speed matters (no HTTP overhead)
  • You control the context injection directly

Use Supertest when:

  • Testing authentication middleware (JWT parsing, cookie-based auth)
  • Testing CORS or rate limiting
  • Testing persisted query endpoints
  • Testing multipart uploads (file uploads via GraphQL)
  • Verifying HTTP status codes (some APIs return non-200 on errors)
  • Testing introspection disable behavior

Setup

npm install supertest
npm install --save-dev @types/supertest

Your Apollo Server needs to be attached to an Express app:

// src/app.ts
import express from 'express';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import cors from 'cors';
import { typeDefs, resolvers } from './schema';
import { createContext } from './context';

export async function createApp() {
  const app = express();
  
  const server = new ApolloServer({ typeDefs, resolvers });
  await server.start();
  
  app.use(cors());
  app.use(express.json());
  
  app.use(
    '/graphql',
    expressMiddleware(server, {
      context: createContext,
    })
  );
  
  return { app, server };
}

Basic Supertest GraphQL Helper

// tests/helpers/request.ts
import supertest from 'supertest';
import { Express } from 'express';

interface GraphQLRequest {
  query: string;
  variables?: Record<string, unknown>;
  operationName?: string;
}

export function graphql(app: Express) {
  return {
    async execute(
      request: GraphQLRequest,
      options: {
        authToken?: string;
        headers?: Record<string, string>;
        expectedStatus?: number;
      } = {}
    ) {
      const { authToken, headers = {}, expectedStatus = 200 } = options;

      const req = supertest(app)
        .post('/graphql')
        .set('Content-Type', 'application/json')
        .set('Accept', 'application/json');

      if (authToken) {
        req.set('Authorization', `Bearer ${authToken}`);
      }

      Object.entries(headers).forEach(([key, value]) => req.set(key, value));

      const response = await req.send(request);

      expect(response.status).toBe(expectedStatus);
      return response.body;
    },
  };
}

Authentication Testing

One of the biggest advantages of Supertest over executeOperation is testing real JWT/session middleware:

// tests/integration/auth.test.ts
import { createApp } from '../../src/app';
import { graphql } from '../helpers/request';
import { generateToken } from '../../src/auth';
import { testDb } from '../helpers/testDb';

let app: Express;
let gql: ReturnType<typeof graphql>;

beforeAll(async () => {
  const setup = await createApp();
  app = setup.app;
  gql = graphql(app);
  await testDb.migrate();
});

afterAll(() => testDb.close());
beforeEach(() => testDb.seed());
afterEach(() => testDb.cleanup());

describe('Authentication', () => {
  it('returns user data with valid JWT', async () => {
    const token = generateToken({ userId: '1', role: 'user' });
    
    const body = await gql.execute(
      { query: `query { me { id email } }` },
      { authToken: token }
    );

    expect(body.errors).toBeUndefined();
    expect(body.data.me.id).toBe('1');
  });

  it('returns UNAUTHENTICATED without token', async () => {
    const body = await gql.execute(
      { query: `query { me { id } }` }
      // no authToken
    );

    expect(body.errors[0].extensions.code).toBe('UNAUTHENTICATED');
    expect(body.data.me).toBeNull();
  });

  it('returns UNAUTHENTICATED with expired token', async () => {
    const expiredToken = generateToken({ userId: '1' }, { expiresIn: '-1s' });
    
    const body = await gql.execute(
      { query: `query { me { id } }` },
      { authToken: expiredToken }
    );

    expect(body.errors[0].extensions.code).toBe('UNAUTHENTICATED');
  });

  it('returns UNAUTHENTICATED with malformed token', async () => {
    const body = await gql.execute(
      { query: `query { me { id } }` },
      { authToken: 'not-a-valid-jwt' }
    );

    expect(body.errors[0].extensions.code).toBe('UNAUTHENTICATED');
  });
});

Pagination Testing

Pagination bugs are common. Test cursor-based pagination thoroughly:

describe('Pagination', () => {
  beforeEach(async () => {
    // Create 25 products in a known order
    await testDb.products.bulkCreate(
      Array.from({ length: 25 }, (_, i) => ({
        name: `Product ${i + 1}`,
        price: (i + 1) * 10,
      }))
    );
  });

  const PRODUCTS_QUERY = `
    query Products($first: Int, $after: String, $last: Int, $before: String) {
      products(first: $first, after: $after, last: $last, before: $before) {
        edges {
          cursor
          node {
            id
            name
          }
        }
        pageInfo {
          hasNextPage
          hasPreviousPage
          startCursor
          endCursor
        }
        totalCount
      }
    }
  `;

  const token = generateToken({ userId: '1', role: 'user' });

  it('returns first page with hasNextPage=true', async () => {
    const body = await gql.execute(
      { query: PRODUCTS_QUERY, variables: { first: 10 } },
      { authToken: token }
    );

    const { edges, pageInfo, totalCount } = body.data.products;
    expect(edges).toHaveLength(10);
    expect(pageInfo.hasNextPage).toBe(true);
    expect(pageInfo.hasPreviousPage).toBe(false);
    expect(totalCount).toBe(25);
  });

  it('returns second page correctly', async () => {
    // Get first page to get cursor
    const first = await gql.execute(
      { query: PRODUCTS_QUERY, variables: { first: 10 } },
      { authToken: token }
    );
    const endCursor = first.data.products.pageInfo.endCursor;

    // Get second page
    const second = await gql.execute(
      { query: PRODUCTS_QUERY, variables: { first: 10, after: endCursor } },
      { authToken: token }
    );

    expect(second.data.products.edges).toHaveLength(10);
    // Second page should not contain any items from first page
    const firstIds = first.data.products.edges.map((e: any) => e.node.id);
    const secondIds = second.data.products.edges.map((e: any) => e.node.id);
    const overlap = firstIds.filter((id: string) => secondIds.includes(id));
    expect(overlap).toHaveLength(0);
  });

  it('returns last page with hasNextPage=false', async () => {
    const body = await gql.execute(
      { query: PRODUCTS_QUERY, variables: { first: 30 } }, // more than total
      { authToken: token }
    );

    expect(body.data.products.edges).toHaveLength(25);
    expect(body.data.products.pageInfo.hasNextPage).toBe(false);
  });

  it('returns empty for out-of-range cursor', async () => {
    const body = await gql.execute(
      {
        query: PRODUCTS_QUERY,
        variables: { first: 10, after: 'invalid-cursor-xyz' },
      },
      { authToken: token }
    );

    // Should return empty or error — depends on implementation
    // Test whatever your API is supposed to do
    expect(body.errors || body.data.products.edges).toBeDefined();
  });
});

Error Handling Patterns

GraphQL errors behave differently from REST. Test each error path explicitly:

describe('Error handling', () => {
  const token = generateToken({ userId: '1', role: 'user' });

  it('returns 200 with errors array on resolver error', async () => {
    // Note: GraphQL returns 200 even for errors
    const body = await gql.execute(
      { query: `query { user(id: "nonexistent") { id name } }` },
      { authToken: token, expectedStatus: 200 }
    );

    expect(body.data.user).toBeNull();
    expect(body.errors[0].extensions.code).toBe('NOT_FOUND');
    expect(body.errors[0].path).toEqual(['user']);
  });

  it('returns 400 for invalid GraphQL syntax', async () => {
    const response = await supertest(app)
      .post('/graphql')
      .set('Authorization', `Bearer ${token}`)
      .send({ query: 'this is not valid graphql {{{}' });

    expect(response.status).toBe(400);
  });

  it('returns partial data with errors for field-level errors', async () => {
    // A query where one field succeeds and another fails
    const body = await gql.execute(
      {
        query: `query {
          user(id: "1") { id name }
          user2: user(id: "nonexistent") { id name }
        }`,
      },
      { authToken: token }
    );

    // user should be present
    expect(body.data.user).not.toBeNull();
    // user2 should be null (error)
    expect(body.data.user2).toBeNull();
    // errors array should have one error for user2
    expect(body.errors).toHaveLength(1);
    expect(body.errors[0].path).toEqual(['user2']);
  });

  it('masks internal errors in production', async () => {
    // Force an unexpected error
    jest.spyOn(testDb.users, 'findById').mockRejectedValueOnce(
      new Error('INTERNAL: db connection pool exhausted')
    );

    const body = await gql.execute(
      { query: `query { user(id: "1") { id } }` },
      { authToken: token }
    );

    // Production should mask internal error messages
    expect(body.errors[0].message).not.toContain('db connection pool');
    expect(body.errors[0].extensions.code).toBe('INTERNAL_SERVER_ERROR');
  });
});

Testing File Uploads

GraphQL multipart uploads (via graphql-upload) require special testing:

describe('Mutation.uploadAvatar', () => {
  it('uploads and processes image', async () => {
    const token = generateToken({ userId: '1', role: 'user' });
    
    const response = await supertest(app)
      .post('/graphql')
      .set('Authorization', `Bearer ${token}`)
      .field(
        'operations',
        JSON.stringify({
          query: `mutation UploadAvatar($file: Upload!) { uploadAvatar(file: $file) { url } }`,
          variables: { file: null },
        })
      )
      .field('map', JSON.stringify({ '0': ['variables.file'] }))
      .attach('0', Buffer.from('fake-image-data'), {
        filename: 'avatar.jpg',
        contentType: 'image/jpeg',
      });

    expect(response.body.data.uploadAvatar.url).toMatch(/^https?:\/\//);
  });
});

CI Configuration

# .github/workflows/graphql-integration.yml
name: GraphQL Integration Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_DB: testdb
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
        ports:
          - 5432:5432
        options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5

      redis:
        image: redis:7
        ports:
          - 6379:6379

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - name: Run integration tests
        run: npm test -- --testPathPattern=integration
        env:
          DATABASE_URL: postgres://test:test@localhost:5432/testdb
          REDIS_URL: redis://localhost:6379
          JWT_SECRET: test-secret-not-for-production
          NODE_ENV: test

Performance Considerations

Supertest tests are slower than executeOperation tests because they go through the full HTTP stack. Keep your test suite fast by:

  1. Sharing the app instance: Create the Express app once in beforeAll, not in each test
  2. Reusing tokens: Generate JWT tokens once per describe block, not per test
  3. Minimal seeding: Only seed the data each test actually needs
  4. Parallel test files: Jest runs test files in parallel by default — don't fight it with shared state

A typical integration test suite for a medium GraphQL API should complete in under 60 seconds. If it's slower, profile which tests are slowest and look for missing indexes or N+1 queries in your resolvers.

Read more