GraphQL returns HTTP 200 for failed queries: errors land in body.errors alongside a null body.data, so a test that only checks the status code passes even when the query returned nothing. Every GraphQL request is also a POST to a single /graphql endpoint, which means the query in the request body determines what you get back and there are no separate endpoints to test against. This guide covers writing queries and mutations with Playwright's request fixture, asserting both the response shape and error conditions correctly, and building a reusable gql fixture that removes repeated boilerplate.
How GraphQL is different from REST
REST: multiple endpoints, each returning a fixed shape.
GET /users/1 → { id, name, email, createdAt, ... }
GET /orders/456 → { id, items, total, status, ... }GraphQL: one endpoint (/graphql), always POST, you specify exactly what you want:
query {
user(id: "1") {
name
email
}
order(id: "456") {
total
status
}
}The query goes in the request body as JSON. The response shape matches your query exactly: no extra fields, no separate requests.
For testing, this means:
- Every test is a POST to the same URL
- The query shape is part of the test: if you ask for a field that doesn't exist, you get an error
- GraphQL errors don't always use HTTP error codes (more on this below)
Writing GraphQL tests in Playwright
Use request.post() for all GraphQL operations:
import { test, expect } from '@playwright/test';
test('fetch user returns correct data', async ({ request }) => {
const response = await request.post('https://api.example.com/graphql', {
data: {
query: `
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`,
variables: { id: '1' },
},
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer your-token-here',
},
});
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.errors).toBeUndefined();
expect(body.data.user.name).toBe('Alice');
expect(body.data.user.email).toContain('@');
});Three things to notice:
1. The query uses variables ($id: ID!) instead of string interpolation. Always do this to avoid injection issues and to keep queries reusable.
2. The Content-Type header must be application/json.
3. You check for body.errors explicitly, because GraphQL returns 200 even when there's an error.
The GraphQL error model
This trips up everyone testing GraphQL for the first time.
REST: a bad request returns 400. An unauthorized request returns 401. A missing resource returns 404.
GraphQL: almost everything returns 200. Errors come back in the response body alongside (or instead of) data:
{
"data": null,
"errors": [
{
"message": "User not found",
"locations": [{ "line": 2, "column": 3 }],
"path": ["user"]
}
]
}This means your assertions must check the response body, not just the HTTP status:
// ❌ Wrong — passes even when GraphQL returns an error
expect(response.status()).toBe(200);
// ✅ Correct — checks both HTTP status and GraphQL error field
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.errors).toBeUndefined();
expect(body.data).not.toBeNull();Some GraphQL servers do return 400 for malformed queries and 401 for auth failures, but don't rely on it. Always check body.errors.
Testing mutations
Mutations (create, update, delete operations) follow the same pattern:
test('create order mutation', async ({ request }) => {
const response = await request.post('https://api.example.com/graphql', {
data: {
query: `
mutation CreateOrder($input: OrderInput!) {
createOrder(input: $input) {
id
status
total
}
}
`,
variables: {
input: {
productId: 'prod-123',
quantity: 2,
shippingAddress: '123 Main St',
},
},
},
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer your-token-here',
},
});
const body = await response.json();
expect(body.errors).toBeUndefined();
expect(body.data.createOrder.status).toBe('PENDING');
expect(body.data.createOrder.id).toBeTruthy();
});Building a reusable GraphQL client fixture
Copy-pasting the headers and POST call into every test is noisy. Extract it into a fixture:
// fixtures/graphql.ts
import { test as base, APIRequestContext } from '@playwright/test';
type GraphQLFixtures = {
gql: (query: string, variables?: Record<string, unknown>) => Promise<Record<string, unknown>>;
};
export const test = base.extend<GraphQLFixtures>({
gql: async ({ request }, use) => {
const gql = async (query: string, variables: Record<string, unknown> = {}) => {
const response = await request.post(process.env.GRAPHQL_URL!, {
data: { query, variables },
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.API_TOKEN}`,
},
});
const body = await response.json();
if (body.errors) {
throw new Error(`GraphQL error: ${JSON.stringify(body.errors)}`);
}
return body.data;
};
await use(gql);
},
});Now tests become clean:
import { test } from '../fixtures/graphql';
import { expect } from '@playwright/test';
test('fetch user', async ({ gql }) => {
const data = await gql(`
query { user(id: "1") { name email } }
`);
expect(data.user.name).toBe('Alice');
});Testing error cases
Verify that your GraphQL API returns appropriate errors for invalid inputs:
test('returns error for non-existent user', async ({ request }) => {
const response = await request.post('https://api.example.com/graphql', {
data: {
query: `query { user(id: "non-existent-id") { name } }`,
},
headers: { 'Content-Type': 'application/json' },
});
const body = await response.json();
expect(body.errors).toBeDefined();
expect(body.errors[0].message).toContain('not found');
});
test('returns auth error without token', async ({ request }) => {
const response = await request.post('https://api.example.com/graphql', {
data: { query: `query { user(id: "1") { name } }` },
headers: { 'Content-Type': 'application/json' },
// No Authorization header
});
const body = await response.json();
expect(body.errors).toBeDefined();
expect(body.errors[0].message).toMatch(/unauthorized|unauthenticated/i);
});Schema validation
For deep GraphQL testing, validate responses against the schema using graphql-tag and schema introspection. This catches type mismatches and field changes automatically, useful in contract testing scenarios where the schema is the contract.
This is advanced territory: most teams start with the request/response pattern above and add schema validation later when the API has stabilized.
→ See also: API Testing with Playwright's APIRequestContext (No Postman Required) | Advanced API Testing with Playwright: Patterns for Real Projects | Contract Testing with Pact: Stop Breaking APIs Between Teams