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 endpointIn 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.