graphql-yoga-tests
Tests a GraphQL Yoga server (the-guild.dev runtime) with `yoga.fetch()` for in-process, no-network request simulation of queries and mutations, `@graphql-tools/executor-http` for subscription and incremental-delivery (streaming) tests, auth-header pass-through, and production-config gates for disabled introspection and persisted operations; also carries the Mercurius (Fastify GraphQL plugin) in-process `app.inject()` testing patterns in references/mercurius.md. Use to test a GraphQL Yoga or Mercurius server, write query, mutation, or subscription tests, or check production plugin config; for other runtimes use apollo-server-tests or hasura-tests instead, not this skill.
Install with skills.sh (any agent)
npx skills add testland/qa --skill graphql-yoga-testsgraphql-yoga-tests
Overview
Per the-guild.dev/graphql/yoga-server/docs/features/testing (opens in new window): "Calling the yoga.fetch method does not send an actual HTTP request. It simulates the HTTP request which 100% conforms with how Request/Response work."
Structurally different from Apollo's executeOperation - Yoga's testing path goes through the HTTP transport layer including middleware, headers, and response codes. There is no separate "in-process" vs "HTTP-layer" choice.
When to use
Authoring
Install
npm install --save-dev graphql-yoga @graphql-tools/executor-httpBasic test
Per Yoga docs:
import { createYoga, createSchema } from 'graphql-yoga';
const yoga = createYoga({
schema: createSchema({
typeDefs: /* GraphQL */ `
type Query { greetings: String }
`,
resolvers: { Query: { greetings: () => 'Hello' } },
}),
});
test('greetings', async () => {
const response = await yoga.fetch('http://yoga/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: '{ greetings }' }),
});
const result = await response.json();
expect(result.data.greetings).toBe('Hello');
});The URL http://yoga/graphql is a placeholder - yoga.fetch doesn't make a network call, but the URL must parse.
HTTP executor for subscriptions
Per Yoga docs:
import { buildHTTPExecutor } from '@graphql-tools/executor-http';
import { parse } from 'graphql';
const executor = buildHTTPExecutor({ fetch: yoga.fetch });
const result = await executor({ document: parse(`{ greetings }`) });
expect(result.data.greetings).toBe('Hello');For subscriptions (SSE):
const stream = await executor({ document: parse(subscriptionQuery) });
if (Symbol.asyncIterator in stream) {
for await (const event of stream) {
expect(event.data).toBeDefined();
if (allEventsReceived) break;
}
}Auth header pass-through
const response = await yoga.fetch('http://yoga/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${testToken}`,
},
body: JSON.stringify({ query: '{ me { id } }' }),
});Yoga's context-builder runs against the simulated request, so auth middleware is exercised.
Worked example
Integration scenario combining the operations above in one file: verify a { me { id } } query requires a bearer token, and that introspection is off in the production plugin set.
Result: one file proves auth pass-through (tokenless rejected, valid token resolves) and the production introspection gate in-process, no network.
Running
Standard test commands
npm testProduction-config tests
Yoga's production gates - disable-introspection and persisted-operations - have their own plugin setup and strict-mode assertions. Full test patterns are in references/production-config-tests.md.
Parsing results
yoga.fetch returns a standard Response. Parse with .json() for non-streaming queries; iterate with the async-iterator for subscriptions.
The response shape:
{
"data": { "greetings": "Hello" },
"errors": [
{
"message": "...",
"path": ["greetings"],
"extensions": { "code": "..." }
}
]
}Yoga uses useMaskedErrors by default (per Yoga docs) - error messages are masked to "Unexpected error" in production unless the error has been marked safe. Test against this:
const resp = await yoga.fetch(/* ... */);
const result = await resp.json();
// Production: error message is "Unexpected error", original
// hidden in extensions if at all
expect(result.errors[0].message).toBe('Unexpected error.');CI integration
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm test
- name: Production-mode tests
env: { NODE_ENV: production }
run: npx jest tests/production-config/Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Skipping yoga.fetch and using HTTP server directly | Slower; same coverage | yoga.fetch is purpose-built |
Asserting on Yoga's default error string "Unexpected error." everywhere | Misses real errors that aren't masked | Use useMaskedErrors({ errorMessage: 'Sanitised' }) and assert per-test |
Skipping useDisableIntrospection in prod tests | Production introspection silently enabled | Mirror prod plugin set in test |
Persisted-operations plugin without explicit allowArbitraryOperations: false | Auto-bypass on unrecognised hash | Use strict mode |
Subscription tests with await response.json() | SSE/multipart streams aren't JSON | Use buildHTTPExecutor + async iterator |
| Stale schema in test | Schema drifts; tests pass against old shape | Rebuild schema per test file or use beforeAll |
Limitations
References
Mercurius (Fastify) testing
View source (opens in new window)Mercurius (Fastify) testing
Per github.com/mercurius-js/mercurius (opens in new window), the plugin is registered as await app.register(mercurius, { schema, resolvers }). Tests then submit POSTs via Fastify's app.inject() - the HTTP-layer simulator that runs requests through the full middleware stack without binding a port.
When to use
Authoring
Install
npm install --save-dev fastify mercuriusBasic test
import Fastify from 'fastify';
import mercurius from 'mercurius';
function buildApp() {
const app = Fastify();
app.register(mercurius, {
schema: `type Query { add(x: Int, y: Int): Int }`,
resolvers: {
Query: {
add: async (_, { x, y }) => x + y,
},
},
graphiql: false, // disable GraphiQL UI in tests
});
return app;
}
test('add', async () => {
const app = buildApp();
const response = await app.inject({
method: 'POST',
url: '/graphql',
payload: { query: '{ add(x: 2, y: 3) }' },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.data.add).toBe(5);
await app.close();
});Per the README's quickstart pattern, plugin registration takes schema + resolvers. The test pattern is inject then close.
Auth header tests
const response = await app.inject({
method: 'POST',
url: '/graphql',
headers: { authorization: `Bearer ${testToken}` },
payload: { query: '{ me { id } }' },
});Headers go through Fastify's middleware (e.g., fastify-jwt) exactly as in production.
Per-test app lifecycle
let app: ReturnType<typeof buildApp>;
beforeEach(() => { app = buildApp(); });
afterEach(async () => { await app.close(); });Per Fastify convention: rebuild + close per test to avoid plugin state contamination.
Running
npm testProduction-config tests
Per the introspection catalog in graphql-complexity-limit-tester (references/introspection.md):
test('graphiql disabled in production config', async () => {
const app = Fastify();
app.register(mercurius, {
schema, resolvers,
graphiql: false,
});
const resp = await app.inject({ method: 'GET', url: '/graphiql' });
expect(resp.statusCode).toBe(404);
await app.close();
});
test('introspection disabled', async () => {
// Mercurius doesn't have a direct introspection flag; use
// mercurius-validation or a custom validator that rejects
// introspection AST nodes
const app = Fastify();
app.register(mercurius, {
schema, resolvers,
graphiql: false,
validationRules: [rejectIntrospectionRule],
});
const resp = await app.inject({
method: 'POST',
url: '/graphql',
payload: { query: '{ __schema { types { name } } }' },
});
const body = JSON.parse(resp.body);
expect(body.errors).toBeDefined();
await app.close();
});Query-complexity / depth limiting
Use graphql-validation-complexity or graphql-depth-limit as a validation rule passed via Mercurius config:
import depthLimit from 'graphql-depth-limit';
app.register(mercurius, {
schema, resolvers,
graphiql: false,
validationRules: [depthLimit(5)],
});Test:
test('depth limit', async () => {
const deep = '{ user { friends { friends { friends { friends { friends { id }}}}}}}';
const resp = await app.inject({ method: 'POST', url: '/graphql', payload: { query: deep } });
const body = JSON.parse(resp.body);
expect(body.errors[0].message).toMatch(/maximum operation depth/i);
});Parsing results
app.inject() returns a Light My Request response:
{
statusCode: 200,
body: '{"data":...}', // string, not parsed
headers: { ... },
payload: '{"data":...}', // alias for body
}Always JSON.parse(response.body) for GraphQL responses.
The GraphQL response shape is standard:
{
"data": { ... },
"errors": [{ "message": "...", "path": [...], "extensions": {...} }]
}CI integration
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm testFor multi-process / cluster-mode tests, Fastify's inject won't exercise cluster behaviour - use a real app.listen({ port: 0 }) for those cases.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
graphiql: true in production code path | Schema disclosure via GraphiQL | Always graphiql: false in prod; gate per env |
Sharing one app across tests | Plugin state leaks; mutations persist | Per-test buildApp + close |
Skipping await app.close() | Fastify keep-alive timers leak; CI hangs | Always close |
app.inject with body field | Should be payload; Fastify ignores body here | Use payload |
Asserting on response.payload for non-JSON content | Buffer / stream responses need different handling | Inspect headers['content-type'] first |
| No depth / complexity validation | Mercurius doesn't add these by default | Add validationRules per graphql-complexity-limit-tester |
| Testing on real listening port | Slower; flaky parallel CI | app.inject is purpose-built |
| Mocking the schema instead of using it | Tests pass against a fake schema, not the real one | Always test the real schema |
Limitations
References
Production-config tests
View source (opens in new window)Production-config tests
Mirror the production plugin set in test so prod-only gates are exercised. Run these under NODE_ENV=production in CI.
Introspection disabled
Per the introspection catalog in graphql-complexity-limit-tester (references/introspection.md):
import { useDisableIntrospection } from '@graphql-yoga/plugin-disable-introspection';
test('introspection disabled', async () => {
const yoga = createYoga({
schema,
plugins: [useDisableIntrospection()],
});
const resp = await yoga.fetch('http://yoga/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: '{ __schema { types { name } } }' }),
});
const result = await resp.json();
expect(result.errors).toBeDefined();
expect(result.errors[0].message).toMatch(/introspection/i);
});Persisted-operations test
Per the persisted-query catalog in graphql-complexity-limit-tester (references/persisted-queries.md), Mode 2:
import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations';
const operations = {
'abcdef': '{ greetings }',
};
test('rejects unregistered hash in strict mode', async () => {
const yoga = createYoga({
schema,
plugins: [
usePersistedOperations({
getPersistedOperation: (key) => operations[key],
}),
],
});
const resp = await yoga.fetch('http://yoga/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
extensions: {
persistedQuery: { version: 1, sha256Hash: 'unknown' },
},
}),
});
expect(resp.status).toBe(404); // Yoga's default for unknown
});Related skills
apollo-server-tests
Wraps Apollo Server testing patterns: `server.executeOperation()` (in-process, no HTTP), `supertest` against an ephemeral-port HTTP server (port 0), context injection via the `contextValue` second-argument, and assertion patterns for response shape + errors. Includes the production-config gates testable through this skill - introspection-disabled, persisted-query mode, hideSchemaDetailsFromClientErrors. Use when writing tests for an Apollo Server v4+ GraphQL service.
graphql-complexity-limit-tester
The GraphQL attack-surface / hardening skill: crafts over-limit depth and complexity queries then asserts rejection before execution, verifying that graphql-depth-limit, graphql-cost-analysis, and graphql-armor (max-depth / cost-limit / max-tokens plugins) are actually enforced and not just configured; carries the introspection attack-surface catalog (what __schema exposes, per-framework disable controls, hideSchemaDetailsFromClientErrors) in references/introspection.md and the persisted-query allowlisting strategies (Apollo APQ protocol, auto-register vs strict-allowlist vs hybrid modes) in references/persisted-queries.md. Use when auditing a GraphQL service for DoS or schema-disclosure exposure, hardening a production deployment, or adding tests that prove the limits in CI.
graphql-n-plus-one-remediation
Detects and fixes the GraphQL N+1 pattern: scans a repo, PR diff, or schema type for list-returning resolvers (grep-driven detection workflow), traces the resolver tree to locate the fan-out (one parent query returns N rows, then a child field resolver fires once per row), classifies every child field resolver as safe or N+1 risk, and applies one of three fixes: per-request DataLoader batching, eager projection in the parent resolver, or selection-set-aware prefetch. Use when reviewing a PR that adds or changes a list-returning resolver, when a connection-pool exhaustion or slow-query alert traces back to GraphQL traffic, or when a resolver trace shows a child field resolved once per parent row.
graphql-subscription-test-author
Authors GraphQL subscription resolver test suites over graphql-ws (WebSocket) and graphql-sse (Server-Sent Events) transports: subscribe to event streams via the async-iterator API, assert emitted data shape and sequence, verify connection lifecycle and protocol close codes, and validate auth-on-connect (connectionParams / authenticate callback) plus resolver-level pubsub trigger logic. Use for real-time subscription operations; not for queries or mutations - for those use apollo-server-tests or graphql-yoga-tests (Mercurius in its references).
hasura-tests
Wraps Hasura GraphQL Engine testing patterns: docker-compose test instance, the metadata API for declarative schema/permission setup, x-hasura-role and x-hasura-user-id session headers for role-based permission tests, the v1/graphql endpoint via curl/HTTPie/native HTTP clients, and the role-by-table-by-operation permission-matrix pattern. Use for a metadata-driven Hasura engine where row-level permissions dominate; for a code-first server runtime harness use graphql-yoga-tests (Mercurius in its references) or apollo-server-tests instead, not this skill.