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/testdbContinuous Monitoring
HelpMeTest can run your integration test scenarios on a schedule against your staging or production GraphQL endpoint, catching regressions between deployments.