Testland
Browse all skills & agents

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-tests
View source

graphql-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

  • Unit / integration tests for a Yoga-based GraphQL server.
  • Subscription tests (SSE / WS via Yoga).
  • Production-config gates for Yoga's plugin-based controls.

Authoring

Install

npm install --save-dev graphql-yoga @graphql-tools/executor-http

Basic 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.

  1. Build the server with createYoga({ schema, plugins: [useDisableIntrospection()] }) and a context-builder that reads Authorization.
  2. yoga.fetch { me { id } } with no header; parse .json() and assert result.errors is present - the context / auth middleware rejected it.
  3. yoga.fetch the same query with Authorization: Bearer ${testToken}; assert result.data.me.id is returned.
  4. yoga.fetch an { __schema { types { name } } } query; assert result.errors[0].message matches /introspection/i.

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 test

Production-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-patternWhy it failsFix
Skipping yoga.fetch and using HTTP server directlySlower; same coverageyoga.fetch is purpose-built
Asserting on Yoga's default error string "Unexpected error." everywhereMisses real errors that aren't maskedUse useMaskedErrors({ errorMessage: 'Sanitised' }) and assert per-test
Skipping useDisableIntrospection in prod testsProduction introspection silently enabledMirror prod plugin set in test
Persisted-operations plugin without explicit allowArbitraryOperations: falseAuto-bypass on unrecognised hashUse strict mode
Subscription tests with await response.json()SSE/multipart streams aren't JSONUse buildHTTPExecutor + async iterator
Stale schema in testSchema drifts; tests pass against old shapeRebuild schema per test file or use beforeAll

Limitations

  • No real HTTP server. Tests that depend on the underlying HTTP framework's behaviour (e.g., Node http quirks, h2-upgrade scenarios) need a real server with node-fetch or supertest.
  • Subscription transports. Yoga supports SSE by default; WebSocket subscriptions need graphql-ws integration with its own test harness.
  • Error masking complexity. useMaskedErrors interacts with every test that asserts on errors; understand the project's configuration.
  • File uploads. Yoga's multipart-upload spec testing needs FormData not JSON.

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

  • Unit / integration tests for a Mercurius-based GraphQL server.
  • Production-config gates: graphiql disabled, depth limits.
  • Fastify-specific middleware behaviour (auth, rate-limit, CORS).

Authoring

Install

npm install --save-dev fastify mercurius

Basic 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 test

Production-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 test

For 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-patternWhy it failsFix
graphiql: true in production code pathSchema disclosure via GraphiQLAlways graphiql: false in prod; gate per env
Sharing one app across testsPlugin state leaks; mutations persistPer-test buildApp + close
Skipping await app.close()Fastify keep-alive timers leak; CI hangsAlways close
app.inject with body fieldShould be payload; Fastify ignores body hereUse payload
Asserting on response.payload for non-JSON contentBuffer / stream responses need different handlingInspect headers['content-type'] first
No depth / complexity validationMercurius doesn't add these by defaultAdd validationRules per graphql-complexity-limit-tester
Testing on real listening portSlower; flaky parallel CIapp.inject is purpose-built
Mocking the schema instead of using itTests pass against a fake schema, not the real oneAlways test the real schema

Limitations

  • No subscription support in inject. Mercurius subscriptions use WebSockets; tests need a real listen + ws client.
  • JIT compilation off in test. Mercurius supports JIT via jit: 1 (compile after 1 invocation); the prod JIT path may optimise differently than tests.
  • No introspection-disable flag. Have to roll custom validation rules; not as ergonomic as Apollo's introspection: false.
  • Fastify plugin order matters. auth plugin must register before mercurius; test setup must match prod order or auth doesn't apply.

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.