Testing GraphQL Persisted Queries and Automatic Persisted Queries (APQ)
GraphQL persisted queries reduce payload size and improve security by replacing full query strings with short hash identifiers. But they introduce new failure modes — hash mismatches, cache invalidation bugs, and APQ negotiation failures — that require dedicated testing strategies.
This guide covers everything you need to test persisted queries and APQ implementations reliably.
What Are Persisted Queries?
Standard GraphQL sends the full query string on every request:
{
"query": "query GetUser($id: ID!) { user(id: $id) { name email } }",
"variables": { "id": "123" }
}With persisted queries, you register queries ahead of time and send only their hash:
{
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9aca10d700549df22f1ae1dac"
}
},
"variables": { "id": "123" }
}The server looks up the hash and executes the stored query. If the hash isn't found, the client falls back to sending the full query (APQ negotiation).
APQ Negotiation Flow
Automatic Persisted Queries (APQ) follow a two-step protocol:
- First request — client sends hash only, no query string
- If hash unknown — server returns
PersistedQueryNotFounderror - Second request — client resends with both hash AND full query string
- Server stores the query, executes it, returns result
- Subsequent requests — hash-only works
This flow is critical to test because failures at any step silently degrade to full-query mode, defeating the performance benefit.
Setting Up Test Infrastructure
Test Client with APQ Support
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';
const persistedQueryLink = createPersistedQueryLink({ sha256 });
const httpLink = new HttpLink({ uri: 'http://localhost:4000/graphql' });
const client = new ApolloClient({
link: persistedQueryLink.concat(httpLink),
cache: new InMemoryCache(),
});Direct HTTP Testing (No Client Library)
For lower-level tests, use raw HTTP to inspect APQ negotiation:
import crypto from 'crypto';
function hashQuery(query) {
return crypto.createHash('sha256').update(query).digest('hex');
}
async function sendPersistedQuery(hash, query = null, variables = {}) {
const body = {
extensions: {
persistedQuery: { version: 1, sha256Hash: hash }
},
variables,
};
if (query) body.query = query;
const res = await fetch('http://localhost:4000/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return res.json();
}Core Test Cases
Test 1: APQ Full Negotiation Flow
Verify the complete two-step APQ handshake works end-to-end:
describe('APQ Negotiation', () => {
const GET_USER_QUERY = `
query GetUser($id: ID!) {
user(id: $id) { id name email }
}
`;
const queryHash = hashQuery(GET_USER_QUERY);
it('should complete APQ negotiation in two requests', async () => {
// Step 1: Send hash only — expect PersistedQueryNotFound
const firstResponse = await sendPersistedQuery(queryHash, null, { id: '1' });
expect(firstResponse.errors).toBeDefined();
expect(firstResponse.errors[0].message).toBe('PersistedQueryNotFound');
expect(firstResponse.errors[0].extensions.code).toBe('PERSISTED_QUERY_NOT_FOUND');
// Step 2: Send hash + full query — expect successful registration and execution
const secondResponse = await sendPersistedQuery(queryHash, GET_USER_QUERY, { id: '1' });
expect(secondResponse.errors).toBeUndefined();
expect(secondResponse.data.user).toBeDefined();
expect(secondResponse.data.user.id).toBe('1');
});
it('should serve from cache on third request', async () => {
// After negotiation, hash-only should work
const cachedResponse = await sendPersistedQuery(queryHash, null, { id: '1' });
expect(cachedResponse.errors).toBeUndefined();
expect(cachedResponse.data.user).toBeDefined();
});
});Test 2: Hash Verification
The server must reject requests where the hash doesn't match the query:
describe('Hash Verification', () => {
it('should reject mismatched hash and query', async () => {
const REAL_QUERY = `query { users { id name } }`;
const DIFFERENT_QUERY = `query { products { id title } }`;
const wrongHash = hashQuery(DIFFERENT_QUERY);
const response = await sendPersistedQuery(wrongHash, REAL_QUERY);
// Server should detect hash/query mismatch
expect(response.errors).toBeDefined();
expect(response.errors[0].extensions.code).toBe('PERSISTED_QUERY_NOT_FOUND');
// Or a specific validation error depending on implementation
});
it('should reject tampered hash', async () => {
const query = `query { me { id } }`;
const tamperedHash = 'a'.repeat(64); // Not the real hash
const response = await sendPersistedQuery(tamperedHash, query);
expect(response.errors).toBeDefined();
});
});Test 3: Cache Invalidation After Schema Changes
After a schema update, cached queries that reference removed fields should fail gracefully:
describe('Cache Invalidation', () => {
it('should handle removed field after schema update', async () => {
// Register a query that uses a field that will be "removed"
const queryWithDeprecatedField = `
query GetUser($id: ID!) {
user(id: $id) { id name legacyUsername }
}
`;
const hash = hashQuery(queryWithDeprecatedField);
// Register it
await sendPersistedQuery(hash, queryWithDeprecatedField, { id: '1' });
// Simulate what happens when the query is served but field doesn't exist
const response = await sendPersistedQuery(hash, null, { id: '1' });
// Should get a GraphQL field error, not a network error
if (response.errors) {
expect(response.errors[0].path).toBeDefined();
expect(response.errors[0].extensions?.code).not.toBe('PERSISTED_QUERY_NOT_FOUND');
}
// Partial data is acceptable if other fields resolve
});
});Test 4: Pre-Registered Queries (Static Manifest)
Many teams pre-register queries in a manifest file for enhanced security (only allow-listed queries execute):
// queries-manifest.json
// {
// "operations": {
// "GetUser": { "id": "ecf4edb4...", "body": "query GetUser..." }
// }
// }
describe('Static Query Allow-list', () => {
it('should execute pre-registered query by hash', async () => {
const manifest = JSON.parse(fs.readFileSync('./queries-manifest.json', 'utf8'));
const op = manifest.operations.GetUser;
const response = await sendPersistedQuery(op.id, null, { id: '1' });
expect(response.errors).toBeUndefined();
expect(response.data).toBeDefined();
});
it('should reject arbitrary query not in allow-list', async () => {
const arbitraryQuery = `query { __schema { types { name } } }`;
const hash = hashQuery(arbitraryQuery);
// First, try with full query (APQ registration attempt)
const response = await sendPersistedQuery(hash, arbitraryQuery);
// In strict allow-list mode, this should be rejected
expect(response.errors).toBeDefined();
// Error code varies by implementation
expect(['PERSISTED_QUERY_NOT_FOUND', 'FORBIDDEN', 'BAD_USER_INPUT'])
.toContain(response.errors[0].extensions?.code);
});
});Performance Testing
One of the main reasons for persisted queries is bandwidth reduction. Verify this works:
describe('Payload Size Reduction', () => {
it('should reduce request payload size', async () => {
const largeQuery = `
query GetDashboardData($userId: ID!, $orgId: ID!) {
user(id: $userId) {
id name email avatar
organization { id name plan }
recentActivity(limit: 10) {
id type createdAt
details { key value }
}
}
organization(id: $orgId) {
members { id name role }
projects { id title status }
}
}
`;
const hash = hashQuery(largeQuery);
const variables = { userId: '1', orgId: 'org-1' };
// Register the query
await sendPersistedQuery(hash, largeQuery, variables);
// Measure payload sizes
const fullQueryPayload = JSON.stringify({ query: largeQuery, variables });
const persistedPayload = JSON.stringify({
extensions: { persistedQuery: { version: 1, sha256Hash: hash } },
variables,
});
console.log('Full query bytes:', fullQueryPayload.length);
console.log('Persisted query bytes:', persistedPayload.length);
expect(persistedPayload.length).toBeLessThan(fullQueryPayload.length);
});
});Security Testing
Introspection via Persisted Queries
If your API disables introspection in production but persisted queries bypass that check:
describe('Security', () => {
it('should apply security rules to persisted queries', async () => {
const introspectionQuery = `
query IntrospectionQuery {
__schema {
queryType { name }
types { name kind }
}
}
`;
const hash = hashQuery(introspectionQuery);
// Try to register and execute introspection
const registerResponse = await sendPersistedQuery(hash, introspectionQuery);
if (registerResponse.errors) {
// APQ registration rejected — good
expect(registerResponse.errors[0].message).toMatch(/introspection|forbidden/i);
} else {
// If registered, subsequent calls should also be blocked or return same result
const executionResponse = await sendPersistedQuery(hash);
expect(executionResponse).toEqual(registerResponse);
}
});
});Testing with Apollo Studio / Client Tooling
When using Apollo Client's createPersistedQueryLink, integration tests look like standard Apollo tests but APQ happens transparently:
import { render, screen, waitFor } from '@testing-library/react';
import { MockedProvider } from '@apollo/client/testing';
import { GET_USER } from './queries';
// For unit tests — mock the response, APQ handled by link
const mocks = [{
request: {
query: GET_USER,
variables: { id: '1' },
},
result: {
data: { user: { id: '1', name: 'Alice' } },
},
}];
test('UserProfile renders with persisted query', async () => {
render(
<MockedProvider mocks={mocks} addTypename={false}>
<UserProfile userId="1" />
</MockedProvider>
);
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
});For E2E tests that validate the actual APQ negotiation, intercept network requests with Playwright:
import { test, expect } from '@playwright/test';
test('APQ negotiation happens on first page load', async ({ page }) => {
const graphqlRequests = [];
page.on('request', req => {
if (req.url().includes('/graphql')) {
graphqlRequests.push(JSON.parse(req.postData() || '{}'));
}
});
await page.goto('/profile/1');
await page.waitForSelector('[data-testid="user-name"]');
// Check that APQ was used
const persistedRequests = graphqlRequests.filter(
r => r.extensions?.persistedQuery
);
expect(persistedRequests.length).toBeGreaterThan(0);
// First request should not have query string (hash-only attempt)
const firstPersisted = persistedRequests[0];
expect(firstPersisted.query).toBeUndefined();
});Common Bugs to Test For
| Bug | Test Approach |
|---|---|
| Server stores query but doesn't validate hash | Submit wrong hash with full query, verify rejection |
| APQ fallback disabled | Check PersistedQueryNotFound triggers resend correctly |
| Cache not shared across server instances | Run negotiation on instance A, test hash-only on instance B |
| Stale cache after query updates | Deploy new query version, verify old hash still works or fails cleanly |
| Security bypass via APQ | Test that allow-list enforcement applies to APQ-registered queries |
Checklist
Before shipping a persisted query implementation:
- APQ two-step negotiation completes successfully
- Hash mismatch is rejected with proper error code
- Pre-registered queries execute from manifest
- Arbitrary queries rejected in allow-list mode
- Security rules (rate limits, auth, introspection block) apply to persisted queries
- Cache survives server restart (if using Redis/external cache)
- Payload size reduction is measurable and significant
- E2E tests verify APQ headers in real network requests
Persisted queries are a small protocol extension with surprisingly large attack surface. Testing both the happy path and the security properties ensures you get the performance benefits without introducing new vulnerabilities.