Testing Apollo Server: Resolvers, DataSources, and Subscriptions with Jest

Testing Apollo Server: Resolvers, DataSources, and Subscriptions with Jest

Apollo Server v4 introduced a cleaner testing API centered on executeOperation, which lets you run full GraphQL operations without spinning up an HTTP server. This post covers testing resolvers, DataSources, authentication via context, subscriptions with graphql-ws, and how to use snapshot testing effectively for GraphQL responses — all with Jest.

Key Takeaways

executeOperation replaces apollo-server-testing. The v4 API is built-in and requires no extra package — pass operations and context values directly to the server instance. Mock dataSources at the context level. Pass mock DataSource objects in the contextValue rather than trying to patch module imports — this keeps tests isolated and explicit. Test auth in context construction, not in resolvers. If your context function extracts a user from a JWT, test that function independently with valid and invalid tokens. Snapshot testing works well for complex nested responses. Use Jest snapshots to catch unintended schema changes, but update them intentionally when the schema evolves. Subscription tests need async iterators. PubSub-based subscriptions are testable without WebSockets by driving the iterator directly in tests.

Apollo Server v4 made significant changes to how the server is structured and how testing works. The old apollo-server-testing package is gone. The new approach is cleaner: executeOperation is a first-class method on the ApolloServer instance, and the context API is more explicit. This post covers the full testing surface of an Apollo Server v4 application.

Project Setup

The examples use Apollo Server v4 with a typical Express integration. Install the dependencies:

npm install @apollo/server graphql express
npm install --save-dev jest @types/jest supertest

Here's the application structure we'll test:

src/
  schema.js          # typeDefs
  resolvers/
    user.js
    post.js
  dataSources/
    UserAPI.js
    PostAPI.js
  context.js         # context factory function
  server.js          # ApolloServer instance

Setting Up the Test Server

Create a test factory that builds an Apollo Server instance configured for testing:

// test-utils/createTestServer.js
const { ApolloServer } = require('@apollo/server');
const { typeDefs } = require('../src/schema');
const { resolvers } = require('../src/resolvers');

async function createTestServer(contextValue = {}) {
  const server = new ApolloServer({
    typeDefs,
    resolvers,
    // Disable introspection warnings in test output
    includeStacktraceInErrorResponses: false
  });

  await server.start();
  return server;
}

module.exports = { createTestServer };

For tests that need the full HTTP stack, a second factory builds an Express app:

// test-utils/createTestApp.js
const express = require('express');
const { expressMiddleware } = require('@apollo/server/express4');
const { createTestServer } = require('./createTestServer');

async function createTestApp(contextFactory) {
  const server = await createTestServer();
  const app = express();

  app.use(express.json());
  app.use('/graphql', expressMiddleware(server, {
    context: contextFactory
  }));

  return { app, server };
}

module.exports = { createTestApp };

Testing Resolvers with executeOperation

The executeOperation method accepts a GraphQL request object and an optional contextValue. It returns the response body without making an HTTP request:

// resolvers/user.test.js
const { createTestServer } = require('../test-utils/createTestServer');

const GET_USER = `
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      email
      role
    }
  }
`;

describe('user query', () => {
  let server;

  beforeAll(async () => {
    server = await createTestServer();
  });

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

  it('returns a user for authenticated requests', async () => {
    const mockUserAPI = {
      getUser: jest.fn().mockResolvedValue({
        id: 'user-1',
        name: 'Alice Chen',
        email: 'alice@example.com',
        role: 'ADMIN'
      })
    };

    const { body } = await server.executeOperation(
      { query: GET_USER, variables: { id: 'user-1' } },
      {
        contextValue: {
          authenticatedUser: { id: 'caller-1', role: 'ADMIN' },
          dataSources: { userAPI: mockUserAPI }
        }
      }
    );

    expect(body.kind).toBe('single');
    expect(body.singleResult.errors).toBeUndefined();
    expect(body.singleResult.data.user).toMatchObject({
      id: 'user-1',
      name: 'Alice Chen'
    });
    expect(mockUserAPI.getUser).toHaveBeenCalledWith('user-1');
  });

  it('returns null for non-existent users', async () => {
    const mockUserAPI = {
      getUser: jest.fn().mockResolvedValue(null)
    };

    const { body } = await server.executeOperation(
      { query: GET_USER, variables: { id: 'missing' } },
      {
        contextValue: {
          authenticatedUser: { id: 'caller-1' },
          dataSources: { userAPI: mockUserAPI }
        }
      }
    );

    expect(body.singleResult.errors).toBeUndefined();
    expect(body.singleResult.data.user).toBeNull();
  });
});

Testing Mutations

Mutations follow the same pattern. Test both the success path and validation errors:

const CREATE_POST = `
  mutation CreatePost($input: CreatePostInput!) {
    createPost(input: $input) {
      id
      title
      status
      author {
        id
        name
      }
    }
  }
`;

describe('createPost mutation', () => {
  it('creates a post and returns the author', async () => {
    const mockPostAPI = {
      createPost: jest.fn().mockResolvedValue({
        id: 'post-1',
        title: 'Hello World',
        status: 'DRAFT',
        authorId: 'user-1'
      })
    };

    const mockUserAPI = {
      getUser: jest.fn().mockResolvedValue({
        id: 'user-1',
        name: 'Alice Chen'
      })
    };

    const { body } = await server.executeOperation(
      {
        query: CREATE_POST,
        variables: {
          input: { title: 'Hello World', content: 'Post content here.' }
        }
      },
      {
        contextValue: {
          authenticatedUser: { id: 'user-1' },
          dataSources: { postAPI: mockPostAPI, userAPI: mockUserAPI }
        }
      }
    );

    expect(body.singleResult.data.createPost).toMatchObject({
      id: 'post-1',
      title: 'Hello World',
      status: 'DRAFT',
      author: { id: 'user-1', name: 'Alice Chen' }
    });
  });

  it('validates input length', async () => {
    const { body } = await server.executeOperation(
      {
        query: CREATE_POST,
        variables: { input: { title: '', content: 'x' } }
      },
      {
        contextValue: {
          authenticatedUser: { id: 'user-1' },
          dataSources: { postAPI: {} }
        }
      }
    );

    expect(body.singleResult.errors).toBeDefined();
    expect(body.singleResult.errors[0].message).toContain('title');
  });
});

Testing DataSources in Isolation

DataSources are regular classes — test them without a running server. Mock the underlying HTTP or database calls:

// dataSources/UserAPI.js
class UserAPI {
  constructor({ db }) {
    this.db = db;
  }

  async getUser(id) {
    return this.db.query('SELECT * FROM users WHERE id = $1', [id])
      .then(res => res.rows[0] ?? null);
  }

  async getUsersByIds(ids) {
    return this.db.query(
      'SELECT * FROM users WHERE id = ANY($1)',
      [ids]
    ).then(res => res.rows);
  }
}
// dataSources/UserAPI.test.js
const { UserAPI } = require('./UserAPI');

describe('UserAPI', () => {
  let userAPI;
  let mockDb;

  beforeEach(() => {
    mockDb = {
      query: jest.fn()
    };
    userAPI = new UserAPI({ db: mockDb });
  });

  it('returns null when user not found', async () => {
    mockDb.query.mockResolvedValue({ rows: [] });

    const result = await userAPI.getUser('nonexistent');

    expect(result).toBeNull();
    expect(mockDb.query).toHaveBeenCalledWith(
      'SELECT * FROM users WHERE id = $1',
      ['nonexistent']
    );
  });

  it('batches user lookups by IDs', async () => {
    const users = [
      { id: '1', name: 'Alice' },
      { id: '2', name: 'Bob' }
    ];
    mockDb.query.mockResolvedValue({ rows: users });

    const result = await userAPI.getUsersByIds(['1', '2']);

    expect(result).toEqual(users);
    expect(mockDb.query).toHaveBeenCalledWith(
      expect.stringContaining('ANY'),
      [['1', '2']]
    );
  });
});

Testing Authentication Context

The context function is a critical security boundary. Test it as a pure function:

// context.js
const jwt = require('jsonwebtoken');
const { GraphQLError } = require('graphql');

async function createContext({ req }) {
  const authHeader = req.headers.authorization;

  if (!authHeader) {
    return { authenticatedUser: null };
  }

  const token = authHeader.replace('Bearer ', '');

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    return { authenticatedUser: { id: decoded.sub, role: decoded.role } };
  } catch (err) {
    throw new GraphQLError('Invalid token', {
      extensions: { code: 'UNAUTHENTICATED' }
    });
  }
}

module.exports = { createContext };
// context.test.js
const jwt = require('jsonwebtoken');
const { createContext } = require('./context');

process.env.JWT_SECRET = 'test-secret';

describe('createContext', () => {
  it('returns null user when no auth header', async () => {
    const context = await createContext({ req: { headers: {} } });
    expect(context.authenticatedUser).toBeNull();
  });

  it('extracts user from valid token', async () => {
    const token = jwt.sign(
      { sub: 'user-1', role: 'ADMIN' },
      'test-secret'
    );

    const context = await createContext({
      req: { headers: { authorization: `Bearer ${token}` } }
    });

    expect(context.authenticatedUser).toEqual({
      id: 'user-1',
      role: 'ADMIN'
    });
  });

  it('throws UNAUTHENTICATED for tampered tokens', async () => {
    await expect(
      createContext({
        req: { headers: { authorization: 'Bearer invalid.token.here' } }
      })
    ).rejects.toMatchObject({
      extensions: { code: 'UNAUTHENTICATED' }
    });
  });
});

For integration tests that need auth through the full HTTP stack:

// auth.integration.test.js
const request = require('supertest');
const jwt = require('jsonwebtoken');
const { createTestApp } = require('../test-utils/createTestApp');
const { createContext } = require('../src/context');

describe('Authentication integration', () => {
  let app, server;

  beforeAll(async () => {
    ({ app, server } = await createTestApp(createContext));
  });

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

  it('returns 200 with data for authenticated query', async () => {
    const token = jwt.sign({ sub: 'user-1', role: 'USER' }, process.env.JWT_SECRET);

    const response = await request(app)
      .post('/graphql')
      .set('Content-Type', 'application/json')
      .set('Authorization', `Bearer ${token}`)
      .send({ query: '{ me { id } }' });

    expect(response.status).toBe(200);
    expect(response.body.errors).toBeUndefined();
  });
});

Snapshot Testing for GraphQL Responses

Snapshot tests catch unintended schema changes and are especially useful for complex nested types. Use them carefully — they're valuable for stable query shapes, not for evolving APIs:

describe('PostList query snapshots', () => {
  it('matches snapshot for paginated post list', async () => {
    const mockPostAPI = {
      listPosts: jest.fn().mockResolvedValue({
        edges: [
          {
            node: { id: 'p1', title: 'First Post', status: 'PUBLISHED' },
            cursor: 'cursor-1'
          }
        ],
        pageInfo: {
          hasNextPage: false,
          endCursor: 'cursor-1'
        }
      })
    };

    const { body } = await server.executeOperation(
      {
        query: `
          query {
            posts(first: 10) {
              edges { node { id title status } cursor }
              pageInfo { hasNextPage endCursor }
            }
          }
        `
      },
      { contextValue: { dataSources: { postAPI: mockPostAPI } } }
    );

    expect(body.singleResult.data).toMatchSnapshot();
  });
});

When you update a query shape intentionally, run jest --updateSnapshot and commit the new snapshot with the code change. Snapshots in version control are documentation of your API contract.

Testing Error Handling and Custom Errors

Apollo Server v4 uses GraphQLError with extensions for structured error handling. Test that your resolvers throw the right error codes:

// resolvers/post.js
const { GraphQLError } = require('graphql');

async function deletePost(parent, { id }, context) {
  if (!context.authenticatedUser) {
    throw new GraphQLError('Must be logged in', {
      extensions: { code: 'UNAUTHENTICATED' }
    });
  }

  const post = await context.dataSources.postAPI.getPost(id);

  if (!post) {
    throw new GraphQLError('Post not found', {
      extensions: { code: 'NOT_FOUND', postId: id }
    });
  }

  if (post.authorId !== context.authenticatedUser.id) {
    throw new GraphQLError('Not authorized to delete this post', {
      extensions: { code: 'FORBIDDEN' }
    });
  }

  return context.dataSources.postAPI.deletePost(id);
}
describe('deletePost error handling', () => {
  it('returns UNAUTHENTICATED when not logged in', async () => {
    const { body } = await server.executeOperation(
      { query: `mutation { deletePost(id: "p1") { id } }` },
      { contextValue: { authenticatedUser: null, dataSources: {} } }
    );

    const error = body.singleResult.errors[0];
    expect(error.extensions.code).toBe('UNAUTHENTICATED');
  });

  it('returns NOT_FOUND for missing post', async () => {
    const { body } = await server.executeOperation(
      { query: `mutation { deletePost(id: "missing") { id } }` },
      {
        contextValue: {
          authenticatedUser: { id: 'user-1' },
          dataSources: {
            postAPI: { getPost: jest.fn().mockResolvedValue(null) }
          }
        }
      }
    );

    const error = body.singleResult.errors[0];
    expect(error.extensions.code).toBe('NOT_FOUND');
    expect(error.extensions.postId).toBe('missing');
  });

  it('returns FORBIDDEN when not post author', async () => {
    const { body } = await server.executeOperation(
      { query: `mutation { deletePost(id: "p1") { id } }` },
      {
        contextValue: {
          authenticatedUser: { id: 'other-user' },
          dataSources: {
            postAPI: {
              getPost: jest.fn().mockResolvedValue({
                id: 'p1',
                authorId: 'original-author'
              })
            }
          }
        }
      }
    );

    expect(body.singleResult.errors[0].extensions.code).toBe('FORBIDDEN');
  });
});

Testing Subscriptions with graphql-ws

For subscription tests that go through the full WebSocket protocol, start a real HTTP server in your test and connect with a graphql-ws client:

// subscriptions.test.js
const http = require('http');
const express = require('express');
const { expressMiddleware } = require('@apollo/server/express4');
const { makeServer } = require('graphql-ws');
const { WebSocketServer } = require('ws');
const { createClient } = require('graphql-ws');
const WebSocket = require('ws');
const { createTestServer } = require('../test-utils/createTestServer');
const { pubsub } = require('../src/pubsub');

describe('Subscription tests', () => {
  let httpServer, wsServer, port;

  beforeAll(async () => {
    const apolloServer = await createTestServer();
    const app = express();
    app.use(express.json());
    app.use('/graphql', expressMiddleware(apolloServer));

    httpServer = http.createServer(app);
    wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' });
    makeServer({ schema: apolloServer.schema }).use(wsServer);

    await new Promise(resolve => httpServer.listen(0, resolve));
    port = httpServer.address().port;
  });

  afterAll(async () => {
    await new Promise(resolve => wsServer.close(resolve));
    await new Promise(resolve => httpServer.close(resolve));
  });

  it('receives published events', async () => {
    const client = createClient({
      url: `ws://localhost:${port}/graphql`,
      webSocketImpl: WebSocket
    });

    const received = await new Promise((resolve, reject) => {
      client.subscribe(
        { query: `subscription { messageAdded { id text } }` },
        {
          next: (data) => { resolve(data); client.dispose(); },
          error: reject,
          complete: () => {}
        }
      );

      // Trigger the subscription event after subscribing
      setTimeout(() => {
        pubsub.publish('MESSAGE_ADDED', {
          messageAdded: { id: 'm1', text: 'Hello from test' }
        });
      }, 50);
    });

    expect(received.data.messageAdded).toEqual({
      id: 'm1',
      text: 'Hello from test'
    });
  });
});

Test Organization Tips

Organize Apollo Server tests by feature, not by resolver file. A test file for "post management" tests all the queries and mutations related to posts together, which makes it easier to understand the feature's behavior from the tests:

tests/
  user-management.test.js     # GetUser, UpdateUser, DeleteUser
  post-management.test.js     # CreatePost, UpdatePost, DeletePost, ListPosts
  auth-context.test.js        # Context factory, token validation
  subscriptions.test.js       # messageAdded, notificationReceived
  dataSources/
    UserAPI.test.js
    PostAPI.test.js

Keep the contextValue construction in test helpers to avoid repetition:

// test-utils/contexts.js
function authenticatedContext(overrides = {}) {
  return {
    authenticatedUser: { id: 'test-user-1', role: 'USER' },
    dataSources: {
      userAPI: { getUser: jest.fn() },
      postAPI: { getPost: jest.fn(), createPost: jest.fn() }
    },
    ...overrides
  };
}

function unauthenticatedContext(overrides = {}) {
  return {
    authenticatedUser: null,
    dataSources: {
      userAPI: { getUser: jest.fn() },
      postAPI: { getPost: jest.fn() }
    },
    ...overrides
  };
}

module.exports = { authenticatedContext, unauthenticatedContext };

Apollo Server v4's testing API rewards the pattern of constructing context explicitly in each test. It makes test setup visible, avoids hidden state, and makes it obvious exactly what context each resolver runs in.

Read more