|

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.

ConcernREST testingGraphQL testing
Endpoint surfaceMany URLs, one per resourceSingle /graphql endpoint
HTTP methodGET / POST / PUT / DELETEAlmost always POST
Success signal2xx status codeAbsence of errors + presence of data
Error signal4xx / 5xx statusHTTP 200 with errors[] array
Over/under-fetchingFixed payload shapeClient controls returned fields
RealtimePolling / SSESubscriptions 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 errors array and data shape, never on HTTP status alone.
  • Pass arguments through variables, not string interpolation, to avoid escaping bugs.
  • Prefer extensions.code over the free-text message for error assertions.
  • Use expect.soft to collect every field failure in a single run.
  • Authenticate once via a fixture or setup project and reuse the context.
  • Always dispose() any APIRequestContext you create by hand.
  • Filter subscription frames on the graphql-ws type field to skip protocol noise.
  • Mock with page.route when 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.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.