Stubbing GraphQL using Playwright
A more ergonomic solution to stubbing out multiple GraphQL responses using Playwright.

I’m a software engineer living and working in East London. I’m currently helping to build a one-stop-shop for the digitisation of alternative assets over at Daphne. Although once strictly front-end, today I work across the whole stack, including dipping my toes into DevOps and writing Rust & Go.
Playwright is emerging as the end-to-end testing tool of choice on the web. While slightly less mature than Cypress, over the last couple of years it’s grown into a serious contender.
A while back I wrote about intercepting client-side GraphQL requests in Cypress, so I thought it was worth a brief look at what that might look like in Playwright today.
Revisiting our method
Unlike when working with a REST API, it’s not possible to simply intercept a request based on the URL (e.g. /api/users/123
). All GraphQL queries (including mutations) get sent to the same endpoint, making traditional interception awkward.
We’ll be employing the same strategy as we did with Cypress: use the operation name to distinguish between requests, ignoring any which don’t match a registered interceptor.
The operationName
field is sent by most GraphQL clients when making a request, and matches up to the name you give your local query (so make sure you give it one!).
Here’s an example query, as well as the request body which gets sent over the wire:
GraphQLquery GetThing {
thing {
id
}
}
JSON{
"operationName": "GetThing",
"query": "query GetThing { thing { id } }"
}
Adding our interceptor
Let’s make our helper available to our test suite using a fixture.
Playwright’s fixtures are a far cry from fixtures in Cypress. Instead of static data inputs to feed your application, they’re essentially test-scoped instances of anything needed to establish the test environment.
You can add helpers, assertions, setup code and more.
Let’s add our new helper, interceptGQL
, to fixtures/test.ts
:
TypeScriptimport { Page, Route } from '@playwright/test';
// GQL variables the request was called with.
// Useful to validate the API was called correctly.
type CalledWith = Record<string, unknown>;
// Registers a client-side interception to our BFF (presumes all `graphql`
// requests are to us). Interceptions are per-operation, so multiple can be
// registered for different operations without overwriting one-another.
export async function interceptGQL(
page: Page,
operationName: string,
resp: Record<string, unknown>
): Promise<CalledWith[]> {
// A list of GQL variables which the handler has been called with.
const reqs: CalledWith[] = [];
// Register a new handler which intercepts all GQL requests.
await page.route('**/graphql', function (route: Route) {
const req = route.request().postDataJSON();
// Pass along to the previous handler in the chain if the request
// is for a different operation.
if (req.operationName !== operationName) {
return route.fallback();
}
// Store what variables we called the API with.
reqs.push(req.variables);
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: resp }),
});
});
return reqs;
}
Let’s walk through the code:
- We use
page.route
, which is Playwright’s client-side interceptor, to catch all requests ending in/graphql
. - We then check the
operationName
field to see if it matches the operation passed tointerceptGQL
. - If it doesn’t, we ignore the request, however if it does, we fulfil the request by sending back the response data passed to
interceptGQL
.
Our helper also keeps a record of the GraphQL variables that were part of the query, in case we want to validate that the backend was called correctly.
We can now register our new fixture by extending test
, which will take the place of the default one exported from @playwright/test
:
TypeScriptimport { test as baseTest } from '@playwright/test';
export const test = baseTest.extend<{ interceptGQL: typeof interceptGQL }>({
interceptGQL: async ({ browser }, use) => {
await use(interceptGQL);
},
});
Now all of our tests can have access to the interceptGQL
helper:
TypeScriptimport { test } from '../fixtures/test';
test('Paying a capital call, happy path', async ({ page, interceptGQL }) => {
await interceptGQL(page, 'CreateCapitalCall', {
createCapitalCall: {
facility: 1,
},
});
});
Blocking real requests
As an additional safeguard, it’s also worth blocking unhandled requests. This ensures stray requests never make it to the real backend, if (for instance), you forget to register a handler:
TypeScriptexport const test = baseTest.extend<{ interceptGQL: typeof interceptGQL }>({
interceptGQL: async ({ browser }, use) => {
await use(interceptGQL);
},
page: async ({ page }, use) => {
// Block all BFF requests from making it through to the 'real'
// dependency. If we get this far it means we've forgotten to register a
// handler, and (at least locally) we're using a real dependency.
await page.route('**/graphql', function (route: Route) {
route.abort('blockedbyclient');
});
await use(page);
},
});
Bonus round: type safety
If you’re using GraphQL code generation (if not, you should be!), then it’s possible to get type safety and IDE completion for your stub handlers, request variables and operation names. How exactly you go about this will depend on your specific project and requirements, but here’s a quick example of hacking together a solution using auto-generated URQL hooks.
First we set up the code generation based off of our schema:
YAML# Code generation config.
# Generates req/res and Apollo resolver types from a SDL.
overwrite: true
schema: '../api/schema.graphql'
documents: 'client/queries.graphql'
generates:
client/generated/graphql.ts:
plugins:
- 'typescript'
- 'typescript-operations'
- 'typescript-urql'
- 'named-operations-object'
config:
enumsAsTypes: true
useConsts: true
avoidOptionals:
field: true
inputValue: true
This gives type-safe URQL hooks:
TypeScriptexport function useCreateCapitalCallMutation() {
return Urql.useMutation<CreateCapitalCallMutation, CreateCapitalCallMutationVariables>(CreateCapitalCallDocument);
};
In addition, thanks to the named-operations-object
plugin, we get an object containing all our GraphQL operations:
TypeScriptexport const namedOperations = {
Query: {
GetArticles: 'GetArticles' as const,
GetFunds: 'GetFunds' as const,
},
Mutation: {
CreateCapitalCall: 'CreateCapitalCall' as const,
}
}
This gives us enough to smush together an Operations
type contains a mapping of operations to argument and response types.
We can then use these to update interceptGQL
:
TypeScriptimport { Page, Route } from '@playwright/test';
// Import everything so that any additional queries/mutations automatically get added to `Operations`.
import type * as client from '../../client/generated/graphql';
// Full mapping of all operations and their argument and return types. Used
// to type `interceptGQL` without maintaining a static mapping of valid
// intercepts.
export type Operations = {
[K in keyof typeof client.namedOperations['Mutation']]: {
args: Parameters<ReturnType<typeof client[`use${K}Mutation`]>['1']>[0];
data: NonNullable<ReturnType<typeof client[`use${K}Mutation`]>[0]['data']>;
};
} & {
[K in keyof typeof client.namedOperations['Query']]: {
args: Parameters<ReturnType<typeof client[`use${K}Query`]>['1']>[0];
data: NonNullable<ReturnType<typeof client[`use${K}Query`]>[0]['data']>;
};
};
export async function interceptGQL<O extends keyof Operations>(
page: Page,
operationName: O,
resp: Operations[O]['data']
): Promise<Operations[O]['args'][]> {
const reqs: Operations[O]['args'][] = [];
await page.route('**/graphql', function (route: Route) {
const req = route.request().postDataJSON();
if (req.operationName !== operationName) {
return route.fallback();
}
reqs.push(req.variables);
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: resp }),
});
});
return reqs;
}
This ensures we’re passing the correct stub responses to our handlers, as well as providing in-editor autocompletion. Magic.
The elephant in the room
Of course, the big showstopper here is a complete lack of server-side support. If you have an isomorphic app, you won’t be able to rely on the in-browser interception that Cypress or Playwright offer.
Instead, you’ll likely be forced to spin up an instance of MSW and your application per-suite, or perhaps use something like Mountebank if you’re testing a production-ready container.
I’ll be circling back to explore a few of these options in the future ✌️