Stubbing GraphQL using Playwright

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

Published
Last updated
Photo of Jay in Bazel

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:

Logo for GraphQLGraphQL
query GetThing { thing { id } }
Logo for JSONJSON
{ "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:

Logo for TypeScriptTypeScript
import { 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 to interceptGQL.
  • 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:

Logo for TypeScriptTypeScript
import { 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:

Logo for TypeScriptTypeScript
import { 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:

Logo for TypeScriptTypeScript
export 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:

Logo for YAMLYAML
# 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:

Logo for TypeScriptTypeScript
export 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:

Logo for TypeScriptTypeScript
export 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:

Logo for TypeScriptTypeScript
import { 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 ✌️

← Archive