TechLead
Lesson 19 of 20
5 min read
GraphQL

Testing GraphQL APIs

Write comprehensive tests for GraphQL APIs including unit tests for resolvers, integration tests, and end-to-end query testing

Testing Strategy for GraphQL

A well-tested GraphQL API requires tests at multiple levels: unit tests for individual resolvers and business logic, integration tests for the full GraphQL execution pipeline, and end-to-end tests for critical user flows. Each level catches different types of bugs and provides different levels of confidence.

Testing Pyramid for GraphQL

  • Unit tests: Test resolvers in isolation with mocked data sources
  • Integration tests: Execute real GraphQL queries against the schema with a test database
  • E2E tests: Test the full HTTP pipeline including auth, middleware, and error handling

Unit Testing Resolvers

// resolvers.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { resolvers } from './resolvers';
import { GraphQLError } from 'graphql';

// Mock Prisma
const mockPrisma = {
  user: {
    findUnique: vi.fn(),
    findMany: vi.fn(),
    create: vi.fn(),
  },
  post: {
    findUnique: vi.fn(),
    findMany: vi.fn(),
    create: vi.fn(),
    update: vi.fn(),
    delete: vi.fn(),
    count: vi.fn(),
  },
};

function createMockContext(overrides?: Partial<Context>): Context {
  return {
    prisma: mockPrisma as any,
    currentUser: null,
    loaders: createMockLoaders(),
    ...overrides,
  };
}

describe('Query resolvers', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  describe('user', () => {
    it('returns a user by ID', async () => {
      const mockUser = { id: '1', name: 'Alice', email: 'alice@test.com' };
      mockPrisma.user.findUnique.mockResolvedValue(mockUser);

      const ctx = createMockContext();
      const result = await resolvers.Query.user(null, { id: '1' }, ctx);

      expect(result).toEqual(mockUser);
      expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({
        where: { id: '1' },
      });
    });

    it('returns null for non-existent user', async () => {
      mockPrisma.user.findUnique.mockResolvedValue(null);

      const ctx = createMockContext();
      const result = await resolvers.Query.user(null, { id: '999' }, ctx);

      expect(result).toBeNull();
    });
  });

  describe('me', () => {
    it('returns the current user when authenticated', async () => {
      const currentUser = { id: '1', name: 'Alice', email: 'alice@test.com' };
      const ctx = createMockContext({ currentUser: currentUser as any });

      const result = await resolvers.Query.me(null, {}, ctx);
      expect(result).toEqual(currentUser);
    });

    it('returns null when not authenticated', async () => {
      const ctx = createMockContext();
      const result = await resolvers.Query.me(null, {}, ctx);
      expect(result).toBeNull();
    });
  });
});

describe('Mutation resolvers', () => {
  describe('createPost', () => {
    it('creates a post for authenticated user', async () => {
      const currentUser = { id: '1', name: 'Alice' };
      const newPost = { id: '10', title: 'Test Post', body: 'Content', authorId: '1' };
      mockPrisma.post.create.mockResolvedValue(newPost);

      const ctx = createMockContext({ currentUser: currentUser as any });
      const result = await resolvers.Mutation.createPost(
        null,
        { input: { title: 'Test Post', body: 'Content' } },
        ctx
      );

      expect(result).toEqual(newPost);
      expect(mockPrisma.post.create).toHaveBeenCalledWith({
        data: expect.objectContaining({
          title: 'Test Post',
          authorId: '1',
        }),
      });
    });

    it('throws UNAUTHENTICATED for anonymous users', async () => {
      const ctx = createMockContext();

      await expect(
        resolvers.Mutation.createPost(null, { input: { title: 'Test', body: 'Content' } }, ctx)
      ).rejects.toThrow(GraphQLError);
    });
  });

  describe('deletePost', () => {
    it('throws FORBIDDEN when user does not own the post', async () => {
      const currentUser = { id: '1', name: 'Alice' };
      const post = { id: '10', authorId: '2' }; // Different author
      mockPrisma.post.findUnique.mockResolvedValue(post);

      const ctx = createMockContext({ currentUser: currentUser as any });

      await expect(
        resolvers.Mutation.deletePost(null, { id: '10' }, ctx)
      ).rejects.toThrow('unauthorized');
    });
  });
});

Integration Testing with Real Schema Execution

// integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { ApolloServer } from '@apollo/server';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

let server: ApolloServer;

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

  // Seed test data
  await prisma.user.create({
    data: { id: 'test-user-1', name: 'Test User', email: 'test@test.com', password: 'hashed' },
  });
  await prisma.post.create({
    data: { id: 'test-post-1', title: 'Test Post', body: 'Content', authorId: 'test-user-1' },
  });
});

afterAll(async () => {
  await prisma.post.deleteMany();
  await prisma.user.deleteMany();
  await prisma.$disconnect();
  await server.stop();
});

describe('GraphQL Integration Tests', () => {
  it('fetches a user with their posts', async () => {
    const response = await server.executeOperation({
      query: `
        query GetUser($id: ID!) {
          user(id: $id) {
            id
            name
            posts {
              id
              title
            }
          }
        }
      `,
      variables: { id: 'test-user-1' },
    }, {
      contextValue: {
        prisma,
        currentUser: null,
        loaders: createLoaders(prisma),
      },
    });

    expect(response.body.kind).toBe('single');
    if (response.body.kind === 'single') {
      expect(response.body.singleResult.errors).toBeUndefined();
      expect(response.body.singleResult.data?.user).toEqual({
        id: 'test-user-1',
        name: 'Test User',
        posts: [{ id: 'test-post-1', title: 'Test Post' }],
      });
    }
  });

  it('requires authentication for createPost', async () => {
    const response = await server.executeOperation({
      query: `
        mutation CreatePost($input: CreatePostInput!) {
          createPost(input: $input) { id title }
        }
      `,
      variables: { input: { title: 'New Post', body: 'Content' } },
    }, {
      contextValue: { prisma, currentUser: null, loaders: createLoaders(prisma) },
    });

    if (response.body.kind === 'single') {
      expect(response.body.singleResult.errors).toBeDefined();
      expect(response.body.singleResult.errors![0].extensions?.code).toBe('UNAUTHENTICATED');
    }
  });
});

Testing with Supertest (E2E)

// e2e.test.ts
import request from 'supertest';
import { app } from './app'; // Your Express app

describe('GraphQL E2E Tests', () => {
  it('returns 200 for valid queries', async () => {
    const response = await request(app)
      .post('/graphql')
      .send({
        query: '{ users(limit: 5) { id name } }',
      })
      .expect(200);

    expect(response.body.data.users).toBeDefined();
    expect(response.body.errors).toBeUndefined();
  });

  it('handles authentication flow', async () => {
    // Sign up
    const signUpRes = await request(app)
      .post('/graphql')
      .send({
        query: `
          mutation {
            signUp(input: { name: "E2E User", email: "e2e@test.com", password: "securepass123" }) {
              token
              user { id name }
            }
          }
        `,
      })
      .expect(200);

    const token = signUpRes.body.data.signUp.token;

    // Use token for authenticated request
    const meRes = await request(app)
      .post('/graphql')
      .set('Authorization', `Bearer ${token}`)
      .send({ query: '{ me { id name email } }' })
      .expect(200);

    expect(meRes.body.data.me.name).toBe('E2E User');
  });
});

Testing Best Practices

  • Test the schema contract: Verify queries return the expected shape
  • Test error cases: Verify auth failures, validation errors, and not-found cases
  • Use a test database: Use a separate database for integration tests to avoid data contamination
  • Mock DataLoaders: In unit tests, mock DataLoaders to test resolvers in isolation
  • Test N+1 prevention: Log database queries during tests to verify batching works

Continue Learning