Replay Testing with ReplayClient
The client is a swap point,
and testing is where that pays off most: seat a ReplayClient in place of the
real adapter and your endpoints run against recorded fixtures — offline,
deterministic, no network. capi_test_framework makes this the default way to
test a Capi client, and this chapter is the loop: write a test once,
record it against the live service, commit the fixtures, replay them forever.
One test body, three runtimes
#[capi_test] writes a test once and emits the shape for whichever lane is
building — exactly one compiles per configuration:
- native sync →
#[test], withasyncand every.awaitstripped; - native async →
#[tokio::test]; - browser wasm →
#[wasm_bindgen_test].
#[capi_test]
async fn test_get_models() {
let api = GrokTestHarness::setup(current_test_name!());
let models = api.query(GetModels::new()).await.unwrap();
assert_eq!(models.len(), 3);
}
The .await you write is real in async builds and stripped in sync builds, so
the same source verifies the client on every lane without a cfg in sight; a
helper that must compile both ways takes #[maybe_async], the crate’s
strip_await/identity pair aliased by cfg. current_test_name!() names the
fixture directory after the enclosing function, so the literal and the name
cannot drift apart.
The harness
A small harness in tests/common.rs wires a ReplayClient into the interface.
grok’s is the shipped model:
#[derive(Debug, Copy, Clone, Default)]
pub struct BasicConfig;
impl From<BasicConfig> for ReplayConfig {
fn from(_: BasicConfig) -> Self {
ReplayConfig::default()
.classify_header("cookie", FixtureClass::Omitted)
.with_sensitive_env_prefix("GROK_")
.with_sensitive_file("tests/sensitive.local.env")
.with_sensitive("api_key", "xai-DUMMY_KEY")
.with_sensitive("team_id", "00000000-0000-4000-8000-000000000002")
}
}
pub fn setup_interface(client: ReplayClient) -> GrokApi<ReplayClient> {
let context = client.context(); // the recorded clock and RNG
let mut api = GrokApi::new_with_defaults(client);
api.set_base_context(context); // every request derives from it
api
}
pub struct GrokTestHarness;
impl GrokTestHarness {
pub fn setup<T: ToString>(name: T) -> GrokApi<ReplayClient> {
let api = setup_interface(ReplayClient::for_test(BasicConfig, name));
api.authenticate(api.sensitive("api_key")); // the placeholder in replay, the live key when recording
api
}
}
Three things make it deterministic. ReplayClient::for_test(config, name)
resolves the conventional root — tests/data, or CAPI_FIXTURE_ROOT — and
loads tests/data/<name>/. client.context() is installed as the config’s
base context, so every request derived from it inherits the recorded clock
and RNG: a polling loop sleeps in mock time, a
PKCE verifier or a multipart boundary is
drawn from the recorded seed, and the replay matches byte for byte. And the
config marker converts into a ReplayConfig that declares what each recorded
item is — the next chapter has the full vocabulary.
api.sensitive("api_key") is the record-versus-replay idiom for a credential.
with_sensitive(name, placeholder) registers the placeholder that fixtures hold
and that replay returns; when recording, the live value comes from
<prefix>API_KEY in the environment or the gitignored sensitive.local.env
file, and a missing one fails before any traffic. An unregistered name panics in
every mode, so a typo surfaces in replay too.
Record or replay, by fixture presence
There is no construction-time choice between the two. for_test scans the
fixture directory: fixtures present, the client replays; none present, it
records live traffic there. So the first run of a new test records and every
run after it replays, and re-recording is deleting the directory. is_recording()
reports which mode a run took, and .mock_only() forces replay even when
fixtures are missing, for a test that must never reach the network.
Recording needs a live client, and which one is bundled depends on the lane:
| Lane | Bundled live client |
|---|---|
native, reqwest feature | capic_reqwest |
native, native-client feature (without reqwest) | capic_native_client |
| browser wasm | the Web Fetch client, no feature needed |
everything else — plain std, single-threaded, bare metal | none: new_with_backend is replay-only, and new_with_client_and_backend records through a client you supply |
reqwest and native-client are each mutually exclusive with
single-threaded — the crate refuses the pair with a compile_error! naming
the remedy — so a native recording suite runs --features std,async,reqwest
and a current-thread suite runs --features std,async,single-threaded and
replays committed fixtures. The fixture backend is a seam of its own:
FsFixtureBackend on native, NodeFsFixtureBackend on browser wasm under Node,
and InMemoryFixtureBackend for a fixture set built in the test itself.
The fixture format
Fixtures live in tests/data/<test_name>/, one numbered exchange per request,
consumed in order:
tests/data/test_get_models/
0001.req # the request the client should make
0001.rsp # the response to play back
0001.meta # what replay needs and HTTP does not carry
A .req or .rsp holds the exchange and nothing else, in the canonical text
form capi_http_types::text defines — the same rendering capi_debug_dump
writes, so a dump and a fixture read alike. The head is a request line of method
plus complete absolute URL, or a status line of version plus numeric status, then
one lowercase name: value line per stored field, then the blank line the body
follows. No host line (the URL carries the authority), no request version, no
reason phrase:
GET https://api.x.ai/v1/models
accept: application/json
authorization: [REDACTED]
HTTP/2.0 200
content-type: application/json
{"data":[{"id":"grok-4"}]}
The body is the raw bytes after that blank line, so a fixture may carry
arbitrary binary; a body kept in a file of its own (BodyEncodingMode::File,
or Auto deciding from the content type), or captured chunk by chunk with the
moment each arrived, is named by the sidecar instead. A response’s trailer block
is a standalone 0001.trailers file, because it is only known once the body
reaches EOF, and it replays as pending until the body does — which preserves
the distinction a live trailer-capable client exhibits.
Everything else rides 0001.meta, a small versioned key=value grammar the
test framework owns, so reading a fixture pulls no JSON or TOML codec into the
graph:
capi-meta 1
request.date=2026-01-01T00:00:00Z
request.masking=masked
response.date=2026-01-01T00:00:00.150Z
response.masking=masked
The dates are the recorded wall clock that drives the injected context: the
first request’s pins it, and each match advances it to that exchange’s
response. Every other key has a default and appears only when it differs —
where a body lives, the recording RNG’s seed, the URL fragment a head does not
carry, whether the match key asserts the recorded authority, how a streaming
request body ended, and the masking attestation and capture marker the
next chapter explains.
Ordered consumption is what makes multi-step flows testable: a reconnect, a poll
loop, or an OAuth2 exchange records as 0001, 0002, … and replays request by
request, and the engine supports duplicate keys — create, modify, retrieve
against one endpoint each consume the next matching entry. A request that
matches nothing fails loudly, with with_write_unmatched_requests(true)
writing the offending request to disk for comparison.
By default a recorded response keeps only the headers the response policy
classes Plain — payload-shape headers such as content-type and
content-length, caching validators, the WebSocket upgrade set.
with_record_all_response_headers() keeps everything, for debugging.
Time in a replay
The mock clock is frozen by default — this and sleeping on it are the only
things that move it — so two timestamps taken with nothing in between are the
same instant, and an assertion about elapsed time is exact. The client exposes
the knobs: advance_clock(d) moves it by hand, set_auto_advance(d) gives
every read a step so successive timestamps differ, set_system_time(t) pins
it, and use_live_clock(true) hands the wall clock back. A test that reads the
clock many times without moving it trips a spin detector; set_stall_threshold
raises the bar for one that legitimately does.
A polling test is the common case. capi_test_framework::wait_until(clock, spec, step)
sleeps interval on the clock between iterations and gives up with
WaitTimeout once timeout elapses — instantly in replay, since sleep
advances mock time rather than waiting, and at real intervals when recording,
so the fixtures pace as the service did. wait_until_done is the same loop
over an endpoint that reports a status. And client.mock_timeout(d, future)
races a future against the mock clock, so a test can prove a hang is caught
without waiting for one.
Recording the twin
For an endpoint whose sensitive fields ride the body, record through
api.query_redacted(endpoint) instead of query. It dispatches the live
request as usual, but the fixture the recorder writes — and the request the
replay engine compares — is a twin built from a redacted clone of the
endpoint and finalized with its credentials and signatures redacted by the
seal. Nothing in the twin ever held a live value, so nothing has to be found
and masked after the fact; it asks the endpoint to be Clone and ApiRedact,
which the wire derive provides (Redaction). Seat
RedactedRunner under a runner chain to combine it with paging or polling.
Testing a decoder directly
For a decoder you want to exercise without fixtures at all — a 429 on the
third call, a body truncated below its Content-Length, a server that hangs —
capi_test_core’s MockClient scripts responses and the faults a recording
cannot express:
let mock = MockClient::new()
.on(Method::GET, "/shipments/shp_123")
.respond(MockResponse::ok().bytes(body, "application/json")) // first call
.then_fault(Fault::Connect) // second call: reset
.default_response(MockResponse::not_found()) // every other path
.build();
Rules match in order, a rule’s chained outcomes script a per-call sequence
whose last outcome repeats, and an unmatched request panics unless told
otherwise. Fault covers Connect, ConnectionRefused, Timeout, Hang,
Cancelled, and Empty; MockResponse::ok() / status(code) build a
response with text, bytes, body_encoded, or event_stream, plus the
delivery modifiers a recording cannot express — truncate_to(n),
reset_after(n), malformed(), dribble(chunks, total), delay(d).
MockClient implements ApiClient, so it seats in the same harness a
ReplayClient does, and a MockClient::new() with no rules is the proof of a
negative — hand it to a resumable decoder as FollowUpChannel::new(mock) and a
stray reconnect trips the panic.
The gate
Every committed fixture attests how it was written, and one more test in the suite refuses the ones that must not land:
#[test]
fn fixtures_attest_masking() -> Result<(), Box<dyn std::error::Error>> {
FixtureAudit::scan("tests/data")?.check()?;
Ok(())
}
cargo capi fixtures runs the same audit from the command line, and
cargo capi verify includes it. What the attestation says, and
the three tiers that decide what a fixture may hold, is the next
chapter; recording a WebSocket session as a .ws fixture
is in WebSocket, and the per-message comparison a
gRPC fixture gets — bodies to .bin sidecars, grpc-status in the trailer
block, the twin as the route for a secret in a frame — is in
gRPC.