Skip to content

GraphQL load testing

GraphQL APIs receive their requests as HTTP POST requests with a JSON body containing the operation and variables. From a load-testing perspective, GraphQL is a specialization of HTTP — you can use the same Taurus native HTTP executor or k6 that you use for REST, with a few GraphQL-specific patterns.

Every GraphQL operation is a POST request to a single endpoint (typically /graphql) with a JSON body:

{
"query": "query GetProduct($id: ID!) { product(id: $id) { name price stock } }",
"variables": { "id": "widget-01" },
"operationName": "GetProduct"
}

The response is always a 200 OK JSON envelope — even when the operation fails. GraphQL errors are in the errors field of the response body, not in the HTTP status code.

execution:
- concurrency: 50
ramp-up: 30s
hold-for: 5m
scenario: graphql-load
scenarios:
graphql-load:
default-address: https://api.example.com
headers:
Content-Type: application/json
Authorization: "Bearer ${GRAPHQL_TOKEN}"
requests:
- label: query-products
url: /graphql
method: POST
body: |
{
"query": "query ListProducts { products { id name price } }",
"operationName": "ListProducts"
}
assert:
- equals:
subject: http-code
value: '200'
- not-contains:
subject: body
value: '"errors"' # assert no GraphQL errors in response
- label: mutation-create-order
url: /graphql
method: POST
body: |
{
"query": "mutation CreateOrder($productId: ID!, $qty: Int!) { createOrder(productId: $productId, qty: $qty) { orderId status } }",
"variables": {"productId": "widget-01", "qty": 2},
"operationName": "CreateOrder"
}
assert:
- not-contains:
subject: body
value: '"errors"'

GraphQL always returns HTTP 200, even for errors. Add a not-contains assertion on "errors" in the response body to catch operation failures:

assert:
- not-contains:
subject: body
value: '"errors"'

This marks the request as failed in MaxoPerf metrics when the GraphQL server returns an error payload.

import http from 'k6/http';
import { check } from 'k6';
const GQL_URL = 'https://api.example.com/graphql';
const TOKEN = __ENV.GRAPHQL_TOKEN; // injected from MaxoPerf secrets
const LIST_PRODUCTS_QUERY = `
query ListProducts {
products {
id
name
price
}
}
`;
const CREATE_ORDER_MUTATION = `
mutation CreateOrder($productId: ID!, $qty: Int!) {
createOrder(productId: $productId, qty: $qty) {
orderId
status
}
}
`;
export const options = {
vus: 50,
duration: '5m',
};
function gql(query, variables = {}, operationName = '') {
return http.post(
GQL_URL,
JSON.stringify({ query, variables, operationName }),
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TOKEN}`,
},
}
);
}
export default function () {
// Query
const listRes = gql(LIST_PRODUCTS_QUERY, {}, 'ListProducts');
check(listRes, {
'list products status 200': (r) => r.status === 200,
'no graphql errors': (r) => !r.json('errors'),
});
// Mutation
const createRes = gql(CREATE_ORDER_MUTATION, { productId: 'widget-01', qty: 2 }, 'CreateOrder');
check(createRes, {
'create order status 200': (r) => r.status === 200,
'order id present': (r) => r.json('data.createOrder.orderId') !== null,
'no graphql errors': (r) => !r.json('errors'),
});
}

For data-driven GraphQL tests, use a CSV data entity in MaxoPerf to supply product IDs, user IDs, or other query variables per VU:

scenarios:
parameterized-graphql:
data-sources:
- path: product-ids.csv # uploaded as a Test asset; first column = productId
variable-names: productId
requests:
- label: query-product
url: /graphql
method: POST
body: |
{
"query": "query GetProduct($id: ID!) { product(id: $id) { name price } }",
"variables": {"id": "${productId}"},
"operationName": "GetProduct"
}

See Cookbook: CSV data-driven test for the full CSV setup workflow.

GraphQL results appear in the same MaxoPerf run-detail view as REST results. The key difference is that all operations go through the same endpoint URL (/graphql), so you must use request labels to distinguish operations in the per-endpoint breakdown:

  • In Taurus YAML: use the label: key on each request.
  • In k6: pass a name tag in the request params: http.post(url, body, { tags: { name: 'ListProducts' } }).

Without labels, all GraphQL operations appear as a single row in the per-endpoint panel.

Do:

  • Assert for "errors" in the GraphQL response body, not just HTTP status — GraphQL errors are 200 OK at the HTTP level.
  • Label each operation by its operationName so the MaxoPerf per-endpoint breakdown is meaningful.
  • Use query variables ($id: ID!) and pass them via variables: instead of string-interpolating values into the query body — this avoids query-parsing overhead on the server.

Don’t:

  • Mix queries and mutations in the same VU loop without understanding the write-side load you are generating — mutations change state and can exhaust data or create unbounded database growth.
  • Send introspection queries ({ __schema { types { name } } }) as part of a load test — disable introspection in production before running load tests against it.