GraphQL API Testing with Playwright: Queries, Mutations, Subscriptions
GraphQL endpoints break the assumptions of classic REST testing: there is one URL, the verb is almost always POST, a “200 OK” can still carry a failed operation, and the response shape is whatever your query asked for. If you have ever watched a mutation silently return null while your test stayed green, you already know the pain. In this guide on Playwright GraphQL API testing, you will learn how to validate queries, mutations, and subscriptions in TypeScript using Playwright’s real APIRequestContext, assert on the GraphQL error contract correctly, and wire authentication so your suite stays fast and reliable.
π Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.
Contents
Why GraphQL Testing Is Different From REST
With REST you lean on HTTP status codes and resource URLs to express intent. GraphQL collapses all of that into a single endpoint that returns a predictable envelope: a top-level data object and an optional errors array. The transport almost always answers with HTTP 200 even when the business operation failed, so your assertions must look inside the body rather than at the status line. That single fact reshapes how you write every test.
| Concern | REST testing | GraphQL testing |
|---|---|---|
| Endpoint surface | Many URLs, one per resource | Single /graphql endpoint |
| HTTP method | GET / POST / PUT / DELETE | Almost always POST |
| Success signal | 2xx status code | Absence of errors + presence of data |
| Error signal | 4xx / 5xx status | HTTP 200 with errors[] array |
| Over/under-fetching | Fixed payload shape | Client controls returned fields |
| Realtime | Polling / SSE | Subscriptions over WebSocket |
The practical takeaway: never assert success on response.ok() alone. A query can be syntactically valid, return HTTP 200, and still hand you an errors array because a resolver threw. Treat the body as the source of truth.
Project Setup for Playwright GraphQL API Testing
Playwright’s API testing capability does not need a browser. The request fixture exposes an APIRequestContext that speaks raw HTTP, which is exactly what a GraphQL POST needs. Start by setting a base URL and default headers in playwright.config.ts so every request inherits them.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: {
// Public demo API used throughout this guide
baseURL: 'https://countries.trevorblades.com',
extraHTTPHeaders: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
},
});
A small typed helper keeps every test readable. It centralises the POST, parses the JSON envelope, and gives you a single place to add logging or schema checks later. Notice the generic <T> so callers get typed data back.
// utils/graphql.ts
import { APIRequestContext, expect } from '@playwright/test';
export interface GraphQLResponse<T> {
data?: T;
errors?: Array<{ message: string; path?: string[]; extensions?: Record<string, unknown> }>;
}
export async function gql<T>(
request: APIRequestContext,
query: string,
variables: Record<string, unknown> = {},
): Promise<GraphQLResponse<T>> {
const response = await request.post('/', {
data: { query, variables },
});
// Transport must succeed even though business errors live in the body
expect(response.status(), 'GraphQL transport should be HTTP 200').toBe(200);
return (await response.json()) as GraphQLResponse<T>;
}
That gql wrapper is the backbone of every example below. It asserts the transport layer once, then returns a typed envelope you can drill into.
Testing GraphQL Queries
A query test has three jobs: confirm there are no errors, confirm data is present, and assert on the exact fields you requested. Because the client decides the response shape, your test doubles as living documentation of what the UI actually consumes.
// tests/queries.spec.ts
import { test, expect } from '@playwright/test';
import { gql } from '../utils/graphql';
interface CountryData {
country: { name: string; capital: string; currency: string } | null;
}
test('fetches a country by code with variables', async ({ request }) => {
const query = `
query GetCountry($code: ID!) {
country(code: $code) {
name
capital
currency
}
}`;
const body = await gql<CountryData>(request, query, { code: 'IN' });
expect(body.errors).toBeUndefined();
expect(body.data?.country).not.toBeNull();
expect(body.data?.country?.name).toBe('India');
expect(body.data?.country?.capital).toBe('New Delhi');
expect(body.data?.country?.currency).toContain('INR');
});
Passing arguments through the variables object instead of string-concatenating them into the query is the single most important habit in GraphQL testing. It mirrors how real clients work, sidesteps escaping bugs, and lets you data-drive the same query across many inputs.
Asserting on partial errors
GraphQL can return data and errors in the same response when one field resolver fails but others succeed. Use expect.soft so a single failed assertion does not abort the rest of the checks, letting you see the full picture in one run.
test('reports a clean envelope for a valid query', async ({ request }) => {
const query = `{ countries { code name } }`;
const body = await gql<{ countries: Array<{ code: string; name: string }> }>(request, query);
// soft assertions collect all failures before the test stops
expect.soft(body.errors, 'no partial errors expected').toBeUndefined();
expect.soft(body.data?.countries.length).toBeGreaterThan(100);
expect.soft(body.data?.countries[0]).toHaveProperty('code');
});
Testing GraphQL Mutations
Mutations are where silent failures hide. A mutation can return HTTP 200, a data object, and a null payload because a validation rule rejected the input. Your test must assert that the mutation actually changed state and returned the object you expect. The pattern below targets a typical authenticated write endpoint and shows how to send the auth header per request.
// tests/mutations.spec.ts
import { test, expect } from '@playwright/test';
interface CreatePostData {
createPost: { id: string; title: string; published: boolean } | null;
}
test('creates a post and returns the persisted entity', async ({ request }) => {
const mutation = `
mutation CreatePost($input: PostInput!) {
createPost(input: $input) {
id
title
published
}
}`;
const response = await request.post('https://api.example.com/graphql', {
headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
data: {
query: mutation,
variables: { input: { title: 'Playwright GraphQL', published: true } },
},
});
expect(response.status()).toBe(200);
const body = (await response.json()) as { data?: CreatePostData; errors?: unknown[] };
expect(body.errors).toBeUndefined();
expect(body.data?.createPost).not.toBeNull();
expect(body.data?.createPost?.id).toBeTruthy();
expect(body.data?.createPost?.title).toBe('Playwright GraphQL');
expect(body.data?.createPost?.published).toBe(true);
});
Verifying the negative path
A mutation suite is only trustworthy when it also proves the API rejects bad input. Here you assert that the errors array exists, that data is empty, and that the error carries a machine-readable code in extensions rather than relying on a brittle human-readable string.
test('rejects a mutation with invalid input', async ({ request }) => {
const mutation = `
mutation CreatePost($input: PostInput!) {
createPost(input: $input) { id }
}`;
const response = await request.post('https://api.example.com/graphql', {
headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
data: { query: mutation, variables: { input: { title: '' } } },
});
const body = (await response.json()) as {
data?: { createPost: null };
errors?: Array<{ message: string; extensions?: { code?: string } }>;
};
expect(body.errors).toBeDefined();
expect(body.errors?.length).toBeGreaterThan(0);
expect(body.data?.createPost ?? null).toBeNull();
// Prefer the stable extensions.code over the free-text message
expect(body.errors?.[0].extensions?.code).toBe('BAD_USER_INPUT');
});
Authentication and Reusable State
Logging in on every test is slow and flaky. Playwright solves this with storageState and request-scoped contexts. For pure API suites, the cleanest approach is a fixture that authenticates once, captures the token, and reuses a pre-authenticated APIRequestContext that every test can inject. The snippet below shows a token-based fixture.
// fixtures/auth.ts
import { test as base, request as playwrightRequest, APIRequestContext } from '@playwright/test';
type Fixtures = { authedRequest: APIRequestContext };
export const test = base.extend<Fixtures>({
authedRequest: async ({}, use) => {
// One login round-trip, then reuse the context for the whole test
const login = await playwrightRequest.newContext({
baseURL: 'https://api.example.com',
});
const res = await login.post('/graphql', {
data: {
query: `mutation($e: String!, $p: String!) { login(email: $e, password: $p) { token } }`,
variables: { e: process.env.USER_EMAIL, p: process.env.USER_PASSWORD },
},
});
const token = (await res.json()).data.login.token as string;
const authed = await playwrightRequest.newContext({
baseURL: 'https://api.example.com',
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
});
await use(authed);
await authed.dispose();
await login.dispose();
},
});
export { expect } from '@playwright/test';
Because the header is baked into the context, individual tests stay clean: they call authedRequest.post('/graphql', ...) and never touch the token again. Always dispose() contexts you create manually so connections are released.
Testing GraphQL Subscriptions
Subscriptions push data over a long-lived WebSocket rather than a single POST, so they fall outside APIRequestContext. Playwright still helps in two ways. First, request.post can drive the mutation that triggers an event. Second, Playwright’s page.waitForEvent('websocket') exposes the socket the page opens, and the WebSocket’s framereceived event lets you assert on the frames that arrive.
// tests/subscriptions.spec.ts
import { test, expect } from '@playwright/test';
test('receives a frame over the graphql-ws subscription', async ({ page }) => {
// Capture the WebSocket Playwright sees the page open
const wsPromise = page.waitForEvent('websocket', (ws) =>
ws.url().includes('/graphql'),
);
await page.goto('https://app.example.com/live');
const ws = await wsPromise;
// Wait for a server frame that carries subscription payload data
const frame = await ws.waitForEvent('framereceived', (f) =>
typeof f.payload === 'string' && f.payload.includes('"type":"next"'),
);
const message = JSON.parse(frame.payload as string);
expect(message.type).toBe('next');
expect(message.payload.data).toBeDefined();
});
If your subscription transport is the modern graphql-ws protocol, the handshake messages have type values of connection_init, connection_ack, subscribe, next, and complete. Filtering framereceived on type: "next" isolates the actual payload frames from protocol chatter. For a headless WebSocket-only check without a real page, you can also open a socket directly inside page.evaluate and resolve a promise when the expected frame lands.
π Level Up Your Playwright
From locators to CI pipelines β build a production-grade Playwright + TypeScript framework step by step.
Driving the trigger from the API layer
A realistic end-to-end subscription test combines both worlds: subscribe over WebSocket, fire the mutation through request.post, then assert the pushed frame matches. Keeping the mutation on the fast API path while the page only listens makes the test deterministic and quick.
test('mutation publishes an event the subscription receives', async ({ page, request }) => {
const wsPromise = page.waitForEvent('websocket', (ws) => ws.url().includes('/graphql'));
await page.goto('https://app.example.com/messages');
const ws = await wsPromise;
const framePromise = ws.waitForEvent('framereceived', (f) =>
typeof f.payload === 'string' && f.payload.includes('Hello from Playwright'),
);
// Trigger the event over the fast HTTP API path
await request.post('https://api.example.com/graphql', {
data: {
query: `mutation($t: String!) { postMessage(text: $t) { id } }`,
variables: { t: 'Hello from Playwright' },
},
});
const frame = await framePromise;
expect(frame.payload).toContain('Hello from Playwright');
});
Mocking GraphQL With page.route
When you test the UI rather than the backend, intercept the GraphQL POST with page.route and fulfil it with a fixed envelope. Because every operation hits the same URL, branch on the operationName or query text inside the route handler to return the right canned response.
test('UI renders from a mocked GraphQL response', async ({ page }) => {
await page.route('**/graphql', async (route) => {
const post = route.request().postDataJSON();
if (post.query.includes('GetCountry')) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: { country: { name: 'Stubland', capital: 'Mockville' } } }),
});
} else {
await route.continue();
}
});
await page.goto('https://app.example.com/country/IN');
await expect(page.getByRole('heading', { name: 'Stubland' })).toBeVisible();
});
This isolates front-end behaviour from backend availability, makes edge cases like empty lists or error envelopes trivial to reproduce, and removes network flakiness from UI assertions entirely.
Best Practices Checklist
- Assert on the
errorsarray anddatashape, never on HTTP status alone. - Pass arguments through
variables, not string interpolation, to avoid escaping bugs. - Prefer
extensions.codeover the free-textmessagefor error assertions. - Use
expect.softto collect every field failure in a single run. - Authenticate once via a fixture or setup project and reuse the context.
- Always
dispose()anyAPIRequestContextyou create by hand. - Filter subscription frames on the
graphql-wstypefield to skip protocol noise. - Mock with
page.routewhen the test target is the UI, not the server.
Conclusion
Effective Playwright GraphQL API testing comes down to one mindset shift: stop trusting the HTTP status line and start asserting on the GraphQL envelope. With a thin typed gql helper, variable-driven queries, mutation tests that cover both the happy and rejection paths, a token fixture for auth, and WebSocket frame assertions for subscriptions, you get a suite that is fast, deterministic, and genuinely catches regressions. Add page.route mocking for UI work and you can cover the full GraphQL surface from a single Playwright project.
FAQ
Do I need a browser for Playwright GraphQL API testing?
No. Queries and mutations run through Playwright’s APIRequestContext via the request fixture, which speaks raw HTTP without launching a browser. You only need a browser context for subscriptions, because those use a long-lived WebSocket that you observe through page.waitForEvent('websocket') and the WebSocket’s framereceived event.
Why does my GraphQL test pass even when the operation failed?
GraphQL servers typically return HTTP 200 even for business-logic failures, placing the failure in a top-level errors array while data stays null. If your test only checks response.ok() it will pass. Always assert that body.errors is undefined for success cases and inspect the actual data fields you requested.
How do I test GraphQL subscriptions with Playwright?
Open the page that initiates the subscription, capture the socket with page.waitForEvent('websocket'), then wait for a framereceived event filtered on the graphql-ws message type of next. Trigger the event with a mutation sent through request.post, then assert the received frame’s payload matches what you published.
π Master Playwright End to End
Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.
