Testing
Testing is essential for reliable GraphQL APIs. Axolotl's functional approach makes testing straightforward—resolvers are just functions!
Why Test Axolotl Resolvers?
- Pure functions: Resolvers have no hidden dependencies
- Type safety: TypeScript catches many errors at compile time
- Easy mocking: Context, database, and services are easily mocked
- Fast: Unit tests run in milliseconds
- Confident refactoring: Tests ensure changes don't break functionality
Testing Strategies
1. Unit Tests (Resolver Level)
Test individual resolvers in isolation:
import { test } from 'node:test';
import assert from 'node:assert';
import resolvers from './resolvers.js';
test('Query.hello returns World', () => {
const result = resolvers.Query.hello([null, {}, {}], {});
assert.equal(result, 'World');
});
test('Query.user fetches user by id', async () => {
const mockDb = {
user: {
findUnique: async ({ where }: any) => ({
id: where.id,
name: 'John Doe',
email: 'john@example.com',
}),
},
};
const result = await resolvers.Query.user([null, {}, { db: mockDb }], { id: '123' });
assert.equal(result.id, '123');
assert.equal(result.name, 'John Doe');
});
test('Mutation.createPost requires authentication', async () => {
const ctx = { userId: null, db: mockDb };
await assert.rejects(
() => resolvers.Mutation.createPost([null, {}, ctx], { input: { title: 'Test', content: 'Content' } }),
{ message: /Not authenticated/ },
);
});2. Integration Tests (With Real Database)
Test with a real database:
import { test, before, after } from 'node:test';
import assert from 'node:assert';
import { PrismaClient } from '@prisma/client';
import resolvers from './resolvers.js';
let db: PrismaClient;
before(async () => {
db = new PrismaClient();
// Seed test data
await db.user.create({
data: { id: 'user-1', username: 'testuser', email: 'test@example.com' },
});
});
after(async () => {
// Cleanup
await db.user.deleteMany();
await db.$disconnect();
});
test('creates post successfully', async () => {
const result = await resolvers.Mutation.createPost([null, {}, { db, userId: 'user-1' }], {
input: { title: 'Test Post', content: 'Test content' },
});
assert.ok(result.id);
assert.equal(result.title, 'Test Post');
assert.equal(result.authorId, 'user-1');
// Verify in database
const post = await db.post.findUnique({ where: { id: result.id } });
assert.ok(post);
});
test('updates post with correct ownership', async () => {
// Create post
const post = await db.post.create({
data: {
title: 'Original',
content: 'Content',
authorId: 'user-1',
},
});
// Update as owner
const updated = await resolvers.Mutation.updatePost([null, {}, { db, userId: 'user-1' }], {
id: post.id,
input: { title: 'Updated' },
});
assert.equal(updated.title, 'Updated');
// Try to update as different user
await assert.rejects(
() =>
resolvers.Mutation.updatePost([null, {}, { db, userId: 'user-2' }], { id: post.id, input: { title: 'Hacked' } }),
{ message: /Forbidden/ },
);
});3. E2E Tests (Full GraphQL Queries)
Test complete GraphQL operations:
import { test, before, after } from 'node:test';
import assert from 'node:assert';
import { adapter } from './axolotl.js';
import resolvers from './resolvers.js';
let server: any;
let url: string;
before(async () => {
const { server: s } = adapter({ resolvers });
server = s;
await new Promise((resolve) => server.listen(0, resolve));
const address = server.address();
url = `http://localhost:${address.port}/graphql`;
});
after(async () => {
await server.close();
});
test('registers and logs in user', async () => {
// Register
const registerRes = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `
mutation Register($input: RegisterInput!) {
register(input: $input) {
user { id username }
token
}
}
`,
variables: {
input: {
username: 'newuser',
email: 'new@example.com',
password: 'password123',
},
},
}),
});
const registerData = await registerRes.json();
assert.ok(registerData.data.register.token);
assert.equal(registerData.data.register.user.username, 'newuser');
// Login
const loginRes = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `
mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) {
token
}
}
`,
variables: {
email: 'new@example.com',
password: 'password123',
},
}),
});
const loginData = await loginRes.json();
assert.ok(loginData.data.login.token);
});
test('protected query requires authentication', async () => {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: '{ me { id } }',
}),
});
const data = await res.json();
assert.ok(data.errors);
assert.match(data.errors[0].message, /Not authenticated/);
});Testing Patterns
Mock Database
function createMockDb() {
const users = new Map();
const posts = new Map();
return {
user: {
findUnique: async ({ where }: any) => users.get(where.id) || null,
findMany: async () => Array.from(users.values()),
create: async ({ data }: any) => {
const user = { ...data, id: `user-${users.size + 1}` };
users.set(user.id, user);
return user;
},
update: async ({ where, data }: any) => {
const user = users.get(where.id);
if (!user) throw new Error('Not found');
Object.assign(user, data);
return user;
},
},
post: {
findMany: async ({ where }: any) => {
const allPosts = Array.from(posts.values());
if (!where) return allPosts;
return allPosts.filter((p) => p.authorId === where.authorId);
},
create: async ({ data }: any) => {
const post = { ...data, id: `post-${posts.size + 1}` };
posts.set(post.id, post);
return post;
},
},
};
}
test('uses mock database', async () => {
const db = createMockDb();
const user = await resolvers.Mutation.register([null, {}, { db }], {
input: { username: 'test', email: 'test@example.com', password: 'pass' },
});
assert.ok(user.id);
});Test Context Builder
function createTestContext(overrides = {}) {
return {
userId: null,
userRoles: [],
db: createMockDb(),
request: {
headers: new Map(),
},
...overrides,
};
}
test('authenticated user can create post', async () => {
const ctx = createTestContext({ userId: 'user-1' });
const post = await resolvers.Mutation.createPost([null, {}, ctx], { input: { title: 'Test', content: 'Content' } });
assert.equal(post.authorId, 'user-1');
});Test Data Builders
function buildUser(overrides = {}) {
return {
id: `user-${Math.random()}`,
username: 'testuser',
email: 'test@example.com',
createdAt: new Date().toISOString(),
...overrides,
};
}
function buildPost(overrides = {}) {
return {
id: `post-${Math.random()}`,
title: 'Test Post',
content: 'Test content',
authorId: 'user-1',
createdAt: new Date().toISOString(),
...overrides,
};
}
test('uses test data builders', () => {
const user = buildUser({ username: 'custom' });
const post = buildPost({ authorId: user.id });
assert.equal(post.authorId, user.id);
});Testing Directives
import { makeExecutableSchema } from '@graphql-tools/schema';
import { graphql } from 'graphql';
test('auth directive blocks unauthenticated users', async () => {
const typeDefs = `
directive @auth on FIELD_DEFINITION
type Query {
public: String!
protected: String! @auth
}
`;
const resolvers = {
Query: {
public: () => 'public data',
protected: () => 'protected data',
},
};
let schema = makeExecutableSchema({ typeDefs, resolvers });
schema = authDirective(schema);
// Unauthenticated request
const result = await graphql({
schema,
source: '{ protected }',
contextValue: { userId: null },
});
assert.ok(result.errors);
assert.match(result.errors[0].message, /Not authenticated/);
// Authenticated request
const result2 = await graphql({
schema,
source: '{ protected }',
contextValue: { userId: 'user-1' },
});
assert.equal(result2.data?.protected, 'protected data');
});CI/CD Integration
GitHub Actions
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '20'
- run: npm install
- run: npm run build
- run: npx prisma migrate deploy
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
- run: npm test
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
JWT_SECRET: test-secretCoverage
Track test coverage:
// package.json
{
"scripts": {
"test": "node --test --experimental-test-coverage",
"test:coverage": "node --test --experimental-test-coverage"
}
}Best Practices
- Test behavior, not implementation - Test what resolvers do, not how
- Use descriptive test names -
test('creates post successfully')nottest('test 1') - Keep tests isolated - Each test should be independent
- Mock external services - Don't call real APIs in tests
- Test error cases - Not just happy paths
- Use test data builders - For consistent test data
- Clean up after tests - Delete test data
- Run tests in CI/CD - Automate testing
Performance Testing
import { test } from 'node:test';
import assert from 'node:assert';
test('query completes within time limit', async () => {
const start = Date.now();
await resolvers.Query.posts([null, {}, { db }], { limit: 100 });
const duration = Date.now() - start;
assert.ok(duration < 100, `Query took ${duration}ms, expected < 100ms`);
});Next Steps
- See Best Practices for testing strategies
- Learn about Recipes for common test patterns
- Check CI/CD examples for deployment pipelines