Core
GraphQL load testing
perfscale drives GraphQL endpoints under load with the native step engine: one operation per step, queries and mutations over HTTP POST (or opt-in GET), with the query validated against the endpoint's own schema before it is ever sent.
No codegen and no client stubs: the document is plain text in the YAML (or a
.graphql file), variables are JSON, and responses come back as structured
data/errors the next steps extract from with ${{ … }} interpolation.
This page is the guide. Per-step parameters and outputs live in the
actions reference; a runnable scenario ships as
examples/graphql.test.yaml.
One action: std/graphql@v1
steps:
- name: fetch viewer
use: std/graphql@v1
with:
url: https://api.example.com/graphql
query: |
query GetViewer($id: ID!) {
viewer(id: $id) { id name }
}
variables: { "id": "${{ vars.viewer_id }}" }
check:
status: 200
outputs: viewer
- name: rename widget
use: std/graphql@v1
with:
url: https://api.example.com/graphql
query: |
mutation Rename($id: ID!, $name: String!) {
renameWidget(id: $id, name: $name) { id }
}
variables: { "id": "${{ viewer.data.viewer.id }}", "name": "w-${seq}" }
queryis inline (multiline YAML reads well);query_filepoints at a.graphqlfile instead — the two are mutually exclusive.variablesis a JSON object.${{ … }}interpolation applies everywhere inwith:, so values extracted from earlier steps flow straight in, and single-brace${…}generator tokens (${uuid},${rand},${now}) expand per execution — every iteration can send a fresh name or id.- A document with several operations needs
operation: GetViewerto pick one — the sameoperationNamethe server receives. method: GETmoves the operation into URL query parameters for CDN-cacheable reads. Mutations stay on POST (the default).
Schema validation
Every query is parsed before it is sent, and — when a schema is available —
validated against it. A typo fails the step (and perfscale lint) with a
did-you-mean suggestion instead of burning requests against the target:
fetch viewer: query validation failed: unknown field 'viewr' on type 'Query' — did you mean 'viewer'?
Two schema sources:
- Introspection (default) — the engine POSTs an introspection query to the endpoint once per run, caches the schema process-wide, and validates every query against it. One round trip per endpoint, no matter how many VUs.
schema_file: schema.graphql— validate against a local SDL file. This is the fallback for endpoints that refuse introspection (common in production): when introspection fails and noschema_fileis given, the step still runs — unvalidated, with one[sys]log line saying so.introspection: falseopts out of fetching entirely.
perfscale lint applies the same gate: syntax always (offline), schema when
the endpoint is reachable or an SDL file is given. perfscale lint --offline
skips the network pass.
What counts as failure
GraphQL errors travel in a 200 OK body, so HTTP status alone is not the
verdict:
- HTTP status ≥ 400 → the step fails.
errorspresent,datanull/absent → the step fails (nothing resolved).- partial
datapluserrors→ the step passes — the server resolved what it could — and the errors are counted ingraphql_errors.
Standard check: assertions work unchanged: status, duration_ms_lt,
body_contains (against the raw body). Field-level assertions read the
decoded payload via a std/check@v1 step:
- name: viewer id present
use: std/check@v1
with:
on: viewer.data.viewer
message_contains: "id" # …or assert via outputs interpolation
Metrics
graphql_req_duration— histogram of every operation's round trip; the runner derivesgraphql_req_failed(rate). Gate on it withstd/thresholds@v1:"graphql_req_duration": ["p99<200"].graphql_errors— counter of GraphQL-level errors, including the partial-data ones that pass the step.graphql_op_<operationName>_duration— per-operation histogram, emitted only when the operation is named (explicitoperationor a single named operation), so metric cardinality stays bounded by the test definition.- The request also feeds the standard
http_req_duration/http_req_failed/http_reqsaggregates, like any HTTP step.
Connection pooling
pool: per-vu (default) pins the step to the VU's HTTP client shard — the
same keep-alive behaviour as std/http@v1, so a VU reuses its warm
connections across iterations. pool: shared puts every VU on one
process-global client: maximal connection reuse against a single endpoint, at
the cost of pool-lock contention under very high VU counts.
Limits
- Subscriptions are not supported (the transport is HTTP request/response); a document containing one fails validation.
- Request batching (arrays of operations) and incremental delivery
(
@defer/@stream) are not supported — one operation per step. query_fileandschema_fileare filesystem access: they requireallow_file_actionsin the run config and honourfs_rootconfinement.