Skip to the guide
All guides

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.

app Reqtide PHP 0.1.0checked reading 7 minutes

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

ClassPurpose
VirtualClockA 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.
SeededRandomSourceA RandomSource over Random\Randomizer with an Mt19937 seed. The same seed gives the same sequence.
ScriptedRandomSourceA 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.
VirtualDelaySchedulerA 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.
RecordingEventSinkAn EventSink that stores events (events(), ofType()). throwOnEmit($e, $times) throws after recording, for failure-isolation tests.
ControlledRuntimeAdapterA 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.
DispatchScriptImmutable behavior for one dispatch (returning, throwing, callingOperation, advancing, requestingCancellation).
ControlledExecutionThe 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 RecordingPreparedAttemptAn 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 DelayRecordRead-only records that the adapter and the scheduler expose.
The classes in 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.

PHP
/** 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

PHP
/** 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:

PHP
// 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');
PHP
[$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

PHP
[$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:

Text
-- 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 dispatched

Tips

  • Seed randomness with SeededRandomSource when 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.