On this page
- The simulator should speak the same boundary as the adapter
- Transport/protocol simulator
- Semantic provider fake
- Do not replace protocol tests with only semantic mocks
- Make scenarios data, not bespoke test code
- Separate request matching from response scripting
- Request assertions catch adapter regressions
- Build deterministic streaming events
- Split UTF-8 characters across transport chunks
- Split SSE fields across arbitrary reads
- Test multiline SSE data
- Simulate valid transport with invalid semantic JSON
- Malformed fixtures are first-class test cases
- Unknown events should test forward compatibility
- Latency should be virtualizable
- Keep a small real-time integration layer too
- Simulate phase-specific timeouts
- Rate-limit scenarios need headers and scope
- Simulate stacked retries deliberately
- Unknown request outcome is essential
- Simulate partial output before failure
- Cancellation has several race points
- Tool calling needs structured fixtures
- Test multiple tool calls in one turn
- Test dependencies across rounds, not fake parallel dependencies
- Invalid tool arguments should stay model errors, not tool crashes
- Human approvals need deterministic waiting
- Side-effecting tools need idempotency fixtures
- Reasoning events need their own scenarios
- Structured output needs success and failure fixtures
- Attachments need upload simulators
- Test base URL resolution
- Authentication tests should never use real secrets
- Redirect behavior deserves explicit tests
- Circuit breaker tests need repeated scenarios
- Health scoring tests need noisy evidence
- Request queue tests need controllable concurrency
- Background jobs need durable IDs
- Webhooks can be simulated without public networking
- Offline/degraded behavior needs a switchboard
- App lifecycle is part of the simulator environment
- Store scenario results for diagnostics
- Snapshot normalized events, not raw unordered dictionaries
- Golden fixtures should be small and readable
- Captured real responses need sanitization
- Provider simulators should be versioned with adapters
- Test unknown future behavior separately from legacy behavior
- A simulator should expose developer controls
- Keep simulation obviously non-production
- Simulated pricing should not leak into production paywalls or public metadata
- Deterministic pseudo-randomness can help fuzz scenarios
- Property tests fit stream parsers well
- Fuzz only within bounded resource limits
- One simulator can serve many provider adapters, but do not erase native differences
- A layered simulator architecture
- Useful invariants
- Minimum high-value scenario suite
- Where BYOKchat fits
- Further reading
A multi-provider AI client has a testing problem that ordinary HTTP apps often do not.
The hardest bugs happen in states such as:
stream starts, then disconnects
one tool-call argument arrives across six chunks
provider returns 429 with a retry hint
JSON event is malformed halfway through
request succeeds but final usage never arrives
app cancels during a tool round
provider changes finish reason
background job completes after relaunch
You cannot reliably wait for real providers to produce these failures on demand.
A deterministic provider simulator should be treated as part of the client architecture, not as a toy mock used only for unit tests.
The simulator should speak the same boundary as the adapter
There are two useful testing levels.
Transport/protocol simulator
Acts like an HTTP provider:
request -> status/headers/body/stream
This tests:
- request encoding;
- authentication/header construction;
- SSE parsing;
- JSON decoding;
- retry headers;
- cancellation;
- timeout behavior.
Semantic provider fake
Implements the normalized provider interface directly:
interface AIProvider {
send(request: NormalizedRequest): AsyncStream<ProviderEvent>
}
This is faster for application-level tests.
A strong test suite uses both.
Do not replace protocol tests with only semantic mocks
If every test starts after the provider adapter, you can miss bugs in:
SSE framing
UTF-8 chunk boundaries
HTTP status mapping
headers
JSON event routing
multipart upload
The provider adapter is exactly where compatibility bugs live.
See How AI Provider Adapters Work.
Make scenarios data, not bespoke test code
Instead of writing a new fake for each test, define a scenario:
{
"name": "stream_then_disconnect",
"steps": [
{"after_ms": 10, "event": "message_start"},
{"after_ms": 20, "event": "text_delta", "text": "Hello"},
{"after_ms": 30, "transport": "disconnect"}
]
}
Then the simulator executes the script deterministically.
This creates reusable fixtures across:
unit tests
integration tests
manual developer UI
regression tests
Separate request matching from response scripting
A scenario can declare expectations:
{
"expect": {
"model": "test-model",
"stream": true,
"tools": ["get_weather"]
}
}
If the client sends the wrong request, the simulator should fail loudly instead of returning a plausible response anyway.
Request assertions catch adapter regressions
Useful assertions include:
method
path
query parameters
content type
auth header present but value redacted
provider version header
model ID
message ordering
tool schema
reasoning setting
stream flag
Never snapshot real API key values.
Use sentinel credentials such as:
sk-test-sentinel
and assert only that the adapter places them where expected.
Build deterministic streaming events
Streaming scenarios should let tests control:
number of chunks
chunk timing
transport packet boundaries
semantic event boundaries
final event
usage timing
Do not assume:
one network chunk = one SSE event
That is a common parser bug.
See How to Parse Server-Sent Events Correctly.
Split UTF-8 characters across transport chunks
A robust simulator should test bytes such as an emoji or non-Latin character split across reads.
For example:
chunk 1: first bytes of character
chunk 2: remaining bytes
The client must decode the byte stream correctly rather than treating each network read as standalone UTF-8 text.
Split SSE fields across arbitrary reads
Test:
"da"
"ta: {\"type\":..."
"}\n\n"
and multiple events in one read:
event1\n\nevent2\n\n
The parser should reconstruct protocol frames independently of socket chunking.
Test multiline SSE data
SSE supports multiple data: lines for one event.
A simulator can emit:
data: first
data: second
and verify the parser follows the event-stream rules instead of supporting only the provider’s most common happy path.
Simulate valid transport with invalid semantic JSON
Example:
data: {"type":"delta",
followed by event termination.
The client should classify this as a protocol/decoding failure, not a network error.
See How to Classify AI API Errors.
Malformed fixtures are first-class test cases
Useful cases:
invalid JSON
missing required field
unknown event type
wrong type for usage field
negative token count
unknown finish reason
duplicate start event
final event before start
text delta after completion
Decide which should:
fail request
ignore safely
preserve as unknown extension
and test that contract.
Unknown events should test forward compatibility
Providers add event types over time.
A simulator can insert:
{"type":"future.event","foo":"bar"}
between known events.
If the adapter can safely ignore unknown noncritical events, test that behavior.
If an unknown event makes state ambiguous, fail clearly.
Latency should be virtualizable
Real sleeps make tests slow and flaky.
Abstract time:
interface Clock {
now: Date
sleep(duration: Duration) async
}
Then a test can advance virtual time:
+500 ms -> first byte timeout not yet
+500 ms -> timeout fires
without actually waiting one second.
Keep a small real-time integration layer too
Virtual clocks are ideal for scheduling logic.
A few end-to-end tests should still exercise real asynchronous IO to catch race conditions in:
URLSession
async sequences
cancellation
socket/server cleanup
Balance speed with realism.
Simulate phase-specific timeouts
Scenarios should cover:
connect stalls
headers arrive but first model event stalls
reasoning event arrives but first text stalls
stream goes idle after tokens
overall deadline expires
queued request times out before dispatch
tool call exceeds deadline
See Timeout Design for AI Applications.
Rate-limit scenarios need headers and scope
A useful 429 fixture can include:
Retry-After: 3
or an HTTP-date.
Provider-specific fixtures can include documented native quota headers.
Tests should verify:
adapter parses native metadata
shared scheduler respects not-before time
no tight retry loop
user cancellation clears queued retry
See Retry-After and Rate-Limit Headers Explained.
Simulate stacked retries deliberately
Configure:
transport retry enabled
SDK retry enabled
app retry enabled
in a test harness and assert the actual number of provider attempts.
This catches accidental retry multiplication.
Unknown request outcome is essential
A scenario can model:
request body fully received by simulator
simulator records operation as completed
connection drops before response reaches client
The client sees an ambiguous network failure even though the server-side action happened.
This is the core case for idempotency and reconciliation testing.
See Designing Reliable AI Retries.
Simulate partial output before failure
Example:
100 text deltas
then connection reset
Assert:
partial text preserved
message marked interrupted
usage unknown or partial according to fixture
retry does not overwrite old branch
See Why AI Streams Break in the Middle.
Cancellation has several race points
Test cancellation:
before request dispatch
while connecting
after headers
while streaming text
while tool arguments stream
while tool executes
while waiting for retry
while background job polls
The expected cleanup differs at each phase.
Tool calling needs structured fixtures
A scenario can emit:
tool call start id=call_1 name=get_weather
arguments fragment {"city":
arguments fragment "Tokyo"}
call complete
Then the client executes a fake tool and returns:
{"temperature_c":31}
The simulator can expect the next model request to contain that result.
Test multiple tool calls in one turn
Emit:
call_1 get_weather(Tokyo)
call_2 get_weather(Paris)
and verify correlation by call ID.
Do not rely on arrival order alone.
See How Streaming Tool Calls Work.
Test dependencies across rounds, not fake parallel dependencies
If call B depends on call A’s result, the model should need another model round after A.
A single response containing both calls should be treated as independent proposals unless the protocol explicitly says otherwise.
Fixtures should reinforce this architecture.
Invalid tool arguments should stay model errors, not tool crashes
Simulate:
{"city": 123}
against a schema expecting string.
The host should reject before execution and return the configured tool-error path.
See How to Validate AI Tool Arguments Safely.
Human approvals need deterministic waiting
A tool fixture can pause at:
approval_required
The test drives:
approve
deny
cancel app
relaunch then approve
arguments mutate before execution
Approval must remain bound to the operation that was reviewed.
Side-effecting tools need idempotency fixtures
A fake send_email tool can record operation IDs.
Then simulate:
tool completes
client crashes before persistence
workflow resumes
same operation requested again
Assert only one external side effect occurs when the idempotency design promises that.
See Idempotency for AI Tool Execution.
Reasoning events need their own scenarios
A reasoning-capable provider adapter can receive:
reasoning start
reasoning delta/summary
text start
text delta
usage
Test that reasoning events do not accidentally appear as answer text unless the UI contract calls for it.
See How Streaming Reasoning Differs From Streaming Answers.
Structured output needs success and failure fixtures
Test:
valid schema-constrained output
valid JSON but schema invalid
truncated JSON
provider refusal
provider reports schema unsupported
The host should distinguish transport success from application validation success.
Attachments need upload simulators
A file-capable simulator should model:
upload accepted
upload rejected for size
processing pending
processing ready
processing failed
provider file expired
upload success + generation failure
This tests the attachment state machine independently from model text streaming.
See How File Attachments Flow Through AI APIs.
Test base URL resolution
Custom endpoints commonly fail because clients concatenate paths incorrectly.
Fixtures should cover base URLs such as:
https://example.com
https://example.com/v1
https://example.com/proxy/ai/
and assert the final request path.
See How to Design a Custom OpenAI-Compatible Provider Connection.
Authentication tests should never use real secrets
Use sentinel values and assert:
header present
correct scheme
protected header forwarded only to allowed host
not included in redirect to different host
not logged
This makes credential handling testable without sensitive fixtures.
Redirect behavior deserves explicit tests
Simulate:
same-host redirect
different-host redirect
HTTP -> HTTPS
HTTPS -> HTTP
redirect loop
Verify credential forwarding and transport policy follow the client security rules.
See Certificate Validation for Custom AI Endpoints.
Circuit breaker tests need repeated scenarios
A scripted sequence:
503
503
503
circuit opens
cooldown
half-open probe -> 503
longer cooldown
half-open probe -> success
circuit closes
is easy to test deterministically with a virtual clock.
See Circuit Breakers for AI Providers.
Health scoring tests need noisy evidence
Feed observations such as:
success
401
success
context overflow
503
cancelled
success
and assert only relevant service-health events affect the health window.
This catches classification pollution.
Request queue tests need controllable concurrency
A simulator can block requests until released.
Then assert:
max 2 in flight
foreground request jumps ahead of maintenance work
cancel queued request before dispatch
rate-limited target does not block unrelated provider
See How to Build an AI Request Queue.
Background jobs need durable IDs
Simulate:
create job -> job_123
app terminates
relaunch
GET job_123 -> completed
Assert the app reconciles the existing operation rather than creating a new one.
See How Long-Running AI Tasks Work.
Webhooks can be simulated without public networking
Feed signed fixture events directly into the webhook verification/dispatch layer.
Test:
valid signature
invalid signature
duplicate event
out-of-order event
completed before local poll notices
failure after stale queued notification
Keep webhook transport tests separate from business-state reconciliation tests.
Offline/degraded behavior needs a switchboard
The simulator/harness can independently disable:
public internet provider
local server
MCP server
web search
file service
Then verify the UI preserves capabilities that remain available.
See Designing Offline and Degraded Modes for AI Apps.
App lifecycle is part of the simulator environment
For mobile clients, the test harness should trigger:
background
foreground
process termination
restore persisted state
while provider scenarios continue or stall.
This reveals bugs that pure network mocks cannot.
Store scenario results for diagnostics
A failed test should report:
scenario name
expected request number
received request summary
script step
virtual timestamp
client state
Do not dump full secret-bearing headers or private content.
Snapshot normalized events, not raw unordered dictionaries
Provider events can contain unstable fields such as IDs/timestamps.
Normalize fixtures:
replace dynamic request ID
normalize timestamp
preserve semantic event sequence
Then snapshots remain useful rather than constantly changing.
Golden fixtures should be small and readable
A huge captured production stream is hard to review.
Prefer a hand-curated fixture that isolates one behavior:
three text deltas + final usage
Use larger captured samples only for known compatibility cases.
Captured real responses need sanitization
If you record traffic from a live provider to create fixtures, remove:
API keys
user prompts
private files
real tool arguments/results
request IDs tied to account
private URLs
Prefer synthetic test content from the start.
Provider simulators should be versioned with adapters
When an adapter changes because the provider API changes, update fixtures intentionally.
Keep scenarios labeled:
provider protocol generation/version
so older behavior can remain covered when compatibility matters.
Test unknown future behavior separately from legacy behavior
Two distinct classes:
future unknown event/field -> forward compatibility
old deprecated event/schema -> backward compatibility
Do not conflate them.
A simulator should expose developer controls
A manual developer screen can select:
Success stream
Slow TTFT
429 then recover
Disconnect at 30%
Malformed JSON
Tool approval
Tool failure
Background completion
This is useful for UI testing without editing fixture code.
Keep simulation obviously non-production
Use model/provider names such as:
Simulator / Slow Stream
Simulator / Rate Limited
and a distinct developer-only connection type.
Never let simulated responses be confused with real provider traffic in user analytics.
Simulated pricing should not leak into production paywalls or public metadata
A provider simulator may need fake usage/pricing to test UI.
Keep it explicitly marked:
simulated
and isolated from production pricing catalogs or public product copy.
Deterministic pseudo-randomness can help fuzz scenarios
For parser robustness, generate chunk boundaries from a fixed seed:
seed = 42
The test remains reproducible while exercising many splits.
On failure, print the seed.
Property tests fit stream parsers well
Useful invariant:
for any arbitrary transport chunking of the same valid SSE byte stream,
semantic event output is identical
Generate many chunk partitions to catch framing assumptions.
Fuzz only within bounded resource limits
Malformed payload testing should not create unbounded strings or allocations that make CI unstable.
Use explicit maximum fixture sizes and time budgets.
One simulator can serve many provider adapters, but do not erase native differences
A generic engine can execute scripts while provider-specific fixture encoders produce:
OpenAI-style events
Anthropic-style events
Gemini-style events
compatible-server variants
The shared engine handles timing/failure transport.
Native encoders preserve real protocol differences.
A layered simulator architecture
For faster unit tests, the scenario engine can also feed the normalized app pipeline directly.
Useful invariants
same scenario + seed -> same semantic outcome
arbitrary network chunking does not change parsed events
credentials never appear in fixture snapshots
unknown provider events are handled by documented policy
cancellation stops retries and queued work
provider attempts are countable
simulated time controls timeout/backoff/circuit state deterministically
Minimum high-value scenario suite
If you build only a small first version, include:
successful text stream
successful non-stream response
401
403
429 + Retry-After
500/503 transient failure
slow first token
idle stream timeout
partial stream disconnect
user cancellation
malformed event
unknown event
streamed tool call
invalid tool arguments
tool denied
tool side-effect idempotency
structured-output invalid JSON
file upload + generation
background job recovery
That covers a surprisingly large share of production failure logic.
Where BYOKchat fits
A provider-neutral client with several direct/native adapters gets disproportionate value from a simulator because every adapter must feed the same chat, streaming, tool, analytics, and recovery pipelines while preserving provider-specific semantics at the boundary.
A deterministic simulator lets those pipelines be tested before real API calls, without consuming user quota or waiting for a real provider to fail in exactly the right way.