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/supertestYour 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: testPerformance Considerations
Supertest tests are slower than executeOperation tests because they go through the full HTTP stack. Keep your test suite fast by:
- Sharing the app instance: Create the Express app once in
beforeAll, not in each test - Reusing tokens: Generate JWT tokens once per describe block, not per test
- Minimal seeding: Only seed the data each test actually needs
- 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.