Test and Mock GraphQL APIs Without Trusting the 200
Replay a GraphQL recording at the server that produced it, and every request comes back 200 OK. Here is one of those 200s, from a Session query whose recorded token has expired:
{
"data": { "session": null },
"errors": [
{ "message": "token expired", "locations": [{ "line": 2, "column": 3 }], "path": ["session"] }
]
}
A GraphQL server reports most failures inside a successful HTTP response, so any check that stops at the status code passes this request. And that is the second way GraphQL hides from URL-shaped tooling. The first: every operation is a POST to the same /graphql endpoint, so a coverage report, a mock server or a dashboard keyed on method and path sees one endpoint, called eight times, all green.
This post walks through testing a GraphQL API with proxymock so that neither trick works. You will generate tests and mocks from the schema of a running server, record its real responses, replay them as a regression test that catches the error above, mock the API for its client, measure what the recording covers per operation, and flag a schema change in CI. It is the GraphQL companion to gRPC Mocks, Tests and Contract Checks From a .proto. Everything below ran on a real service written in Java, Node.js, Go and Python, with every output copied from those runs, and proxymock never needed to know which language it was talking to.
- proxymock reads each GraphQL request as the operation it runs, so replay results, mocks and coverage are per operation, not per
/graphql. - A GraphQL error inside a 200 fails a replay as a body mismatch, and a semantic-path transform on
variables.tokenfixes an expired token for good. proxymock validateresolves each recorded request’s selection against the schema, so a type change is caught even where a union fragment hides it.
flowchart LR
S[Running server] -->|introspection| G[proxymock generate]
G --> T[Tests and mocks]
S --> R[proxymock record]
R --> P[proxymock replay]
R --> M[proxymock mock]
R --> C[proxymock coverage]
R --> V[proxymock validate in CI]
What you need
- A proxymock build with GraphQL schema support. This feature is awaiting a public release; the walkthrough currently requires a build from speedscale!7235.
proxymock generate --helpmust mention GraphQL before you continue. Follow the installation and activation instructions before recording. - Java 17 or later with Maven, Node.js 20 or later, Go 1.23 or later, or Python 3.10 or later, whichever you work in. Step 6 also uses
jq. - The example: graphql-demo from Speedscale’s open source demo repository. It is a small SaaS API with four queries, two mutations, a union, and a driver that exercises aliases, named and inline fragments, a document holding two operations, and a
Sessionquery that verifies a signed JWT that expires after five minutes.
The demo comes in four editions, each built on its language’s standard GraphQL library: graphql-java, graphql-js, graphql-go and graphql-core. They define the same schema, answer the same way, and their drivers send byte-for-byte the same requests, so every output in this post is the same whichever one you pick. Where they differ, the post says so. Pick a language here and every code block below follows it:
Java
git clone https://github.com/speedscale/demo && cd demo/graphql-demo/java
mvn -q package
java -jar target/graphql-demo.jar # serves POST /graphql on :8080Node.js
git clone https://github.com/speedscale/demo && cd demo/graphql-demo/node
npm ci
node graphql-demo.js # serves POST /graphql on :8080Go
git clone https://github.com/speedscale/demo && cd demo/graphql-demo
go build -o graphql-demo .
./graphql-demo # serves POST /graphql on :8080Python
git clone https://github.com/speedscale/demo && cd demo/graphql-demo/python
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python graphql_demo.py # serves POST /graphql on :8080Step 1: Generate tests and mocks from the schema
Every edition defines its schema in code, as code-first servers do, so there is no .graphql file to point at. Here is the User type and the createUser mutation in each:
Java
GraphQLObjectType userType = GraphQLObjectType.newObject().name("User")
.field(newFieldDefinition().name("id").type(nonNull(GraphQLString)))
.field(newFieldDefinition().name("email").type(nonNull(GraphQLString)))
.field(newFieldDefinition().name("plan").type(GraphQLString))
.field(newFieldDefinition().name("tags").type(list(GraphQLString)))
.build();
GraphQLObjectType mutationType = GraphQLObjectType.newObject().name("Mutation")
.field(newFieldDefinition().name("createUser").type(userType)
.argument(newArgument().name("input").type(nonNull(createUserInput))))
// trackEvent ...
.build();Node.js
const userType = new GraphQLObjectType({
name: 'User',
fields: {
id: { type: new GraphQLNonNull(GraphQLString) },
email: { type: new GraphQLNonNull(GraphQLString) },
plan: { type: GraphQLString },
tags: { type: new GraphQLList(GraphQLString) },
},
})
const mutationType = new GraphQLObjectType({
name: 'Mutation',
fields: {
createUser: {
type: userType,
args: { input: { type: new GraphQLNonNull(createUserInput) } },
resolve: (_, { input }) => ({ /* ... */ }),
},
// trackEvent ...
},
})Go
userType := graphql.NewObject(graphql.ObjectConfig{
Name: "User",
Fields: graphql.Fields{
"id": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"email": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"plan": &graphql.Field{Type: graphql.String},
"tags": &graphql.Field{Type: graphql.NewList(graphql.String)},
},
})
mutationType := graphql.NewObject(graphql.ObjectConfig{
Name: "Mutation",
Fields: graphql.Fields{
"createUser": &graphql.Field{
Type: userType,
Args: graphql.FieldConfigArgument{
"input": &graphql.ArgumentConfig{Type: graphql.NewNonNull(createUserInput)},
},
Resolve: func(p graphql.ResolveParams) (any, error) { /* ... */ },
},
// trackEvent ...
},
})Python
user_type = GraphQLObjectType("User", {
"id": GraphQLField(GraphQLNonNull(GraphQLString)),
"email": GraphQLField(GraphQLNonNull(GraphQLString)),
"plan": GraphQLField(GraphQLString),
"tags": GraphQLField(GraphQLList(GraphQLString)),
})
mutation_type = GraphQLObjectType("Mutation", {
"createUser": GraphQLField(
user_type,
args={"input": GraphQLArgument(GraphQLNonNull(create_user_input))},
resolve=resolve_create_user,
),
# "trackEvent": ...
})proxymock generate takes the running endpoint instead and introspects it, so the language the schema is written in never comes up. An SDL file or a saved introspection result works the same way.
proxymock generate --direction both --out ./proxymock/generated http://localhost:8080/graphql
Processing 6 operations from GraphQL schema...
...
PLACEHOLDERS: 2 of 6 inbound test(s) still send generated placeholders ("example_value" or ${{param:...}}).
Generated inbound tests are drivers, not a baseline: replace the placeholders with real values
before replaying them at a real implementation or recording them through proxymock record.
- proxymock/generated/localhost/query_search_200_inbound.md
- proxymock/generated/localhost/mutation_trackEvent_200_inbound.md
Each root query and mutation field becomes one named operation, written once as an inbound test and once as an outbound mock. Here is what the createUser test sends:
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
email
plan
tags
}
}
with {"input": {"email": "user@example.com"}} as its variables. The selection follows the order the schema lists fields in, so against the Go edition, whose library lists them alphabetically, email comes first. The selection takes every scalar field one level deep, required arguments become variables, input objects get their required fields, and values come from the field names: id gets 12345, email an address. A union like search selects __typename and an inline fragment per member. Subscriptions are listed as skipped, and --include-paths 'query.*' narrows a large schema to the operations you want.
The two placeholders are honest. search(q: "example_value") returns a made-up user and no plans, and trackEvent takes a JSON document serialized into a string, which example_value is not. Generated tests are drivers for a first recording, not a baseline.
Step 2: Record the real responses
The demo ships its own client, so record that instead of the generated drivers. Stop the server from step 1 and start it again under the recorder:
Java
proxymock record --app-port 8080 -- java -jar target/graphql-demo.jarNode.js
proxymock record --app-port 8080 -- node graphql-demo.jsGo
proxymock record --app-port 8080 -- ./graphql-demoPython
proxymock record --app-port 8080 -- .venv/bin/python graphql_demo.pyThen, in a second terminal, send the driver through proxymock’s inbound port, 4143:
Java
java -jar target/graphql-demo.jar -drive -target http://localhost:4143Node.js
node graphql-demo.js -drive -target http://localhost:4143Go
./graphql-demo -drive -target http://localhost:4143Python
.venv/bin/python graphql_demo.py -drive -target http://localhost:41432026/10/26 17:19:54 Session ok
2026/10/26 17:19:54 CreateUser ok
2026/10/26 17:19:54 Dashboard ok
2026/10/26 17:19:54 Find ok
2026/10/26 17:19:54 GetPlans ok
2026/10/26 17:19:54 GetUser ok
2026/10/26 17:19:54 Track ok
2026/10/26 17:19:54 APQ ok (expected error: PersistedQueryNotFound)
2026/10/26 17:19:54 RESTSearch ok
Stop the recorder with Ctrl-C. Keep the recording directory created in this step: steps 3, 5 and 6 use this inbound recording, while step 4 creates a separate outbound recording. Each request is a file in proxymock/recorded-<timestamp>/localhost/, and the request body is stored as the parsed document rather than as query text. That is what lets proxymock match, edit and report on a request by what it means. A persisted query, which carries a hash and no query, is kept exactly as it was sent.
Step 3: Replay it as a regression test
Start the server on its own again, with the command from What you need, and replay the recording at it:
proxymock replay --in proxymock/recorded-<timestamp> --test-against http://localhost:8080
│ ENDPOINT │ METHOD │ COUNT │ ... │ FAILED │ STATUS CODE MISMATCH │
│ /(.*)/search │ POST │ 1 │ ... │ 0 │ 0% │
│ /graphql │ POST │ 1 │ ... │ 0 │ 0% │
│ CreateUser │ mutation │ 1 │ ... │ 0 │ 0% │
│ Dashboard,UserFields │ query │ 1 │ ... │ 0 │ 0% │
│ Find │ query │ 1 │ ... │ 0 │ 0% │
│ GetPlans,GetUser │ query,query │ 2 │ ... │ 0 │ 0% │
│ Track │ mutation │ 1 │ ... │ 0 │ 0% │
│ Session │ query │ 1 │ ... │ 0 │ 0% │
The report is per operation, not per URL. Dashboard,UserFields is an operation that spreads a named fragment. GetPlans,GetUser is one document holding two operations, recorded twice with a different operationName each time. The lone /graphql row is the persisted query, which has no document to read, and /(.*)/search is the REST endpoint the driver calls as a negative case. Within five minutes of recording, the verdict is pass.
Replay the same recording once the token’s five minutes are up, and the table looks just as healthy: Session still shows 0 failed and 0% status mismatch. The verdict does not:
Replay verdict: proxymock/results/replayed-<timestamp>/replay-verdict.json (mismatch)
sequenceDiagram
participant Replay as proxymock replay
participant Server as graphql-demo
Replay->>Server: query Session (recorded token)
Server-->>Replay: 200, data.session null, errors: token expired
Note over Replay: status 200 = 200, so the table shows nothing
Note over Replay: body differs from the recording, so the verdict is mismatch
replay-verdict.json has one failing entry: /graphql, POST, recorded status 200, observed status 200, status pass, body fail. It is the token expired response from the top of this post. A status check calls this request healthy, and the body comparison catches it. The replay command still exits 0 by default; for a CI gate, check the verdict explicitly:
jq -e '.verdict == "pass"' proxymock/results/replayed-<timestamp>/replay-verdict.json
This exits 1 for the expired-token mismatch and 0 for a passing replay.
The fix belongs in the test, not the recording. The token rides in variables.token, and proxymock’s graphql transform reaches it by that path, a semantic path that survives reordered arguments and reformatted queries, where a JSON path into the parsed document would not. Save a blueprint that re-signs it before each replay. The demo’s HS256 key is published for exactly this:
{
"id": "6f1c2a9e-3b7d-4e2a-9c51-2d8f0b7a4e10",
"name": "Resign session token",
"active": true,
"tokenizeConfig": {
"name": "Resign session token",
"id": "6f1c2a9e-3b7d-4e2a-9c51-2d8f0b7a4e10",
"generator": [
{
"filters": { "filters": [{ "include": true, "optUrl": "/graphql" }] },
"extractor": { "type": "req_body" },
"transforms": [
{ "type": "graphql", "config": { "path": "variables.token" } },
{ "type": "jwt_resign", "config": { "key": "speedscale-demo-secret" } }
]
}
]
}
}
Put it in proxymock/blueprints/resign-session-token.json. The generator chain rewrites what replay sends. It is scoped by URL path because, once proxymock reads a request as GraphQL, its endpoint is the operation name, not /graphql. jwt_resign keeps the token’s claims and algorithm and moves its expiry forward. For simpler fixes you do not need to write JSON: proxymock web’s GraphQL panel puts a transform button on every variable and argument, to replace a value, store it for a later request, or smart-replace it.
proxymock replay --in proxymock/recorded-<timestamp> --test-against http://localhost:8080 \
--require-blueprint 'Resign session token'
Blueprint "Resign session token": 1 transform chain(s) ran.
Replay verdict: proxymock/results/replayed-<timestamp>/replay-verdict.json (pass)
The server logs graphql op="Session" ok, in every edition. --require-blueprint fails the run if the chain never fires, so a renamed variable cannot quietly turn this test back into a status check.
Step 4: Mock the API for its client
Now flip the roles: the driver is a client, and the GraphQL API is the dependency it calls. Record the driver’s calls through a mapped port while the server runs:
Java
proxymock record --map 8081=http://localhost:8080 -- \
java -jar target/graphql-demo.jar -drive -target http://localhost:8081Node.js
proxymock record --map 8081=http://localhost:8080 -- \
node graphql-demo.js -drive -target http://localhost:8081Go
proxymock record --map 8081=http://localhost:8080 -- \
./graphql-demo -drive -target http://localhost:8081Python
proxymock record --map 8081=http://localhost:8080 -- \
.venv/bin/python graphql_demo.py -drive -target http://localhost:8081The recorder stops when the driver exits. Stop the server and start the mock:
Java
proxymock mock --in proxymock/recorded-<timestamp> --map 8081=http://localhost:8080 --no-passthrough -- \
java -jar target/graphql-demo.jar -drive -target http://localhost:8081Node.js
proxymock mock --in proxymock/recorded-<timestamp> --map 8081=http://localhost:8080 --no-passthrough -- \
node graphql-demo.js -drive -target http://localhost:8081Go
proxymock mock --in proxymock/recorded-<timestamp> --map 8081=http://localhost:8080 --no-passthrough -- \
./graphql-demo -drive -target http://localhost:8081Python
proxymock mock --in proxymock/recorded-<timestamp> --map 8081=http://localhost:8080 --no-passthrough -- \
.venv/bin/python graphql_demo.py -drive -target http://localhost:8081The mapped port matters because Go’s and Java’s HTTP clients never send localhost through a proxy, so the port is how proxymock gets in between. Node.js and Python’s urllib do use the proxy proxymock sets up for them, so the same commands work there too, and the recording simply holds each call twice, once at each port. With the server stopped, the driver fails on its first request:
Java
2026/10/26 19:32:02 pass 1: Session: decoding response: No content to map due to end-of-inputNode.js
2026/10/26 19:31:27 pass 1: Session: decoding response: Unexpected end of JSON inputGo
2026/10/26 19:31:03 pass 1: Session: decoding response: EOFPython
2026/10/26 19:31:44 pass 1: Session: decoding response: Expecting value: line 1 column 1 (char 0)A GraphQL mock matches on the parsed document and its variables. Whitespace and argument order do not matter. Selection order does, because it is the order of the fields in the response. And every run of this driver signs a new JWT and draws a new request id, so its Session query never matches the one recorded.
proxymock’s mock recommender finds the fields that drifted between the request and its nearest recording:
1. POST /graphql — service responder, 1 request(s)
- id: responder|body:variables.requestId
fix: http.req.bodyBase64.variables.requestId -> constant
- id: responder|body:variables.token
fix: http.req.bodyBase64.variables.token -> constant
It recommends masking variables.token and variables.requestId, and nothing else. It never masks the query or the operation name, because those say which operation a request is; masking them would let any operation match any other. Accept both with Apply all recommended in proxymock web’s Mocks view, or ask your coding assistant to run the proxymock MCP server’s mocks tool, and run the driver again. It gets past Session and stops at CreateUser, whose email address is generated per run. One more accepted fix, and the third run completes:
2026/10/26 19:31:05 pass 1 complete: 7 GraphQL operations + APQ + REST negative
All nine calls are answered from the recording, with no server running, in all four languages. The fixes live in a blueprint in the workspace, so the next run, a teammate’s run and CI all get them.
Step 5: See what the recording covers
The server was stopped in step 4. Start it again with the server command from What you need so proxymock can introspect its schema. Use the inbound recording from step 2 for --in here and in step 6, rather than the outbound recording from step 4.
proxymock coverage --spec http://localhost:8080/graphql --in proxymock/recorded-<timestamp>
cached the introspection result at proxymock/recorded-2026-10-26_21-19-54.625402Z/localhost_8080.introspection.json
Recording/schema coverage: 9 inbound HTTP pair(s), 7 matched
Covered: 6/6 operations, 6/12 statuses, 23/27 fields
POST query.user: 2 pair(s)
status 200: 2 pair(s)
$.data.user: 2 pair(s)
$.data.user.email: 2 pair(s)
$.data.user.id: 2 pair(s)
$.data.user.plan: 1 pair(s)
$.data.user.tags: 0 pair(s)
status 200+errors: 0 pair(s)
...
Unknown routes: 2
POST /graphql (200): persisted query without query text
POST /api/search (200): not a GraphQL request
Ranked gaps:
1. status POST mutation.createUser (200+errors)
2. status POST mutation.trackEvent (200+errors)
...
By URL, this recording is one endpoint, fully covered. By operation, every query and mutation was reached, but the statuses line tells the real story: 200+errors, a 200 whose body carries errors, was never recorded for any of the six. The recording proves the happy path and nothing about how the API fails. Those gaps rank first, because they are what the next recording should add.
Fields are counted under their schema names, so the driver’s me: user(...) alias covers $.data.user, and fields selected through fragments count like any other. The two unknown routes are the persisted query and the REST negative case, listed rather than silently dropped. The --json flag prints JSON to standard output. Redirect it to keep a report beside the recording and compare it over time:
proxymock coverage --spec http://localhost:8080/graphql --in proxymock/recorded-<timestamp> --json \
> proxymock/recorded-<timestamp>/coverage.json
Step 6: Catch the schema change
A GraphQL server validates its own responses against its own schema, so how can a response ever disagree with a schema? When the schema you test against is not the one the server was built from: the schema.graphql a frontend team codes against, the next version in a pull request, or a dependency you do not own.
Say someone changes Plan.price from Int! to String!, to carry a currency, while the deployed server still sends integers. Make that change in a copy of the introspection result:
jq '(.data.__schema.types[] | select(.name == "Plan") | .fields[] | select(.name == "price") | .type.ofType.name) = "String"' \
proxymock/recorded-<timestamp>/localhost_8080.introspection.json > schema-v2.json
proxymock validate --spec ./schema-v2.json --in proxymock/recorded-<timestamp>
Against the live schema the same recording is 7 conformant and exits 0. Against the changed one:
VIOLATION POST query.search (200) [...]
$.data.search[1].price: type mismatch, expected String, got integer (49)
VIOLATION POST query.plans (200) [...]
$.data.plans[0].price: type mismatch, expected String, got integer (0)
$.data.plans[1].price: type mismatch, expected String, got integer (49)
$.data.plans[2].price: type mismatch, expected String, got integer (499)
checked 7 pair(s): 5 conformant, 2 violating, 0 without a spec route
The command exits 2. Look at the first violation. Nobody asked search for a price field directly: it came from an inline fragment, ... on Plan { name price }, inside a union. A search of the client code for plans would miss it. validate finds it because it does not read responses as loose JSON. It parses each recorded request’s document, resolves the selection against the schema, aliases and fragments included, and walks the response alongside it. Along the way it also flags null in a non-null field, a value outside its enum, a selected field missing from the response, and a field nobody selected.
The exit codes are made for CI: 0 conformant, 2 violations, 3 an operation whose root field is not in the schema, and 1 when the check cannot run. Point --spec at a URL and it uses the cached introspection when the server is down, so the check still runs on a build agent.
What does not work yet
- Subscriptions.
generatelists them as skipped, and a recorded subscription is not checked as one. - Requests that are not a JSON POST. A query in the URL, a batched array of operations, and an
application/graphqlbody are recorded and replayed as sent, but not read as operations. - Other paths. proxymock recognizes GraphQL by a path ending in
/graphql. An API served elsewhere still records, mocks and replays as JSON over HTTP, without the per-operation view. - Generated mocks answer one document. A mock generated from the schema matches the generated selection and variables. A client that asks for different fields gets no match. Record the client, or edit the generated request to match.
From an AI coding assistant
The same flow runs through the proxymock MCP server. Its generate tool takes a schema file or endpoint URL, its mocks tool runs the analyze and accept loop from step 4, and proxymock inventory finds .graphql files and introspection results in a repository and suggests this path when a service has no recorded traffic.
Next steps
- GraphQL semantic paths is the reference for addressing values inside a request.
- GraphQL vs gRPC compares the two when you are still choosing, and the gRPC version of this walkthrough does the same from a
.proto. - Point
proxymock coverageat your own recording and schema, and see how many of your operations have ever been recorded failing.