Playwright and Cypress for GraphQL E2E Testing

Playwright and Cypress for GraphQL E2E Testing

E2E testing for GraphQL-powered apps requires different techniques than REST APIs. With REST, you intercept by URL (/api/users). With GraphQL, every query hits the same endpoint (/graphql), so you intercept by operation name or query content.

Both Playwright and Cypress support GraphQL intercepting well. This guide shows you how to use each effectively.

The Core Challenge: One Endpoint, Many Operations

In REST, URL-based routing makes interception easy:

GET /api/users → intercept users endpoint
GET /api/products → intercept products endpoint

In GraphQL, everything goes to /graphql. The operation identity is in the request body:

{
  "operationName": "GetUser",
  "query": "query GetUser($id: ID!) { user(id: $id) { id name } }",
  "variables": { "id": "1" }
}

This means interceptors need to inspect the request body to match a specific operation.

Playwright: Intercepting GraphQL

Playwright's page.route() intercepts network requests and lets you modify or mock responses.

Basic GraphQL Interception

// tests/graphql-intercept.test.ts
import { test, expect } from '@playwright/test';

test('displays products from GraphQL', async ({ page }) => {
  // Intercept GetProducts operation and return mock data
  await page.route('**/graphql', async (route, request) => {
    const body = JSON.parse(request.postData() || '{}');
    
    if (body.operationName === 'GetProducts') {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({
          data: {
            products: {
              edges: [
                { cursor: 'a', node: { id: '1', name: 'Widget', price: 9.99 } },
                { cursor: 'b', node: { id: '2', name: 'Gadget', price: 19.99 } },
              ],
              pageInfo: { hasNextPage: false, endCursor: 'b' },
              totalCount: 2,
            },
          },
        }),
      });
    } else {
      await route.continue();
    }
  });

  await page.goto('/products');
  await expect(page.getByText('Widget')).toBeVisible();
  await expect(page.getByText('Gadget')).toBeVisible();
  await expect(page.getByText('$9.99')).toBeVisible();
});

Reusable GraphQL Mock Helper

// tests/helpers/graphqlMock.ts
import { Page, Route } from '@playwright/test';

type GraphQLResponse = { data?: unknown; errors?: unknown[] };

export class GraphQLMock {
  private mocks = new Map<string, GraphQLResponse>();

  constructor(private page: Page) {}

  async setup() {
    await this.page.route('**/graphql', async (route, request) => {
      const body = JSON.parse(request.postData() || '{}');
      const operationName = body.operationName;

      if (operationName && this.mocks.has(operationName)) {
        const mockResponse = this.mocks.get(operationName)!;
        await route.fulfill({
          status: 200,
          contentType: 'application/json',
          body: JSON.stringify(mockResponse),
        });
      } else {
        await route.continue();
      }
    });
  }

  mock(operationName: string, response: GraphQLResponse) {
    this.mocks.set(operationName, response);
    return this;
  }

  clear(operationName?: string) {
    if (operationName) {
      this.mocks.delete(operationName);
    } else {
      this.mocks.clear();
    }
  }
}
// Usage in tests
test('shows error state when API fails', async ({ page }) => {
  const gqlMock = new GraphQLMock(page);
  await gqlMock.setup();
  
  gqlMock.mock('GetProducts', {
    errors: [{ message: 'Service unavailable', extensions: { code: 'SERVICE_UNAVAILABLE' } }],
  });

  await page.goto('/products');
  await expect(page.getByText('Failed to load products')).toBeVisible();
  await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible();
});

Asserting on GraphQL Requests

Sometimes you need to verify what your app sent, not just what it received:

test('sends correct variables on search', async ({ page }) => {
  // Capture the request
  const requestPromise = page.waitForRequest(
    (req) => {
      if (!req.url().includes('/graphql')) return false;
      const body = JSON.parse(req.postData() || '{}');
      return body.operationName === 'SearchProducts';
    }
  );

  await page.goto('/products');
  await page.getByPlaceholder('Search...').fill('widget');
  await page.getByRole('button', { name: 'Search' }).click();

  const request = await requestPromise;
  const body = JSON.parse(request.postData() || '{}');
  
  expect(body.variables).toEqual({
    query: 'widget',
    first: 20,
  });
});

Testing Mutation Flows

test('creates product and shows success message', async ({ page }) => {
  let capturedMutationBody: Record<string, unknown> | null = null;

  await page.route('**/graphql', async (route, request) => {
    const body = JSON.parse(request.postData() || '{}');

    if (body.operationName === 'CreateProduct') {
      capturedMutationBody = body;
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({
          data: {
            createProduct: {
              id: 'new-123',
              name: body.variables.input.name,
              price: body.variables.input.price,
            },
          },
        }),
      });
    } else {
      await route.continue();
    }
  });

  await page.goto('/admin/products/new');
  await page.getByLabel('Product name').fill('My New Widget');
  await page.getByLabel('Price').fill('49.99');
  await page.getByRole('button', { name: 'Create product' }).click();

  await expect(page.getByText('Product created successfully')).toBeVisible();

  expect(capturedMutationBody?.variables).toMatchObject({
    input: {
      name: 'My New Widget',
      price: 49.99,
    },
  });
});

Cypress: GraphQL Interception

Cypress uses cy.intercept() for network interception.

Basic GraphQL Intercept

// cypress/e2e/products.cy.ts

describe('Products page', () => {
  it('displays products from GraphQL response', () => {
    cy.intercept('POST', '/graphql', (req) => {
      if (req.body.operationName === 'GetProducts') {
        req.reply({
          data: {
            products: {
              edges: [
                { node: { id: '1', name: 'Widget', price: 9.99 }, cursor: 'a' },
              ],
              pageInfo: { hasNextPage: false, endCursor: 'a' },
              totalCount: 1,
            },
          },
        });
      }
    }).as('getProducts');

    cy.visit('/products');
    cy.wait('@getProducts');
    cy.contains('Widget').should('be.visible');
  });
});

Cypress GraphQL Command (Custom)

Encapsulate the operation check in a custom command:

// cypress/support/commands.ts
Cypress.Commands.add(
  'interceptGraphQL',
  (operationName: string, response: object) => {
    cy.intercept('POST', '/graphql', (req) => {
      if (req.body.operationName === operationName) {
        req.reply({ body: response });
      }
    }).as(operationName);
  }
);

// Types
declare global {
  namespace Cypress {
    interface Chainable {
      interceptGraphQL(operationName: string, response: object): Chainable;
    }
  }
}

Usage:

describe('Cart', () => {
  beforeEach(() => {
    cy.interceptGraphQL('GetCart', {
      data: {
        cart: {
          items: [
            { id: '1', product: { name: 'Widget', price: 9.99 }, quantity: 2 },
          ],
          total: 19.98,
        },
      },
    });
  });

  it('shows cart total', () => {
    cy.visit('/cart');
    cy.wait('@GetCart');
    cy.contains('$19.98').should('be.visible');
  });

  it('shows empty state when no items', () => {
    cy.interceptGraphQL('GetCart', {
      data: { cart: { items: [], total: 0 } },
    });
    
    cy.visit('/cart');
    cy.wait('@GetCart');
    cy.contains('Your cart is empty').should('be.visible');
  });
});

Asserting on Request Body in Cypress

it('sends search term to GraphQL', () => {
  cy.intercept('POST', '/graphql').as('graphqlRequest');
  
  cy.visit('/products');
  cy.get('[placeholder="Search..."]').type('widget');
  cy.get('[data-cy="search-button"]').click();

  cy.wait('@graphqlRequest').then((interception) => {
    expect(interception.request.body.operationName).to.equal('SearchProducts');
    expect(interception.request.body.variables.query).to.equal('widget');
  });
});

Fixture Files

For complex mock responses, use fixture files instead of inline objects:

// cypress/fixtures/getProducts.json
{
  "data": {
    "products": {
      "edges": [
        { "cursor": "abc", "node": { "id": "1", "name": "Widget Pro", "price": 99.99, "slug": "widget-pro" } },
        { "cursor": "def", "node": { "id": "2", "name": "Gadget Plus", "price": 49.99, "slug": "gadget-plus" } }
      ],
      "pageInfo": { "hasNextPage": true, "endCursor": "def" },
      "totalCount": 50
    }
  }
}
// In your test
cy.intercept('POST', '/graphql', (req) => {
  if (req.body.operationName === 'GetProducts') {
    cy.fixture('getProducts').then((response) => req.reply(response));
  }
}).as('getProducts');

Testing Loading States

GraphQL loading states are worth testing — a slow API shouldn't render a broken UI:

// Playwright
test('shows skeleton loader while loading', async ({ page }) => {
  let resolveRequest: (value: unknown) => void;
  const pendingRequest = new Promise(resolve => {
    resolveRequest = resolve;
  });

  await page.route('**/graphql', async (route, request) => {
    const body = JSON.parse(request.postData() || '{}');
    if (body.operationName === 'GetProducts') {
      await pendingRequest; // Hold the request
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({ data: { products: { edges: [], totalCount: 0, pageInfo: {} } } }),
      });
    }
  });

  const gotoPromise = page.goto('/products');
  
  // While request is pending, skeleton should be visible
  await expect(page.getByTestId('product-skeleton')).toBeVisible();
  
  // Release the request
  resolveRequest!(null);
  await gotoPromise;
  
  // Skeleton should be gone
  await expect(page.getByTestId('product-skeleton')).not.toBeVisible();
});

When to Use Real vs Mocked GraphQL

Use mocked responses for:

  • Loading states (you control timing)
  • Error states (hard to trigger in real backends)
  • Specific edge cases (empty state, single item, max pagination)
  • Running tests without a backend available
  • Speed (mocks are instant)

Use real backend for:

  • Critical happy paths (verify full stack integrity)
  • Testing after deployment (smoke tests)
  • Verifying mutations actually persist data
  • Performance testing

The best E2E test suites combine both: mocked responses for most tests (for speed and control), and a smaller set of end-to-end tests against a real backend for the most critical flows.

Read more