TL;DR
GraphQL + AI is a collision of flexible schema language and non-deterministic outputs. Standard GraphQL testing breaks. You need schema validation for dynamic AI responses, subscription testing for streaming, resolver strategies for hallucinations, query complexity limits, and rate limiting. We map the problem space and give you a testing framework to handle it.
Our GraphQL API serves LLM-powered search results. The schema looked reasonable:
type Query {
searchWithAI(query: String!): SearchResult
}
type SearchResult {
results: [Item!]!
summary: String!
relevance: Float!
}
Then we shipped a feature where the AI generates a summary in real time. Client code broke. Developers began reporting: "The summary field is sometimes null, sometimes empty string, sometimes 10,000 tokens. How am I supposed to handle this?"
They'd found the central truth of GraphQL + AI testing: Your schema promises structure. Your AI guarantees chaos. These forces are incompatible.
This post maps the problem space and gives you patterns to test your way through it.
Why GraphQL + AI Is Different
Let's be precise about what breaks:
Standard GraphQL assumptions: Queries are deterministic. If you ask for `user.email`, you always get an email or null, never a hallucinated email. The schema is a contract. Testing focuses on resolver correctness and schema compliance.
AI-powered GraphQL: Queries are non-deterministic. The same query returns different results (summaries, classifications, generated content) on every execution. The schema is more of a guidance than a contract, "this field is probably a string, but could be weirdly formatted or outside expected bounds."
This breaks three testing pillars:
- Determinism: You can't test "does this return X?" You test "does this return something reasonable?"
- Latency: AI endpoints are slow (2-30s). Standard GraphQL load testing assumes sub-second resolvers.
- Resource constraints: LLM calls have quota limits, cost-per-call, token budgets. One malicious query can drain your entire budget.
Let's tackle each.
Problem 1: Schema Validation for Dynamic Outputs
Your schema says a field is a String. The AI returns a string. Great. But what if:
- The string is 50,000 tokens (was supposed to be a summary)
- It contains embedded HTML or JavaScript
- It's in the wrong language
- It violates your content policy
- It references data that doesn't exist
The schema validation passed. Real-world testing fails.
Solution: Multi-layer validation.
GraphQL schema is layer 1 (type checking). Add layers 2-4:
Layer 2: Semantic Validation
After GraphQL type checking passes, validate that the AI output makes semantic sense. For a summary field:
- Length is between 50-500 characters (not 50,000)
- Doesn't start with "I'm an AI" or other prompt artifacts
- References entities that actually exist in the source data
- Passes basic language quality checks (no HTML/JS injection)
Implement as a post-resolver middleware that wraps every AI-powered field.
Layer 3: Policy Validation
Does the output violate your policies?
- PII leakage (did the AI accidentally expose a user's address?)
- Toxicity (is the summary mean-spirited?)
- Hallucination markers (does it claim facts not in source data?)
Use a separate LLM or ML classifier to audit outputs. Expensive but worth it for high-risk fields.
Layer 4: Determinism Testing (The Tricky One)
Run the same query 5-10 times. Collect outputs. Measure:
- How much variance is there? (Is it always wildly different?)
- Do all outputs pass layers 2-3?
- Is there a subset of outputs that seem obviously wrong?
Don't test for exact determinism (you won't get it). Test for "reasonable variance." If 1 in 10 outputs is garbage, you have a problem.
Testing pattern:
// Pseudo-code for determinism validation
const iterations = 10;
const outputs = [];
for (let i = 0; i < iterations; i++) {
const result = await graphql(query);
outputs.push(result.summary);
// Layer 2: Semantic check
assert(result.summary.length > 50 && result.summary.length < 500);
// Layer 3: Policy check
const toxicity = await classifier.score(result.summary);
assert(toxicity < 0.3);
}
// Layer 4: Variance analysis
const uniqueOutputs = new Set(outputs);
assert(uniqueOutputs.size > 2); // Some variety, but not pure randomness
assert(outputs.filter(x => isGarbage(x)).length < 2); // < 20% garbage rate
This isn't perfect, but it's pragmatic. You're measuring confidence, not guarantees.
Problem 2: Subscription Testing for Streaming Responses
Many AI integrations stream responses back to clients (token-by-token for LLMs, bounding box updates for vision models). GraphQL subscriptions are the right pattern here, but they break most testing tools.
The challenge: Standard GraphQL test frameworks (Apollo Client, graphql-request) assume queries terminate. Subscriptions are open-ended. How do you test something that streams?
Solution: Streaming validation framework.
Testing Strategy for Subscriptions
Instead of "does the final result match?", think "does the stream behave well?"
- Connection resilience: Does the subscription survive network flaps? (You need backoff + retry logic.)
- Token ordering: If streaming tokens for an LLM summary, do they arrive in order?
- Completion signals: Does the subscription cleanly close, or hang indefinitely?
- Error handling: If the LLM fails mid-stream, does the client get a clear error?
- Throughput bounds: Do tokens arrive too fast (buffering issues) or too slow (timeout risk)?
Testing pattern (pseudocode):
describe('AI streaming subscriptions', () => {
test('stream completes and tokens arrive in order', async () => {
const tokens = [];
let done = false;
const subscription = client.subscribe({
query: gql`
subscription {
streamAISummary(documentId: "doc1") {
token
isComplete
}
}
`
});
subscription.on('data', (data) => {
tokens.push(data.data.streamAISummary.token);
if (data.data.streamAISummary.isComplete) done = true;
});
// Wait for completion or timeout
await timeout(30000, () => until(() => done));
// Validations
assert(tokens.length > 10); // Got substantial output
const fullText = tokens.join('');
assert(fullText.length < 5000); // Reasonable bound
assert(isValidEnglish(fullText)); // Sanity check
});
test('subscription survives network reconnection', async () => {
// Start subscription
const sub = client.subscribe({...});
await receiveNTokens(sub, 5);
// Simulate network failure
network.disconnect();
await sleep(2000);
network.reconnect();
// Should resume, not hang
const moreTokens = await receiveNTokens(sub, 5);
assert(moreTokens.length > 0);
});
});
This tests the streaming contract: data flows, errors propagate, connections heal. It doesn't test the AI quality (that's a separate concern).
Problem 3: Resolver Testing with Non-Deterministic Outputs
Your resolvers call LLM APIs. The outputs are random. How do you test them?
Old approach (broken): Unit test with mocks. Mock the LLM to always return "Summary: xyz". Tests pass, but they don't catch real LLM problems.
New approach (better): Stratified resolver testing.
Layer 1: Mock Layer (Fast Tests)
Mock the LLM with deterministic responses. Test that your resolver correctly formats the output and handles edge cases (timeouts, empty responses, etc.).
// Unit test with mock
jest.mock('llm', () => ({
summarize: jest.fn().mockResolvedValue('Mock summary')
}));
test('resolver formats LLM output', async () => {
const result = await resolveSearchResult({
query: 'test',
llm: mockLLM
});
assert(result.summary === 'Mock summary');
});
Layer 2: Chaos Layer (Real LLM Tests)
Call the real LLM, but with chaos injected. Test resolvers against realistic failure modes:
- LLM takes 45 seconds (timeout behavior)
- LLM returns empty string (should you retry?)
- LLM returns obviously wrong output (how do you detect?)
- LLM hits rate limits (fallback logic)
// Integration test with chaos
test('resolver handles LLM timeout', async () => {
const slowLLM = {
summarize: async () => await sleep(60000) // Never returns
};
const result = await Promise.race([
resolveSearchResult({ llm: slowLLM }),
timeout(5000)
]);
assert(result.error === 'timeout');
assert(result.fallbackValue === 'Unable to generate summary'); // Graceful
});
Layer 3: Production Validation
In production, monitor resolver outcomes in real time. Track:
- % of outputs that pass your validation layers
- Latency percentiles (p50, p95, p99)
- Error rate and error types
Use these metrics to set SLOs. If "% valid outputs" drops below 95%, page your team.
Problem 4: Query Complexity and Rate Limiting for AI Endpoints
GraphQL's flexibility is both a feature and a bug. A single client query can request:
{
searchResults(query: "test", limit: 1000) {
summary # Calls LLM for each result
analysis # Calls LLM again
relatedQuestions {
answer # More LLM calls
}
}
}
That's potentially 3,000 LLM calls from one query. Your bill goes nuclear.
Solution: Query complexity analysis + rate limiting.
Step 1: Assign Costs to Fields
Not all fields are equal. A database lookup is cheap. An LLM call is expensive.
const schema = buildSchema(`
type Query {
search(query: String!): SearchResult
}
type SearchResult {
results: [Item!]! # cost: 0
summary: String! # cost: 5 (LLM call)
analysis: String! # cost: 10 (expensive LLM)
sentiment: Float! # cost: 1 (ML classifier)
}
`);
const costMap = {
'Query.search': { complexity: 1 },
'SearchResult.summary': { complexity: 5 },
'SearchResult.analysis': { complexity: 10 },
'SearchResult.sentiment': { complexity: 1 }
};
// Before executing query, calculate total cost
const cost = calculateQueryComplexity(query, costMap);
if (cost > 100) {
throw new Error(`Query too expensive (cost: ${cost}, max: 100)`);
}
Step 2: Rate Limiting with Token Bucket
Limit LLM-heavy queries across your user base.
const rateLimiter = new TokenBucket({
capacity: 100000, // max "cost" per minute
refillRate: 1000 // refill 1000 cost-points per second
});
async function executeQuery(query, userId) {
const cost = calculateQueryComplexity(query, costMap);
if (!rateLimiter.tryConsume(userId, cost)) {
throw new Error('Rate limit exceeded. Please try again later.');
}
return executeGraphQL(query);
}
Step 3: Testing Rate Limits
Write tests that verify limits actually work:
test('queries above complexity threshold are rejected', async () => {
const expensiveQuery = `{
search(query: "test", limit: 1000) {
results { summary analysis relatedQuestions { answer } }
}
}`;
const cost = calculateQueryComplexity(expensiveQuery, costMap);
assert(cost > 100);
const result = await executeQuery(expensiveQuery);
assert(result.error === 'complexity_exceeded');
});
test('rate limiting across users is enforced', async () => {
const bucket = new TokenBucket({ capacity: 100, refillRate: 0 }); // No refill
// User 1 exhausts limit
bucket.tryConsume('user1', 100);
assert(bucket.canConsume('user1', 1) === false);
// User 2 still has quota
assert(bucket.canConsume('user2', 50) === true);
});
Problem 5: Error Handling and Observability
When things go wrong (and they will), you need to know quickly.
Critical metrics to track:
- LLM call latency: p50, p95, p99 for each resolver
- Error rate: % of queries that fail (by resolver, by user, by error type)
- Output quality: % of outputs that pass your validation layers
- Cost: Dollars spent on LLM calls per query/per user
- Rate limit hits: How often users hit complexity limits
Testing strategy:
// Instrument your resolvers
async function resolveAISummary(parent, args, context) {
const startTime = Date.now();
const logger = context.logger;
try {
const summary = await llm.summarize(parent.content);
// Validation
const quality = await validateOutput(summary);
logger.info('resolver_success', {
resolver: 'SearchResult.summary',
latency: Date.now() - startTime,
quality,
tokens: countTokens(summary),
cost: estimateCost(summary)
});
return summary;
} catch (err) {
logger.error('resolver_error', {
resolver: 'SearchResult.summary',
error: err.message,
latency: Date.now() - startTime
});
throw err;
}
}
// In your tests, verify logging works
test('resolver logs metrics on success', async () => {
const logs = [];
const logger = { info: (msg, meta) => logs.push({ msg, meta }) };
await resolveAISummary({}, {}, { logger });
assert(logs.length > 0);
assert(logs[0].msg === 'resolver_success');
assert(logs[0].meta.latency > 0);
});
Bringing It Together: A GraphQL + AI Testing Checklist
Before shipping a GraphQL field that's powered by AI, verify:
- [ ] Schema validation (types are correct)
- [ ] Semantic validation (outputs make sense)
- [ ] Policy validation (no PII, toxicity, etc.)
- [ ] Determinism testing (variance is acceptable)
- [ ] Subscription resilience (streaming doesn't hang)
- [ ] Resolver chaos testing (handles timeouts, errors)
- [ ] Query complexity limits (can't accidentally spend $10k)
- [ ] Rate limiting (users can't DoS the API)
- [ ] Observability (latency, errors, quality metrics all logged)
- [ ] Error handling (graceful fallbacks, user-friendly messages)
This sounds like a lot. It is. But it's the price of mixing a query language designed for deterministic data with a runtime designed to be wonderfully unpredictable.
What's Still Hard
Some problems remain unsolved:
- Predicting failure modes: You can't enumerate all the ways an LLM might produce wrong output. You're always learning from production.
- Cost optimization: Balancing query cost against user experience is an art. There's no silver bullet.
- Multi-resolver consistency: If a query calls 5 LLM resolvers, and 1 hallucinates, how does the client know which one? Standardizing error signals is hard.
- Performance under load: LLM providers have rate limits. When you hit them, your whole API suffers. Load testing becomes genuinely complex.
But with the patterns above, you're not just crossing your fingers. You're testing deliberately.
Stop testing GraphQL + AI the old way.
alt.qa provides dedicated testing patterns for GraphQL APIs with non-deterministic resolvers, schema validation, complexity analysis, streaming subscriptions, and real-time quality monitoring.
Try alt.qa Free →