End-to-End GraphQL Testing with Cypress and Playwright

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:

  1. Intercept operations by operationName (not URL)
  2. Seed data via mutations, not database scripts
  3. Test both loading states (using stubs) and real flows (using live server)
  4. Capture request variables to verify correct operation parameters
  5. Handle subscriptions with concurrent browser contexts
  6. 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.

Read more

Start now free