End-to-End GraphQL Testing with Cypress and Playwright
Unit and integration tests catch resolver bugs and schema violations. End-to-end tests answer the question no other layer can: does the entire system — browser, network, server, database — work correctly together?
For GraphQL applications, E2E testing means intercepting real browser-to-server GraphQL requests, seeding data through the API instead of database scripts, and verifying that UI state actually reflects what the server returns. This guide covers both Cypress and Playwright, the two dominant E2E frameworks.
The GraphQL E2E Testing Model
REST E2E tests intercept specific URL endpoints. GraphQL is different: all operations go to a single endpoint (/graphql). You identify which operation is being tested by the operationName field in the request body, not the URL.
This changes how you stub, intercept, and assert on network activity.
Cypress: Intercepting GraphQL Operations
Cypress's cy.intercept() can match on the request body to target specific GraphQL operations:
// cypress/support/graphql.js
Cypress.Commands.add('interceptGQL', (operationName, response) => {
cy.intercept('POST', '/graphql', (req) => {
if (req.body?.operationName === operationName) {
req.reply(response)
}
}).as(operationName)
})
Cypress.Commands.add('waitForGQL', (operationName) => {
cy.wait(`@${operationName}`)
})// cypress/e2e/user-profile.cy.js
describe('User Profile', () => {
it('displays user data from API', () => {
cy.interceptGQL('GetUserProfile', {
statusCode: 200,
body: {
data: {
user: {
id: '1',
name: 'Alice',
email: 'alice@example.com',
postsCount: 12,
},
},
},
})
cy.visit('/users/1')
cy.waitForGQL('GetUserProfile')
cy.findByText('Alice').should('be.visible')
cy.findByText('alice@example.com').should('be.visible')
cy.findByText('12 posts').should('be.visible')
})
it('shows error state when API fails', () => {
cy.interceptGQL('GetUserProfile', {
statusCode: 200,
body: {
errors: [{ message: 'User not found', extensions: { code: 'NOT_FOUND' } }],
},
})
cy.visit('/users/99999')
cy.waitForGQL('GetUserProfile')
cy.findByText(/user not found/i).should('be.visible')
})
})Intercepting Mutations
Mutations need special handling because the UI should update after the mutation completes:
describe('Create Post', () => {
it('creates post and shows it in the list', () => {
// Intercept the mutation
cy.interceptGQL('CreatePost', {
statusCode: 200,
body: {
data: {
createPost: {
id: '99',
title: 'My New Post',
createdAt: '2026-01-01T00:00:00Z',
},
},
},
})
// Also intercept the refetch that happens after mutation
cy.interceptGQL('GetPosts', {
statusCode: 200,
body: {
data: {
posts: [
{ id: '99', title: 'My New Post' },
],
},
},
})
cy.visit('/posts/new')
cy.findByLabelText('Title').type('My New Post')
cy.findByLabelText('Body').type('Content here')
cy.findByRole('button', { name: /create post/i }).click()
cy.waitForGQL('CreatePost')
cy.waitForGQL('GetPosts')
cy.findByText('My New Post').should('be.visible')
})
})Spying Without Stubbing
Sometimes you want to verify that the correct operation was called with the correct variables, without stubbing the response (using a real test server instead):
describe('Search functionality', () => {
it('sends search query with correct variables', () => {
cy.intercept('POST', '/graphql').as('graphqlRequest')
cy.visit('/search')
cy.findByPlaceholderText('Search posts...').type('graphql testing')
cy.findByRole('button', { name: /search/i }).click()
cy.wait('@graphqlRequest').then((interception) => {
expect(interception.request.body.operationName).to.equal('SearchPosts')
expect(interception.request.body.variables).to.deep.include({
query: 'graphql testing',
})
})
})
})Playwright: GraphQL Interception
Playwright uses page.route() for network interception. The pattern is similar but with different syntax:
// tests/helpers/graphql.ts
import { Page, Route } from '@playwright/test'
interface GQLMock {
operationName: string
data?: unknown
errors?: Array<{ message: string; extensions?: Record<string, unknown> }>
}
export async function mockGQL(page: Page, mock: GQLMock) {
await page.route('**/graphql', async (route: Route) => {
const request = route.request()
const body = request.postDataJSON()
if (body?.operationName === mock.operationName) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: mock.data ?? null,
errors: mock.errors,
}),
})
} else {
await route.continue()
}
})
}// tests/user-profile.spec.ts
import { test, expect } from '@playwright/test'
import { mockGQL } from './helpers/graphql'
test('displays user profile data', async ({ page }) => {
await mockGQL(page, {
operationName: 'GetUserProfile',
data: {
user: {
id: '1',
name: 'Alice',
email: 'alice@example.com',
postsCount: 12,
},
},
})
await page.goto('/users/1')
await expect(page.getByText('Alice')).toBeVisible()
await expect(page.getByText('alice@example.com')).toBeVisible()
await expect(page.getByText('12 posts')).toBeVisible()
})
test('shows error state when user not found', async ({ page }) => {
await mockGQL(page, {
operationName: 'GetUserProfile',
errors: [{ message: 'User not found', extensions: { code: 'NOT_FOUND' } }],
})
await page.goto('/users/99999')
await expect(page.getByText(/user not found/i)).toBeVisible()
})Capturing Real Requests with Playwright
test('search sends correct GraphQL variables', async ({ page }) => {
let capturedRequest: unknown = null
await page.route('**/graphql', async (route) => {
const body = route.request().postDataJSON()
if (body?.operationName === 'SearchPosts') {
capturedRequest = body
}
await route.continue()
})
await page.goto('/search')
await page.getByPlaceholder('Search posts...').fill('graphql testing')
await page.getByRole('button', { name: /search/i }).click()
// Wait for the request to be captured
await page.waitForResponse(res =>
res.url().includes('/graphql') &&
res.request().postDataJSON()?.operationName === 'SearchPosts'
)
expect(capturedRequest).toMatchObject({
operationName: 'SearchPosts',
variables: { query: 'graphql testing' },
})
})Seeding Test Data Through the API
Database-level seed scripts are brittle — they depend on schema knowledge and bypass business logic validation. Seed via GraphQL mutations instead:
// tests/fixtures/seed.ts
import { request, gql } from 'graphql-request'
const API_URL = process.env.TEST_API_URL || 'http://localhost:4000/graphql'
export async function seedTestUser(overrides: Partial<TestUser> = {}) {
const mutation = gql`
mutation CreateTestUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
email
}
}
`
const input = {
name: overrides.name ?? 'Test User',
email: overrides.email ?? `test-${Date.now()}@example.com`,
role: overrides.role ?? 'user',
}
const data = await request(API_URL, mutation, { input })
return data.createUser
}
export async function seedTestPost(authorId: string, overrides = {}) {
const mutation = gql`
mutation CreateTestPost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
body
}
}
`
const data = await request(API_URL, mutation, {
input: {
title: overrides.title ?? 'Test Post',
body: overrides.body ?? 'Test content',
authorId,
},
})
return data.createPost
}
export async function cleanupTestData(ids: { users?: string[]; posts?: string[] }) {
if (ids.users?.length) {
for (const id of ids.users) {
await request(API_URL, gql`mutation { deleteUser(id: "${id}") { id } }`)
}
}
if (ids.posts?.length) {
for (const id of ids.posts) {
await request(API_URL, gql`mutation { deletePost(id: "${id}") { id } }`)
}
}
}Using seed data in tests:
test.describe('Post list', () => {
let testUser: { id: string }
let testPosts: Array<{ id: string }>
test.beforeAll(async () => {
testUser = await seedTestUser({ name: 'E2E Test User' })
testPosts = await Promise.all([
seedTestPost(testUser.id, { title: 'First E2E Post' }),
seedTestPost(testUser.id, { title: 'Second E2E Post' }),
])
})
test.afterAll(async () => {
await cleanupTestData({
posts: testPosts.map(p => p.id),
users: [testUser.id],
})
})
test('shows all user posts', async ({ page }) => {
await page.goto(`/users/${testUser.id}/posts`)
await expect(page.getByText('First E2E Post')).toBeVisible()
await expect(page.getByText('Second E2E Post')).toBeVisible()
})
})Testing Pagination
GraphQL pagination (cursor-based or offset-based) is a common source of E2E bugs:
test('loads more posts on scroll', async ({ page }) => {
// Seed 25 posts to test pagination
const user = await seedTestUser()
const posts = await Promise.all(
Array.from({ length: 25 }, (_, i) =>
seedTestPost(user.id, { title: `Post ${i + 1}` })
)
)
await page.goto(`/users/${user.id}/posts`)
// First page: 10 posts visible
await expect(page.locator('[data-testid="post-card"]')).toHaveCount(10)
// Scroll to bottom to trigger next page
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
// Wait for next page to load
await expect(page.locator('[data-testid="post-card"]')).toHaveCount(20)
// Scroll again
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
await expect(page.locator('[data-testid="post-card"]')).toHaveCount(25)
// No more items indicator
await expect(page.getByText(/all posts loaded/i)).toBeVisible()
// Cleanup
await cleanupTestData({ posts: posts.map(p => p.id), users: [user.id] })
})Testing Real-Time Subscriptions in Playwright
Testing WebSocket subscriptions requires two concurrent browser contexts or a background process to trigger events:
test('real-time message appears without refresh', async ({ browser }) => {
// Two users: sender and receiver
const senderContext = await browser.newContext()
const receiverContext = await browser.newContext()
const senderPage = await senderContext.newPage()
const receiverPage = await receiverContext.newPage()
const channel = await seedTestChannel()
// Receiver opens the channel first
await receiverPage.goto(`/channels/${channel.id}`)
await expect(receiverPage.getByText(/0 messages/i)).toBeVisible()
// Sender sends a message
await senderPage.goto(`/channels/${channel.id}`)
await senderPage.getByPlaceholder('Type a message...').fill('Hello from E2E test')
await senderPage.getByRole('button', { name: /send/i }).click()
// Receiver's page should update via subscription — no refresh needed
await expect(receiverPage.getByText('Hello from E2E test')).toBeVisible({ timeout: 5000 })
await senderContext.close()
await receiverContext.close()
})Network Condition Testing
GraphQL apps need to handle slow connections and timeouts gracefully:
test('shows loading state during slow query', async ({ page, context }) => {
// Simulate slow 3G
await context.route('**/graphql', async (route) => {
await new Promise(r => setTimeout(r, 2000)) // 2 second delay
await route.continue()
})
await page.goto('/users/1')
// Loading state should be visible immediately
await expect(page.getByTestId('loading-skeleton')).toBeVisible()
// Eventually resolves
await expect(page.getByText('Alice')).toBeVisible({ timeout: 10000 })
})
test('shows error on timeout', async ({ page, context }) => {
// Simulate connection that never responds
await context.route('**/graphql', async (route) => {
// Never fulfill the route — simulates server timeout
await new Promise(r => setTimeout(r, 30000))
})
await page.goto('/users/1')
// App should show timeout error after its configured timeout period
await expect(page.getByText(/connection timed out/i)).toBeVisible({ timeout: 15000 })
})Authentication in E2E Tests
Don't log in through the UI in every test — it's slow and brittle. Authenticate once and reuse the session:
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'authenticated',
use: { storageState: 'playwright/.auth/user.json' },
testMatch: 'tests/authenticated/**/*.spec.ts',
},
{
name: 'public',
testMatch: 'tests/public/**/*.spec.ts',
},
],
})// tests/auth.setup.ts
import { test as setup } from '@playwright/test'
setup('create authenticated session', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('e2e@example.com')
await page.getByLabel('Password').fill('test-password')
await page.getByRole('button', { name: /log in/i }).click()
await page.waitForURL('/dashboard')
await page.context().storageState({ path: 'playwright/.auth/user.json' })
})Alternatively, set the auth token directly via GraphQL mutation:
setup('create session via API', async ({ page }) => {
// Skip browser UI login — use the API
const { loginUser } = await request(API_URL, gql`
mutation {
loginUser(email: "e2e@example.com", password: "test-password") {
token
}
}
`)
// Set the token in localStorage/cookies
await page.goto('/')
await page.evaluate((token) => {
localStorage.setItem('auth_token', token)
}, loginUser.token)
await page.context().storageState({ path: 'playwright/.auth/user.json' })
})Choosing Between Stubbed and Live Tests
Use stubbed (mocked) GraphQL responses when:
- Testing UI states that are hard to reproduce (empty states, errors, edge cases)
- Running tests in CI without a backend available
- Testing loading and error UI independently of backend behavior
Use live (real server) tests when:
- Validating end-to-end data flow from mutation to query result
- Testing subscriptions (real WebSocket behavior)
- Validating pagination and cursor handling
- Testing authentication and authorization with real tokens
The pragmatic approach: stub edge cases, run real tests for core user flows. This gives fast feedback on UI states without sacrificing confidence on critical paths.
Summary
E2E GraphQL testing with Cypress and Playwright shares the same core model:
- Intercept operations by
operationName(not URL) - Seed data via mutations, not database scripts
- Test both loading states (using stubs) and real flows (using live server)
- Capture request variables to verify correct operation parameters
- Handle subscriptions with concurrent browser contexts
- Authenticate via API in setup, not through UI in each test
The investment in a solid GraphQL intercept helper pays back immediately — it becomes the foundation for testing every operation your app makes, without the brittleness of URL-matching interceptors.