On this page
- Test the boundaries you actually own
- Build a provider simulator early
- Use one shared adapter contract suite
- Then add provider-specific tests
- Snapshot native requests carefully
- Never test with real API keys in fixtures
- Test destination binding
- Streaming needs byte-level tests
- Test partial response persistence
- Cancellation needs race tests
- Test tool calls as state machines
- Side effects require idempotency tests
- Reasoning needs separate fixtures
- Capability detection needs conflict tests
- Context management should use synthetic pathological chats
- Provider switching needs dedicated tests
- Custom OpenAI-compatible servers need adversarial fixtures
- Error classification deserves its own table tests
- Retry tests need a fake clock
- Persistence tests should kill the process conceptually
- Backup/restore needs round-trip and migration tests
- Local-first behavior should be tested with providers unavailable
- Live integration tests still matter
- Do not assert model prose exactly
- Record/replay fixtures can bridge live and simulated testing
- Keep fixtures versioned with the protocol
- Observability tests are important too
- A test pyramid for multi-provider clients
- Definition of done for a new provider
- Where BYOKchat fits
- Further reading
A multi-provider AI client should not rely on live model APIs as its primary test suite.
Real providers are useful for end-to-end verification, but they are poor deterministic test harnesses. They can change models, return different text every run, rate-limit unpredictably, add new stream events, deprecate endpoints, or be temporarily unavailable.
A strong test strategy separates:
application semantics
provider adapter contracts
protocol parsing
failure behavior
live integration confidence
The goal is to prove that your client behaves correctly even when providers disagree or fail.
Test the boundaries you actually own
You do not need to test whether a frontier model can answer “What is 2 + 2?”
You do need to test whether your application:
- builds the right request;
- attaches the right credential to the right endpoint;
- parses streams correctly;
- persists partial/final state safely;
- normalizes tool calls;
- enforces tool approval policy;
- handles reasoning events;
- classifies errors;
- respects cancellation;
- rebuilds context after failure;
- switches providers without corrupting history.
The provider owns model quality. You own the integration.
Build a provider simulator early
A deterministic simulator can emulate the provider boundary without network uncertainty.
It should be able to produce scripted scenarios such as:
normal text stream
slow first token
text stream with usage
reasoning then answer
tool call split over several deltas
parallel tool calls
429 then success
500 three times
401 invalid key
403 permission denied
context-too-long
network disconnect mid-sentence
malformed event
unknown event type
background response accepted
cancel race
Then your tests can assert exact behavior.
For example:
scenario: 429 → retry-after 2s → success
expect:
logical request count = 1
provider attempt count = 2
retry delay uses supplied guidance
final message completed once
This is far more reliable than hoping a real API happens to return a 429 during CI.
Use one shared adapter contract suite
Every provider adapter should satisfy common invariants.
Examples:
credentials never appear in normalized events
one generation-started event occurs before visible output
completed and failed are mutually exclusive terminal states
cancellation prevents later completion
usage fields preserve unknown vs zero
provider request IDs are captured when available
tool calls receive application-owned IDs
native errors map to stable categories
Write these once and run them against every adapter fixture.
A new provider should not be considered integrated until it passes the same core contract.
Then add provider-specific tests
Shared contracts are not enough because providers differ.
Provider-specific fixtures should cover native behavior such as:
native content block ordering
provider-specific auth headers
reasoning event shapes
native tool-call IDs
file references
model-list responses
finish reasons
rate-limit metadata
provider error envelopes
The test principle is:
shared semantics → common suite
native protocol quirks → adapter-specific suite
This keeps both abstraction and fidelity.
Snapshot native requests carefully
Request snapshots are useful for provider adapters.
Given a neutral generation request, assert the native body:
{
"model": "expected-model",
"messages": [...],
"tools": [...]
}
But avoid snapshots so broad that every harmless field-ordering change causes massive diffs.
Prefer semantic assertions for important fields:
selected model correct
instruction mapped correctly
all enabled tools included
unsupported option omitted
attachment translated correctly
Snapshot tests are strongest when they make meaningful protocol changes obvious.
Never test with real API keys in fixtures
Use fake credentials:
sk-test-not-a-real-secret
and verify redaction behavior.
Your test suite should explicitly fail if a credential appears in:
- application logs;
- normalized error objects;
- exported conversations;
- analytics payloads;
- crash diagnostics;
- tool arguments;
- prompt content.
Secret non-leakage is testable behavior.
Test destination binding
For custom endpoints, a critical test is ensuring credentials go only to the intended origin.
Scenarios:
same-origin redirect
cross-origin redirect
malformed URL
HTTP private-LAN endpoint
HTTPS public endpoint
endpoint changes after credential configured
Assert that protected headers are never forwarded across an untrusted origin transition.
This is more important than testing whether URL parsing works in the happy path.
Streaming needs byte-level tests
Many stream bugs appear below the semantic event layer.
Test chunk boundaries like:
chunk 1: dat
chunk 2: a: {"delta":"Hel
chunk 3: lo"}\n\n
The parser must not assume one network chunk equals one SSE event or one UTF-8 string boundary.
Useful cases include:
- event split across chunks;
- several events in one chunk;
- UTF-8 character split across byte buffers;
- blank lines;
- multiline data fields;
- trailing partial frame on disconnect;
- comment/keepalive frames;
- unknown event fields.
Then separately test normalized generation events.
Test partial response persistence
Simulate:
text deltas received
→ local checkpoint written
→ process terminates before completion
After relaunch/recovery, assert that the conversation contains:
partial visible output
non-terminal/partial generation state
provider/model metadata
no false “completed” status
The user should not lose all progress or see a misleading terminal state.
Cancellation needs race tests
Cancellation is a concurrency problem.
Test all boundaries:
cancel before request starts
cancel during DNS/connect
cancel before first token
cancel during text stream
cancel while tool arguments are assembling
cancel while tool executes
cancel just as completion arrives
cancel during retry backoff
Define the expected state for each.
For example, if completion wins the race before cancellation is committed, the final result may legitimately be completed. The important part is deterministic state transitions without duplicate terminals.
Test tool calls as state machines
A tool-enabled generation can move through:
model streaming
tool call assembling
waiting for approval
tool executing
tool result ready
model continuation
completed
Test failures at every transition.
Examples:
- malformed arguments;
- schema validation failure;
- user denies approval;
- tool times out;
- tool succeeds but result delivery fails;
- duplicate tool call after retry;
- two independent parallel calls;
- second call depends on first and therefore requires another model round.
Do not test tools only by checking final assistant text.
See How AI Tool Calling Works.
Side effects require idempotency tests
For mutating tools, simulate unknown outcomes:
send_email succeeds
→ network fails before result is recorded
→ orchestrator retries
Assert that your application-level idempotency layer prevents duplicate execution where designed.
Likewise test issue creation, file writes, payments, or any other side-effecting tool.
A retry test that ignores side effects is incomplete.
See Designing Reliable AI Retries.
Reasoning needs separate fixtures
Reasoning-capable providers may emit:
reasoning summary deltas
visible text deltas
opaque continuation items
usage metadata
Test that:
- visible reasoning renders only when intended;
- opaque state is never rendered;
- continuation state is scoped to the correct provider/model;
- switching providers drops incompatible hidden state;
- reasoning usage does not become ordinary output tokens accidentally;
- malformed reasoning events fail safely.
Do not reuse one text-stream fixture for every reasoning model.
Capability detection needs conflict tests
Create model metadata scenarios such as:
built-in profile: tools = true
remote metadata: field absent
user override: tools = false
runtime error: tools unsupported
Assert the resolver’s precedence.
Other important cases:
- model ID reused on two different endpoints;
- context limit unknown;
- model disappears from discovery but is still selected;
- provider adds a capability after cache refresh;
- local override resets to auto;
- structured output supported but image input not supported.
See Capability Detection in Multi-Model AI Apps.
Context management should use synthetic pathological chats
Build conversations with:
- thousands of tiny turns;
- one enormous code block;
- large tool results;
- several attachments;
- old critical instructions;
- a stale summary;
- model switch to a smaller context window.
Then assert the context manager includes and omits the correct semantic units.
Do not rely only on token count assertions. Verify that required instructions and the current user request survive compaction.
See How to Design Context Management for Long AI Conversations.
Provider switching needs dedicated tests
A multi-provider client should test one conversation across several providers.
Scenario:
turn 1 → Provider A
turn 2 → Provider A + tool
switch to Provider B
turn 3 → Provider B
switch back to Provider A
Assert:
- historical provider metadata remains unchanged;
- completed tool results remain readable;
- Provider A native continuation state is not sent to Provider B;
- stale continuation state is not reused after intervening Provider B turns;
- context is rebuilt for each selected model;
- per-turn usage remains attributed correctly.
See How to Switch AI Providers Mid-Conversation.
Custom OpenAI-compatible servers need adversarial fixtures
A compatible endpoint may behave strangely.
Test:
/model list unavailable
model list empty
SSE contains extra fields
usage omitted
finish reason unknown
unsupported parameter ignored
unsupported parameter rejected
server returns HTML error page
server closes connection without terminal frame
HTTP local endpoint
self-signed HTTPS endpoint
Your client should degrade gracefully rather than assuming “OpenAI-compatible” means perfect equivalence.
See What Is an OpenAI-Compatible API?.
Error classification deserves its own table tests
Given provider-native responses, assert normalized categories:
| Native situation | Expected category |
|---|---|
| Invalid credential | authentication |
| Authenticated but forbidden | permission |
| Rate limit | rate_limited |
| Context too large | context_too_large |
| Unsupported model feature | unsupported_capability or invalid_request |
| Provider 5xx | transient_provider |
| Socket timeout | network/timeout |
| User stop | cancelled |
Preserve native codes and safe messages for diagnostics.
The category should drive retry/UX policy consistently across providers.
Retry tests need a fake clock
Waiting real seconds in tests makes the suite slow and flaky.
Inject a clock/scheduler so you can assert:
attempt 1 fails
advance 1s
attempt 2 fails
advance 2s
attempt 3 succeeds
Test:
- exponential backoff;
- jitter boundaries;
- Retry-After guidance;
- overall deadlines;
- cancellation during backoff;
- maximum attempts;
- no retry for invalid requests;
- no duplicate background job creation.
Deterministic time makes reliability logic testable.
Persistence tests should kill the process conceptually
You may not literally crash the test runner. Instead, stop execution at known checkpoints and create a fresh store/coordinator instance.
Examples:
user message committed, request not started
request started, no first token
partial stream checkpointed
waiting for tool approval
tool side effect completed, result not yet persisted
background job ID persisted
Then assert recovery state after “relaunch.”
This finds bugs that normal happy-path unit tests miss.
Backup/restore needs round-trip and migration tests
Create fixtures from older schema versions.
Verify:
export current data
→ delete local database
→ restore
→ canonical content equivalent
Also test:
- missing attachment;
- corrupted archive;
- duplicate IDs;
- merge conflict;
- replace restore;
- secret fields absent;
- migration from prior versions.
A backup feature is only trustworthy if old fixtures remain in the test suite.
Local-first behavior should be tested with providers unavailable
Launch the app/store layer with every provider simulator offline.
The client should still be able to:
- open history;
- search;
- edit drafts;
- manage projects;
- export data;
- inspect prior usage.
Only generation-dependent operations should fail.
This verifies that provider networking has not become an accidental dependency of local state.
Live integration tests still matter
Deterministic simulation cannot prove that current provider APIs still accept your requests.
Use a smaller live test suite for:
authentication
one minimal generation
stream parsing
model discovery where applicable
one tool-call smoke test where stable
Run it selectively—not as the only correctness layer.
Live tests should use dedicated low-privilege test credentials and conservative usage limits.
Do not assert model prose exactly
A live model can produce semantically equivalent but different text.
Bad assertion:
response == "Paris is the capital of France."
Better integration assertions:
request succeeded
at least one text event arrived
terminal status completed
usage/request ID parsed when provider supplies it
no protocol parser error occurred
Test protocol behavior, not stochastic wording.
Record/replay fixtures can bridge live and simulated testing
For complicated provider events, capture a sanitized real stream and store it as a fixture.
Before committing:
- remove API keys;
- remove private prompts;
- remove account identifiers;
- review URLs/headers;
- preserve event ordering exactly.
Then replay the fixture through your parser deterministically.
This is useful for rare event shapes that are difficult to synthesize accurately.
Keep fixtures versioned with the protocol
Name fixtures clearly:
providerA/text-stream-v2.jsonl
providerA/tool-call-v2.jsonl
providerB/reasoning-stream-2026.jsonl
compatible/malformed-sse-01.txt
When a provider changes protocol behavior, add or update fixtures deliberately rather than mutating an opaque shared sample.
Observability tests are important too
For each simulated logical request, assert sanitized telemetry:
provider/model recorded
attempt count correct
TTFT starts/stops at correct boundary
duration recorded once
error category correct
tool count correct
no prompt/response content included unless explicitly designed
no credentials included
Metrics are part of the product behavior and can silently drift.
A test pyramid for multi-provider clients
Most coverage should be deterministic and fast. Live providers sit at the top as compatibility smoke tests.
Definition of done for a new provider
A new provider integration should not be “done” when one prompt succeeds manually.
Require:
- request construction tests;
- auth redaction tests;
- stream parser fixtures;
- common adapter contract suite;
- error mapping tests;
- cancellation tests;
- capability metadata tests;
- tool/reasoning/file tests for supported features;
- provider-switching tests;
- local persistence/recovery tests where relevant;
- one live smoke test.
This prevents each new provider from becoming a new source of one-off behavior.
Where BYOKchat fits
A BYOK client that supports multiple provider families, custom compatible servers, private-network endpoints, tools, reasoning, attachments, projects, and recovery benefits enormously from deterministic simulation. Real providers confirm compatibility; simulators prove the app’s own state machines.
The more provider choice a product offers, the less acceptable it becomes to test only by opening the app and sending a few manual prompts.