Integration Testing GraphQL Servers: From Resolvers to Database

Integration Testing GraphQL Servers: From Resolvers to Database

GraphQL unit tests verify individual resolvers in isolation. Integration tests verify that the full stack — schema, resolvers, middleware, database — works together correctly. This guide covers building a test environment that runs actual operations against a real database, tests authentication middleware, and verifies end-to-end behavior without mocking the critical path.

Key Takeaways

Don't mock the database in integration tests. The point of integration tests is to verify real interactions. Use a test database, not in-memory fakes.

Use apollo-server-testing or direct HTTP requests. Both approaches are valid — pick based on whether you need to test HTTP-layer concerns (headers, status codes).

Isolate test data per test. Use beforeEach to reset state and create only the data each test needs. Never depend on test execution order.

Test the full auth path. Integration tests should pass real JWTs (or omit them) and verify that the authorization layer actually enforces access control.

Seed minimally. Only insert the data each test needs. Global seed files that insert hundreds of rows are a maintenance nightmare.

Unit Tests vs. Integration Tests

Concern Unit Test Integration Test
Resolver logic ✓ Mock DB ✓ Real DB
Schema validation ✓ ✓
Auth middleware ✗ Bypassed ✓ Full stack
Database queries ✗ Mocked ✓ Real queries
Connection pooling ✗ ✓
N+1 detection ✗ ✓
Speed Fast (ms) Slower (100ms–2s)

You need both. Unit tests catch logic bugs quickly. Integration tests catch wiring bugs that unit tests can't see.

Test Database Setup

Using Testcontainers

Testcontainers spins up a real database in Docker for the duration of your test suite:

npm install --save-dev testcontainers
// test/setup.js
const { PostgreSqlContainer } = require('@testcontainers/postgresql');
const { db } = require('../db');

let container;

beforeAll(async () => {
  container = await new PostgreSqlContainer()
    .withDatabase('testdb')
    .withUsername('test')
    .withPassword('test')
    .start();

  // Override DB connection to use test container
  process.env.DATABASE_URL = container.getConnectionUri();
  await db.migrate.latest(); // run migrations
}, 60_000);

afterAll(async () => {
  await db.destroy();
  await container.stop();
});

beforeEach(async () => {
  // Clean all tables before each test
  await db.raw('TRUNCATE users, posts RESTART IDENTITY CASCADE');
});

Using a Persistent Test DB

If Testcontainers is too slow for your CI budget, use a dedicated test database:

// test/db-setup.js
const testDb = require('./test-db-connection');

beforeEach(async () => {
  await testDb.transaction(async (trx) => {
    await trx.raw('TRUNCATE users, posts RESTART IDENTITY CASCADE');
  });
});

Building a Test Client

Using supertest (HTTP-level)

const request = require('supertest');
const { createApp } = require('../app');

let app;

beforeAll(async () => {
  app = await createApp({ db: testDb });
});

async function gql(query, variables, token) {
  const req = request(app)
    .post('/graphql')
    .send({ query, variables });

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

  return req.expect(200);
}

module.exports = { gql };

Using Apollo Server's built-in test client

const { ApolloServer } = require('@apollo/server');
const { schema } = require('../schema');
const { resolvers } = require('../resolvers');

let server;

beforeAll(async () => {
  server = new ApolloServer({ typeDefs: schema, resolvers });
  await server.start();
});

afterAll(async () => {
  await server.stop();
});

async function executeOperation(query, variables, contextValue = {}) {
  return server.executeOperation(
    { query, variables },
    { contextValue }
  );
}

Writing Integration Tests

Query Integration Tests

const { gql } = require('./test-client');
const { db } = require('../db');

describe('Query.users integration', () => {
  beforeEach(async () => {
    await db('users').insert([
      { id: 1, name: 'Alice', email: 'alice@test.com' },
      { id: 2, name: 'Bob', email: 'bob@test.com' },
    ]);
  });

  it('returns all users', async () => {
    const query = `query { users { id name email } }`;

    const response = await gql(query);

    expect(response.body.errors).toBeUndefined();
    expect(response.body.data.users).toHaveLength(2);
    expect(response.body.data.users).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ name: 'Alice' }),
        expect.objectContaining({ name: 'Bob' }),
      ])
    );
  });

  it('filters users by search term', async () => {
    const query = `query Search($term: String!) { users(search: $term) { name } }`;

    const response = await gql(query, { term: 'Alice' });

    expect(response.body.data.users).toHaveLength(1);
    expect(response.body.data.users[0].name).toBe('Alice');
  });
});

Mutation Integration Tests

describe('Mutation.createUser integration', () => {
  it('creates user in database and returns it', async () => {
    const mutation = `
      mutation CreateUser($name: String!, $email: String!) {
        createUser(name: $name, email: $email) {
          id
          name
          email
        }
      }
    `;

    const adminToken = await createAdminToken();
    const response = await gql(mutation, {
      name: 'Charlie',
      email: 'charlie@test.com',
    }, adminToken);

    expect(response.body.errors).toBeUndefined();

    const { createUser } = response.body.data;
    expect(createUser.name).toBe('Charlie');
    expect(createUser.id).toBeDefined();

    // Verify database was actually written to
    const dbUser = await db('users').where({ id: createUser.id }).first();
    expect(dbUser).not.toBeNull();
    expect(dbUser.email).toBe('charlie@test.com');
    expect(dbUser.created_at).toBeDefined();
  });

  it('rejects mutation without authentication', async () => {
    const mutation = `
      mutation {
        createUser(name: "Eve", email: "eve@test.com") { id }
      }
    `;

    const response = await gql(mutation); // No token

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

    // Verify no user was written to database
    const count = await db('users').count('id as count').first();
    expect(parseInt(count.count)).toBe(0);
  });
});

Testing N+1 Query Detection

One of the most important integration tests for GraphQL is detecting N+1 database queries:

const { db } = require('../db');

describe('N+1 detection', () => {
  beforeEach(async () => {
    await db('users').insert([
      { id: 1, name: 'Alice', email: 'a@test.com' },
      { id: 2, name: 'Bob', email: 'b@test.com' },
      { id: 3, name: 'Carol', email: 'c@test.com' },
    ]);

    await db('posts').insert([
      { id: 1, user_id: 1, title: 'Alice Post 1' },
      { id: 2, user_id: 1, title: 'Alice Post 2' },
      { id: 3, user_id: 2, title: 'Bob Post' },
    ]);
  });

  it('fetches users with posts using a single batch query', async () => {
    const queries = [];
    // Intercept all DB queries
    db.on('query', q => queries.push(q.sql));

    const query = `
      query {
        users {
          name
          posts { title }
        }
      }
    `;

    await gql(query);

    // With DataLoader: 2 queries (1 for users, 1 batched for all posts)
    // Without DataLoader: 4 queries (1 for users, then 1 per user)
    const postQueries = queries.filter(q => q.includes('posts'));
    expect(postQueries).toHaveLength(1); // Batched!
  });
});

Testing Transactions and Rollbacks

describe('Transactional mutations', () => {
  it('rolls back all changes if mutation partially fails', async () => {
    const mutation = `
      mutation {
        transferCredits(fromUserId: "1", toUserId: "2", amount: 1000) {
          success
        }
      }
    `;

    // User 1 doesn't have enough credits
    await db('users').insert([
      { id: 1, credits: 50 },
      { id: 2, credits: 100 },
    ]);

    const response = await gql(mutation, {}, adminToken);

    expect(response.body.errors).toBeDefined();

    // Verify NO credits were transferred — transaction rolled back
    const user1 = await db('users').where({ id: 1 }).first();
    const user2 = await db('users').where({ id: 2 }).first();
    expect(user1.credits).toBe(50); // unchanged
    expect(user2.credits).toBe(100); // unchanged
  });
});

Running Integration Tests in CI

# .github/workflows/integration-test.yml
name: GraphQL Integration Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

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

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm run db:migrate:test
        env:
          DATABASE_URL: postgres://test:test@localhost:5432/testdb
      - run: npm run test:integration
        env:
          DATABASE_URL: postgres://test:test@localhost:5432/testdb

Continuous Monitoring

HelpMeTest can run your integration test scenarios on a schedule against your staging or production GraphQL endpoint, catching regressions between deployments.

Set up scheduled API testing →

Read more

Start now free