All guides
No guide matches your search.
Testing with the virtual clock
Test retries, backoff and circuit recovery without sleeping. Wire one virtual clock through the pipeline, script each attempt and assert on what was dispatched.
On this page
The test helpers ship inside the single enaxon/reqtide package, in the Enaxon\Reqtide\Testing namespace (src/Testing). Install the package as described in Install.
The helpers are for tests. Never call them from production code. Every class is plain, with no base test case, and carries the marker @api-for-tests.
Every code block comes from examples/08-testing.php (opens in a new tab). The example uses a small expect() function so that it runs without a test framework. In a real suite, use the assertions of your framework.
What the helpers give you
| Class | Purpose |
|---|---|
VirtualClock | A Clock that moves only through advance(), advanceMonotonicOnly(), setMonotonic() (never backwards) and setWall(). Wall jumps are independent of the monotonic domain. failNextMonotonicReads(n) and failNextWallReads(n) inject ClockFailureException. Read counts are observable. |
SeededRandomSource | A RandomSource over Random\Randomizer with an Mt19937 seed. The same seed gives the same sequence. |
ScriptedRandomSource | A RandomSource that returns a queued list of integers. It records the requested ranges and fails loudly when the list is exhausted or a value is out of range. |
VirtualDelayScheduler | A DelayScheduler (BoundedBlocking) that advances a VirtualClock instead of sleeping. It checks in the same order as BoundedBlockingScheduler: deadline, then cancellation, then completion. Equality counts as expired. cancelDuringNextDelay($token, $after) cancels in the middle of a delay. It records every delay as a DelayRecord. |
RecordingEventSink | An EventSink that stores events (events(), ofType()). throwOnEmit($e, $times) throws after recording, for failure-isolation tests. |
ControlledRuntimeAdapter | A synchronous RuntimeAdapter driven by DispatchScript objects. A script can return a value, throw, call the real operation, advance the virtual clock during the call, or request cancellation during the call. The adapter records dispatchCount(), attemptContexts() and dispatches(), so tests assert the dispatches that happened. |
DispatchScript | Immutable behavior for one dispatch (returning, throwing, callingOperation, advancing, requestingCancellation). |
ControlledExecution | The settled RuntimeExecution that the adapter returns. When the observed time is at or after the deadline, the result is late (DeadlineExceeded, checked before cancellation). Otherwise a requested cancellation gives Cancelled. An unreadable clock gives null timing, incomplete accounting and a diagnostic. The actual outcome is always kept. |
RecordingAttemptOperation and RecordingPreparedAttempt | An AttemptOperation that counts prepare calls, rewinds() (prepares after the first), replay verifications, invocations and discarded outcomes. It can inject one preparation failure and one replay failure. |
DispatchRecord and DelayRecord | Read-only records that the adapter and the scheduler expose. |
Enaxon\Reqtide\Testing and what each one does.Use one VirtualClock instance for the Deadline, the scheduler and the adapter. A Deadline rejects any other clock.
Step 1: Wire one virtual clock through everything
Use one VirtualClock for the scheduler, the runtime adapter and the memory backends. Time then moves only when you say so.
/** One VirtualClock drives the scheduler, the runtime and the memory backends, so time only moves when you say so. */
function rig(bool $withCircuit): array
{
$clock = new VirtualClock();
$scheduler = new VirtualDelayScheduler($clock);
$runtime = new ControlledRuntimeAdapter($clock);
$events = new RecordingEventSink();
$builder = PipelineBuilder::profile('virtual-time', 'v1')
->partition('shop', 'test', 'orders-api')
->attemptTimeout(Duration::seconds(2), AttemptTimeoutRequirement::Cooperative)
->retry(4, BackoffMode::Exponential, Duration::milliseconds(100), Duration::seconds(1), JitterMode::None)
->retryBudget(10, 10, Duration::seconds(60), Duration::seconds(10));
if ($withCircuit) {
$builder = $builder->circuitBreaker(PipelineBuilder::lastCalls(2), 2, Ratio::of(1, 2), Duration::seconds(30), 1, 1, Duration::seconds(10));
}
$pipeline = $builder->compile(new RuntimeEnvironment(
$clock,
$scheduler,
new SeededRandomSource(1),
ExceptionClassifier::create()->retryOn(UpstreamTimeout::class),
$runtime,
$events,
null,
$withCircuit ? new MemoryCircuitBackend($clock) : null,
null,
new MemoryRetryBudgetBackend($clock),
null,
));
return [$pipeline, $clock, $scheduler, $runtime, $events];
}InMemoryEnvironment is what compileInMemory() uses. It wires the real clock and a scheduler that really sleeps, so a test builds the RuntimeEnvironment by hand, as above. Never pair a VirtualClock with BoundedBlockingScheduler. It would wait for time that never passes.
Step 2: Anchor the context on the same clock
/** A context whose deadline and replay-safety statement are both measured on the pipeline's (virtual) clock. */
function context(CompiledPipeline $pipeline, VirtualClock $clock): ExecutionContext
{
$contract = ReplaySafetyContract::forReadOnlyCallable('get-order', Duration::seconds(3600), $clock);
return $pipeline->newContext(Duration::seconds(10), OperationSafety::replayableRead($contract));
}newContext() builds the deadline on the clock of the pipeline, so that part is automatic. Anything tied to wall time must come from the same clock. The replay-safety contract is one example. A contract made from the real clock lies in the past or the future of the virtual clock, so Reqtide treats it as not valid. The retry is then denied and replay_contract_not_valid is recorded.
A VirtualClock starts at a fixed date, 2026-01-01T00:00:00+00:00 UTC, instead of the real time. A test gives the same answer on every day. startingAt() picks another start.
Step 3: Test backoff without sleeping
The scripted runtime decides every outcome, so the callable you pass is never invoked:
// The scripted runtime decides every outcome, so the callable you pass is never invoked here.
$unused = static fn(): string => throw new \LogicException('the scripted runtime replaces the callable');[$pipeline, $clock, $scheduler, $runtime, $events] = rig(false);
$runtime->enqueue(
DispatchScript::throwing(new UpstreamTimeout('503')),
DispatchScript::throwing(new UpstreamTimeout('503')),
DispatchScript::returning('ok'),
);
$start = $clock->peekMonotonic();
$result = $pipeline->execute($unused, context($pipeline, $clock));
$delays = array_map(static fn(Duration $d): int => intdiv($d->nanoseconds, 1_000_000), $scheduler->requestedDelays());
expect($result === 'ok', 'the third dispatch succeeds');
expect($runtime->dispatchCount() === 3, 'exactly three dispatches');
expect($delays === [100, 200], 'exponential backoff waited 100 ms, then 200 ms');
expect($clock->peekMonotonic() - $start === 300_000_000, 'the virtual clock moved by exactly those two delays');
expect(count($events->ofType(RetryScheduled::class)) === 2, 'two RetryScheduled events were recorded');The first two scripted dispatches throw the retryable error. The pipeline waits 100 ms and then 200 ms (exponential backoff, no jitter), and the third dispatch succeeds. The wait happened on the virtual clock, so nothing slept.
Step 4: Test circuit recovery by advancing time
[$pipeline, $clock, , $runtime] = rig(true);
$runtime->enqueue(
DispatchScript::throwing(new \DomainException('down')),
DispatchScript::throwing(new \DomainException('down')),
DispatchScript::returning('recovered'),
);
for ($i = 0; $i < 2; ++$i) {
try {
$pipeline->execute($unused, context($pipeline, $clock));
} catch (\DomainException) {
// two failures open the circuit
}
}
try {
$pipeline->execute($unused, context($pipeline, $clock));
} catch (AdmissionRejectedException $e) {
expect($e->reason()->value === 'circuit_open', 'the third call is refused while the circuit is open');
}
expect($runtime->dispatchCount() === 2, 'the refused call was never dispatched');
$clock->advance(Duration::seconds(31)); // no sleeping: time just moves
expect($pipeline->execute($unused, context($pipeline, $clock)) === 'recovered', 'a probe is admitted after the open duration');
expect($runtime->dispatchCount() === 3, 'the probe was dispatched');The output of the whole script:
-- backoff without sleeping
ok: the third dispatch succeeds
ok: exactly three dispatches
ok: exponential backoff waited 100 ms, then 200 ms
ok: the virtual clock moved by exactly those two delays
ok: two RetryScheduled events were recorded
-- circuit recovery by advancing the clock
ok: the third call is refused while the circuit is open
ok: the refused call was never dispatched
ok: a probe is admitted after the open duration
ok: the probe was dispatchedTips
- Seed randomness with
SeededRandomSourcewhen you test jitter, and compare two runs with the same seed. Assert the bound, not a hard-coded draw. Full jitter stays between zero and the computed delay. - Assert on observed dispatches with
dispatchCount(), not on what you think the pipeline did. A refused call must show zero dispatches. - Give each test a fresh pipeline. State lives in the backends, so a reused pipeline carries quota, budget and circuit state from one test into the next.
What a virtual test shows
A test that passes on virtual time shows that the policy logic does what you configured. It is no evidence about real timing, real transports or shared state. The helpers simulate settlement and delays. They do not model the interruption of a callback or the physical stop of a transport.
Was this page helpful?