mercurius-test
Wraps Mercurius (Fastify GraphQL plugin) testing patterns: `app.inject()` for HTTP-layer simulation without spinning up a network listener, plugin-registration setup (await app.register(mercurius, { schema, resolvers, graphiql: false })), production-config gates (graphiql: false; jit threshold; query depth limits via fastify-rate-limit + complexity), and the per-test app lifecycle (app.close() in afterEach). Use when writing tests for a Fastify + Mercurius GraphQL server. Composes introspection-attack-surface-reference for the production-safety gates.
mercurius-test
Overview
Per github.com/mercurius-js/mercurius, 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 introspection-attack-surface-reference:
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 introspection-attack-surface-reference |
| 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 |