← Back to DevBytes

Testing GraphQL Yoga Components: From Unit to E2E Tests

Introduction to Testing GraphQL Yoga Components

GraphQL Yoga is a fully-featured GraphQL server built on top of the Envelop and GraphQL HTTP ecosystems. It provides a flexible, framework-agnostic way to build GraphQL APIs with features like file uploads, subscriptions, and middleware support. However, like any complex system, GraphQL Yoga applications require thorough testing to ensure reliability, maintainability, and correctness.

Testing GraphQL Yoga components spans multiple layers — from individual resolver functions to full end-to-end API tests. This tutorial walks you through each layer, providing practical examples and strategies you can apply directly to your projects.

Why Testing GraphQL Yoga Matters

GraphQL APIs introduce unique testing challenges compared to REST APIs. A single GraphQL endpoint can handle countless query combinations, and resolvers often interact with multiple data sources. Without proper testing, subtle bugs can slip through — incorrect null handling, authorization gaps, N+1 query problems, or schema mismatches.

Key Benefits of Testing GraphQL Yoga

Setting Up the Testing Environment

Before writing tests, you need a solid testing setup. We will use Jest as the test runner, though the concepts apply equally to Vitest, Mocha, or Node's built-in test runner. Install the necessary dependencies:

npm install --save-dev jest @types/jest ts-jest supertest
npm install --save-dev @graphql-tools/mock graphql

Create a jest.config.js file to configure TypeScript support:

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  testMatch: ['**/__tests__/**/*.test.ts'],
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.d.ts',
  ],
};

Now, let us define a sample GraphQL Yoga server that we will test throughout this tutorial. Create src/server.ts:

import { createYoga } from 'graphql-yoga';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { users, posts } from './data';

const typeDefs = /* GraphQL */ `
  type User {
    id: ID!
    name: String!
    email: String!
    posts: [Post!]!
  }

  type Post {
    id: ID!
    title: String!
    content: String!
    author: User!
  }

  type Query {
    user(id: ID!): User
    users: [User!]!
    posts: [Post!]!
  }

  type Mutation {
    createUser(name: String!, email: String!): User!
    deleteUser(id: ID!): Boolean!
  }
`;

const resolvers = {
  Query: {
    user: (_: any, args: { id: string }) =>
      users.find((u) => u.id === args.id) ?? null,
    users: () => users,
    posts: () => posts,
  },
  Mutation: {
    createUser: (_: any, args: { name: string; email: string }) => {
      const newUser = {
        id: String(users.length + 1),
        name: args.name,
        email: args.email,
      };
      users.push(newUser);
      return newUser;
    },
    deleteUser: (_: any, args: { id: string }) => {
      const index = users.findIndex((u) => u.id === args.id);
      if (index === -1) return false;
      users.splice(index, 1);
      return true;
    },
  },
  User: {
    posts: (parent: { id: string }) =>
      posts.filter((p) => p.authorId === parent.id),
  },
  Post: {
    author: (parent: { authorId: string }) =>
      users.find((u) => u.id === parent.authorId),
  },
};

const schema = makeExecutableSchema({ typeDefs, resolvers });

export function createYogaServer(contextFn?: any) {
  return createYoga({
    schema,
    context: contextFn || (() => ({ })),
  });
}

export { schema, resolvers, typeDefs };

And the accompanying data file src/data.ts:

export const users = [
  { id: '1', name: 'Alice', email: 'alice@example.com' },
  { id: '2', name: 'Bob', email: 'bob@example.com' },
];

export const posts = [
  {
    id: '1',
    title: 'GraphQL Basics',
    content: 'An introduction to GraphQL.',
    authorId: '1',
  },
  {
    id: '2',
    title: 'Advanced Yoga',
    content: 'Deep dive into GraphQL Yoga.',
    authorId: '1',
  },
];

Unit Testing Resolvers

Unit tests focus on individual resolver functions in isolation. This is the most granular level of testing and allows you to verify that each resolver handles inputs correctly, including edge cases like missing data or invalid arguments.

Testing Query Resolvers

Start by testing the Query.user resolver. Since resolvers are plain functions, you can call them directly without spinning up a server:

// src/__tests__/resolvers.test.ts
import { resolvers } from '../server';
import { users, posts } from '../data';

describe('Query.user resolver', () => {
  const { user } = resolvers.Query;

  it('should return a user when a valid ID is provided', () => {
    const result = user(null, { id: '1' }, {}, null);
    expect(result).toEqual(users[0]);
  });

  it('should return null when the user does not exist', () => {
    const result = user(null, { id: '999' }, {}, null);
    expect(result).toBeNull();
  });

  it('should return null for an empty string ID', () => {
    const result = user(null, { id: '' }, {}, null);
    expect(result).toBeNull();
  });
});

describe('Query.users resolver', () => {
  const { users: usersResolver } = resolvers.Query;

  it('should return all users', () => {
    const result = usersResolver(null, {}, {}, null);
    expect(result).toHaveLength(users.length);
    expect(result).toEqual(users);
  });
});

Testing Mutation Resolvers

Mutations modify data, so you need to be careful about state management. Use beforeEach to reset data between tests to avoid cross-test contamination:

describe('Mutation.createUser resolver', () => {
  const { createUser } = resolvers.Mutation;

  beforeEach(() => {
    // Reset the users array
    users.length = 0;
    users.push(
      { id: '1', name: 'Alice', email: 'alice@example.com' },
      { id: '2', name: 'Bob', email: 'bob@example.com' },
    );
  });

  it('should create a new user with valid input', () => {
    const newUser = createUser(
      null,
      { name: 'Charlie', email: 'charlie@example.com' },
      {},
      null,
    );

    expect(newUser).toMatchObject({
      name: 'Charlie',
      email: 'charlie@example.com',
    });
    expect(newUser.id).toBeDefined();
    expect(users).toHaveLength(3);
  });

  it('should add the new user to the users array', () => {
    createUser(null, { name: 'Dana', email: 'dana@example.com' }, {}, null);
    const dana = users.find((u) => u.email === 'dana@example.com');
    expect(dana).toBeDefined();
    expect(dana?.name).toBe('Dana');
  });
});

describe('Mutation.deleteUser resolver', () => {
  const { deleteUser } = resolvers.Mutation;

  beforeEach(() => {
    users.length = 0;
    users.push(
      { id: '1', name: 'Alice', email: 'alice@example.com' },
      { id: '2', name: 'Bob', email: 'bob@example.com' },
    );
  });

  it('should return true when deleting an existing user', () => {
    const result = deleteUser(null, { id: '1' }, {}, null);
    expect(result).toBe(true);
    expect(users).toHaveLength(1);
  });

  it('should return false when deleting a non-existent user', () => {
    const result = deleteUser(null, { id: '999' }, {}, null);
    expect(result).toBe(false);
    expect(users).toHaveLength(2);
  });
});

Testing Field-Level Resolvers

Field-level resolvers handle relationships between types. Test them by passing a parent object that simulates the resolved parent type:

describe('User.posts field resolver', () => {
  const { posts: userPosts } = resolvers.User;

  it('should return posts for a given user', () => {
    const parent = { id: '1', name: 'Alice', email: 'alice@example.com' };
    const result = userPosts(parent, {}, {}, null);
    expect(result).toHaveLength(2);
    expect(result.every((p: any) => p.authorId === '1')).toBe(true);
  });

  it('should return an empty array for a user with no posts', () => {
    const parent = { id: '2', name: 'Bob', email: 'bob@example.com' };
    const result = userPosts(parent, {}, {}, null);
    expect(result).toHaveLength(0);
  });
});

describe('Post.author field resolver', () => {
  const { author } = resolvers.Post;

  it('should return the author of a post', () => {
    const parent = {
      id: '1',
      title: 'GraphQL Basics',
      content: '...',
      authorId: '1',
    };
    const result = author(parent, {}, {}, null);
    expect(result).toEqual(users[0]);
  });

  it('should return undefined for a post with an unknown author', () => {
    const parent = {
      id: '99',
      title: 'Orphan Post',
      content: '...',
      authorId: '999',
    };
    const result = author(parent, {}, {}, null);
    expect(result).toBeUndefined();
  });
});

Integration Testing with the Yoga Server

While unit tests verify individual resolvers, integration tests exercise the entire GraphQL execution pipeline. This includes schema validation, resolver orchestration, error handling, and context construction. GraphQL Yoga provides a built-in way to execute queries programmatically without an HTTP layer.

Executing Queries Against the Yoga Instance

GraphQL Yoga servers are standard Request handlers, which means you can construct a Request object and pass it directly to the yoga instance. This approach tests the full stack without needing a real HTTP server:

// src/__tests__/integration.test.ts
import { createYogaServer } from '../server';
import { users } from '../data';

describe('GraphQL Yoga integration tests', () => {
  const yoga = createYogaServer();

  async function executeQuery(query: string, variables?: Record) {
    const request = new Request('http://localhost/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query, variables }),
    });
    const response = await yoga(request);
    return response.json();
  }

  it('should return a user by ID', async () => {
    const query = `
      query GetUser($id: ID!) {
        user(id: $id) {
          id
          name
          email
        }
      }
    `;
    const result = await executeQuery(query, { id: '1' });
    expect(result.data.user).toEqual({
      id: '1',
      name: 'Alice',
      email: 'alice@example.com',
    });
    expect(result.errors).toBeUndefined();
  });

  it('should return null for a non-existent user', async () => {
    const query = `
      query {
        user(id: "999") {
          id
          name
        }
      }
    `;
    const result = await executeQuery(query);
    expect(result.data.user).toBeNull();
    expect(result.errors).toBeUndefined();
  });

  it('should return nested relationships correctly', async () => {
    const query = `
      query {
        user(id: "1") {
          name
          posts {
            title
            author {
              name
            }
          }
        }
      }
    `;
    const result = await executeQuery(query);
    expect(result.data.user.posts).toHaveLength(2);
    expect(result.data.user.posts[0].author.name).toBe('Alice');
  });

  it('should return all users', async () => {
    const query = `
      query {
        users {
          id
          name
        }
      }
    `;
    const result = await executeQuery(query);
    expect(result.data.users).toHaveLength(users.length);
  });

  it('should handle mutations correctly', async () => {
    const mutation = `
      mutation CreateUser($name: String!, $email: String!) {
        createUser(name: $name, email: $email) {
          id
          name
          email
        }
      }
    `;
    const result = await executeQuery(mutation, {
      name: 'Eve',
      email: 'eve@example.com',
    });
    expect(result.data.createUser.name).toBe('Eve');
    expect(result.data.createUser.email).toBe('eve@example.com');
    expect(result.errors).toBeUndefined();
  });

  it('should return a validation error for invalid queries', async () => {
    const query = `
      query {
        nonExistentField
      }
    `;
    const result = await executeQuery(query);
    expect(result.errors).toBeDefined();
    expect(result.errors[0].message).toContain('nonExistentField');
  });
});

Testing Context and Authentication

Most real-world GraphQL servers use context to pass authentication information, database connections, or other request-scoped data. Test your context function by providing custom request headers:

// src/__tests__/context.test.ts
import { createYoga, createYogaServer } from '../server';

describe('Context and authentication', () => {
  function createAuthenticatedYoga(currentUser: any) {
    return createYogaServer(() => ({ currentUser }));
  }

  async function executeQuery(yoga: any, query: string, headers: Record = {}) {
    const request = new Request('http://localhost/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', ...headers },
      body: JSON.stringify({ query }),
    });
    const response = await yoga(request);
    return response.json();
  }

  it('should pass context to resolvers', async () => {
    const yoga = createAuthenticatedYoga({
      id: '1',
      name: 'Alice',
      role: 'admin',
    });

    // Add a query that uses context (you would need to update your schema)
    const query = `query { users { id name } }`;
    const result = await executeQuery(yoga, query);
    expect(result.errors).toBeUndefined();
  });
});

Testing Middleware and Plugins

GraphQL Yoga supports plugins that can intercept and modify the execution pipeline. Testing these plugins ensures they behave correctly under various conditions. Here is an example of testing a logging plugin:

// src/__tests__/plugins.test.ts
import { createYoga } from 'graphql-yoga';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { useExecutionCancellation } from '@graphql-yoga/plugin-cancellation';

describe('Plugin behavior', () => {
  const schema = makeExecutableSchema({
    typeDefs: `type Query { hello: String }`,
    resolvers: { Query: { hello: () => 'world' } },
  });

  it('should execute a query with plugins enabled', async () => {
    const yoga = createYoga({
      schema,
      plugins: [useExecutionCancellation()],
    });

    const request = new Request('http://localhost/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query: '{ hello }' }),
    });

    const response = await yoga(request);
    const result = await response.json();
    expect(result.data.hello).toBe('world');
  });

  it('should call a custom plugin hook', async () => {
    const calls: string[] = [];

    const trackingPlugin = {
      onExecute: () => ({
        onExecuteDone: () => {
          calls.push('execution-complete');
        },
      }),
    };

    const yoga = createYoga({
      schema,
      plugins: [trackingPlugin],
    });

    const request = new Request('http://localhost/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query: '{ hello }' }),
    });

    await yoga(request);
    expect(calls).toContain('execution-complete');
  });
});

End-to-End Testing GraphQL APIs

End-to-end (E2E) tests verify the entire system from the HTTP layer down to the database. These tests start a real HTTP server, send actual HTTP requests, and validate the complete response cycle. Use supertest or Node's built-in fetch to perform these tests.

Setting Up E2E Tests with a Real HTTP Server

// src/__tests__/e2e.test.ts
import { createServer } from 'http';
import { createYogaServer } from '../server';

describe('GraphQL Yoga E2E tests', () => {
  let server: ReturnType;
  let baseUrl: string;

  beforeAll((done) => {
    const yoga = createYogaServer();
    server = createServer(yoga);
    server.listen(0, () => {
      const address = server.address();
      if (address && typeof address === 'object') {
        baseUrl = `http://localhost:${address.port}/graphql`;
      }
      done();
    });
  });

  afterAll((done) => {
    server.close(done);
  });

  async function graphqlRequest(
    query: string,
    variables?: Record,
  ) {
    const response = await fetch(baseUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query, variables }),
    });
    return response.json();
  }

  it('should respond to a health check query', async () => {
    const result = await graphqlRequest(`{ users { id name } }`);
    expect(result.errors).toBeUndefined();
    expect(Array.isArray(result.data.users)).toBe(true);
  });

  it('should handle a complete user workflow', async () => {
    // Step 1: Create a user
    const createResult = await graphqlRequest(
      `mutation {
        createUser(name: "Frank", email: "frank@example.com") {
          id
          name
        }
      }`,
    );
    expect(createResult.data.createUser.name).toBe('Frank');
    const userId = createResult.data.createUser.id;

    // Step 2: Query the created user
    const queryResult = await graphqlRequest(
      `query($id: ID!) { user(id: $id) { name email } }`,
      { id: userId },
    );
    expect(queryResult.data.user.email).toBe('frank@example.com');

    // Step 3: Delete the user
    const deleteResult = await graphqlRequest(
      `mutation($id: ID!) { deleteUser(id: $id) }`,
      { id: userId },
    );
    expect(deleteResult.data.deleteUser).toBe(true);

    // Step 4: Verify the user is gone
    const verifyResult = await graphqlRequest(
      `query($id: ID!) { user(id: $id) { id } }`,
      { id: userId },
    );
    expect(verifyResult.data.user).toBeNull();
  });

  it('should return proper HTTP status codes', async () => {
    const response = await fetch(baseUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query: '{ users { id } }' }),
    });
    expect(response.status).toBe(200);
    expect(response.headers.get('content-type')).toContain('application/json');
  });

  it('should handle malformed request bodies gracefully', async () => {
    const response = await fetch(baseUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: 'invalid json',
    });
    expect(response.status).toBe(400);
  });

  it('should support GET requests for introspection', async () => {
    const url = new URL(baseUrl);
    url.searchParams.set('query', '{ __typename }');
    const response = await fetch(url.toString());
    const result = await response.json();
    expect(result.data.__typename).toBe('Query');
  });
});

Testing Subscriptions

GraphQL Yoga supports subscriptions over WebSocket. Testing subscriptions requires a WebSocket client. Here is an example using the ws library:

// src/__tests__/subscriptions.test.ts
import { createServer } from 'http';
import { WebSocket } from 'ws';
import { createYogaServer } from '../server';

describe('GraphQL subscriptions', () => {
  let server: ReturnType;
  let wsUrl: string;

  beforeAll((done) => {
    const yoga = createYogaServer();
    server = createServer(yoga);
    server.listen(0, () => {
      const address = server.address();
      if (address && typeof address === 'object') {
        wsUrl = `ws://localhost:${address.port}/graphql`;
      }
      done();
    });
  });

  afterAll((done) => {
    server.close(done);
  });

  it('should establish a WebSocket connection', (done) => {
    const ws = new WebSocket(wsUrl, 'graphql-transport-ws');

    ws.on('open', () => {
      ws.send(JSON.stringify({ type: 'connection_init' }));
    });

    ws.on('message', (data) => {
      const message = JSON.parse(data.toString());
      if (message.type === 'connection_ack') {
        ws.close();
        done();
      }
    });

    ws.on('error', (err) => done(err));
  });
});

Mocking External Dependencies

In real applications, resolvers often call external services like databases, REST APIs, or message queues. Mocking these dependencies keeps tests fast and deterministic. Here is how to mock a database call:

// src/__tests__/mocking.test.ts
import { resolvers } from '../server';

// Mock a database module
jest.mock('../database', () => ({
  findUserById: jest.fn(),
  findAllUsers: jest.fn(),
}));

import { findUserById, findAllUsers } from '../database';

describe('Resolvers with mocked database', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('should call the database when resolving a user', () => {
    const mockUser = { id: '1', name: 'Alice', email: 'alice@example.com' };
    (findUserById as jest.Mock).mockReturnValue(mockUser);

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

    expect(findUserById).toHaveBeenCalledWith('1');
    expect(result).toEqual(mockUser);
  });

  it('should handle database errors gracefully', () => {
    (findUserById as jest.Mock).mockImplementation(() => {
      throw new Error('Database connection failed');
    });

    expect(() => {
      resolvers.Query.user(null, { id: '1' }, {}, null);
    }).toThrow('Database connection failed');
  });
});

Best Practices for Testing GraphQL Yoga

1. Test in Layers

Do not rely on a single type of test. Use unit tests for resolver logic, integration tests for the execution pipeline, and E2E tests for the HTTP layer. This layered approach gives you fast feedback for common issues while still catching integration bugs.

2. Keep Tests Independent

Each test should set up and tear down its own state. Avoid sharing mutable state between tests, as this leads to flaky tests and debugging nightmares. Use beforeEach and afterEach hooks to reset databases, mocks, and in-memory data.

3. Test Error Paths

It is tempting to only test the happy path, but error handling is where most production bugs occur. Test what happens when inputs are invalid, databases are unreachable, or permissions are insufficient:

it('should return an error when email is missing', async () => {
  const mutation = `
    mutation {
      createUser(name: "No Email", email: "") {
        id
      }
    }
  `;
  const result = await executeQuery(mutation);
  // Depending on your schema validation, this may or may not error.
  // Adjust the assertion based on your validation rules.
  expect(result.data || result.errors).toBeDefined();
});

4. Use Snapshot Testing for Schema

Schema changes can break clients unexpectedly. Use snapshot tests to detect breaking schema changes:

import { printSchema } from 'graphql';
import { schema } from '../server';

it('should not change the schema unexpectedly', () => {
  expect(printSchema(schema)).toMatchSnapshot();
});

5. Test Authorization Logic Separately

Authorization is a cross-cutting concern that deserves dedicated tests. Create tests that verify each role can access only the fields and operations they are permitted to:

describe('Authorization', () => {
  it('should allow admins to see all users', async () => {
    const yoga = createYogaServer(() => ({
      user: { role: 'admin' },
    }));
    // ... test that admin can access all fields
  });

  it('should deny regular users access to sensitive fields', async () => {
    const yoga = createYogaServer(() => ({
      user: { role: 'member' },
    }));
    // ... test that member cannot access admin-only fields
  });
});

6. Measure Test Coverage

Use Jest's coverage reports to identify untested code paths. Aim for high coverage on resolver logic and context construction, but do not chase 100% coverage blindly — focus on meaningful tests rather than metric-driven tests.

npx jest --coverage

7. Use Descriptive Test Names

Test names should describe the behavior being tested, not the implementation. Good test names serve as documentation:

// Good
it('should return null when the user ID does not exist', () => {});

// Avoid
it('test1', () => {});
it('should work', () => {});

8. Leverage GraphQL-Specific Testing Tools

Consider using tools like @graphql-tools/mock for generating mock data based on your schema, or easygraphql-tester for validating queries against the schema without executing resolvers:

import { addMocksToSchema } from '@graphql-tools/mock';
import { schema } from '../server';

const schemaWithMocks = addMocksToSchema({
  schema,
  mocks: {
    User: () => ({
      id: 'mock-id',
      name: 'Mock User',
      email: 'mock@example.com',
    }),
  },
});

it('should return mocked data', async () => {
  const yoga = createYoga({ schema: schemaWithMocks });
  const request = new Request('http://localhost/graphql', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query: '{ users { id name email } }' }),
  });
  const response = await yoga(request);
  const result = await response.json();
  expect(result.data.users[0].name).toBe('Mock User');
});

Conclusion

Testing GraphQL Yoga components effectively requires a multi-layered approach that spans unit tests for individual resolvers, integration tests for the execution pipeline, and end-to-end tests for the full HTTP lifecycle. By testing at each layer, you gain confidence that your API behaves correctly under all conditions — from simple queries to complex mutations with nested relationships and authentication. The key is to start with unit tests for fast feedback, add integration tests to catch orchestration issues, and use E2E tests sparingly for critical user workflows. Combine this layered strategy with best practices like independent test state, error path coverage, schema snapshot testing, and authorization verification, and you will build a robust test suite that catches bugs early, supports confident refactoring, and ensures your GraphQL Yoga server remains reliable as it evolves.

— Ad —

Google AdSense will appear here after approval

← Back to all articles