Scripts, not anecdotes - Deep dive into Simulation
Stop guessing how your system recovers from an outage. Use scripted simulations to turn resilience behavior into a deterministic, assertable fact.
TL;DR: Chaos testing tells you if something breaks; Simulation tells you how it recovers. By using virtual time and scripted sequences, NResilience lets you compress five minutes of failure and recovery into a few milliseconds of CPU time, producing a deterministic report you can actually assert against in CI.
The Flakiness of "Real Time"
If you want to test how a circuit breaker recovers from a 30-second outage, you have two bad options:
- Actually wait 30 seconds. Your CI pipeline slows to a crawl, and you'll eventually get a flaky test because a background thread took 1ms too long.
- Use a mock clock. You spend half your day fighting
Task.Delayand trying to figure out why your mock doesn't trigger the actual timer inside the library.
Simulation solves this by using Virtual Time.
How Simulation Works
At the heart of a simulation is a SimulationClock. It's a TimeProvider that doesn't track wall-clock time. Instead, it only moves when the driver tells it to move.
When the executor asks to "delay for 10 seconds," the simulation doesn't sleep. It queues a timer and immediately jumps the virtual clock forward to the next due event. Five simulated minutes cost whatever the arithmetic costs and no more.
Because everything runs on a single driver thread, there are no races. The sequence of events is identical every single time you run it with the same seed.
Scripting the Failure
While Chaos injects random noise, Simulation uses Sequence to define a precise script of outcomes. This allows you to model a specific disaster scenario: "The dependency fails twice, then slows down for three calls, then recovers."
Scripting a Callback
You can script any callback by defining a sequence of returns, throws, and delays.
using NResilience.Testing;
// Script a specific sequence of events
var calls = Sequence.For<string>()
.Throws(new IOException("Connection reset")) // Call 1: Fail
.Throws(new IOException("Connection reset")) // Call 2: Fail
.Delays(TimeSpan.FromSeconds(2)) // Call 3: Slow...
.Returns("Success!") // ...then succeed
.Returns("Success!"); // Call 4: Succeed
// Run this through your policy
var result = await policy.RunAsync(attempt => calls.NextAsync(attempt), ct);
Scripting the HTTP Layer
For HTTP clients, ScriptedHttpHandler lets you define a sequence of responses without needing a real network transport.
var transport = new ScriptedHttpHandler()
.Responds(HttpStatusCode.ServiceUnavailable, times: 2)
.Responds(HttpStatusCode.OK);
using var client = HttpResilience.CreateClient(policy, innerHandler: transport);
var response = await client.GetAsync(uri, ct);
// Assert that we actually made 3 attempts
Assert.Equal(3, transport.CallCount);
The Simulation Report: Facts, Not Anecdotes
The goal of a simulation isn't just to see if the code finishes; it's to measure the impact.
The SimulationReport captures the total cost of the resilience policy during the run. Instead of saying "it felt like it recovered quickly," you get hard numbers:
- Load Multiplier: Exactly how much extra traffic did the retries send to the dependency? (e.g., 1.1x)
- Availability: What fraction of caller requests actually succeeded?
- TimeToRecover: How long after the impairment ended did the system return to 100% success?
- P99 Latency: What was the worst-case experience for the caller?
// Example report output
// Simulation seed 42 over 00:05:00
// Calls 1000
// Succeeded 950
// Failed 50
// Availability 0.9500
// LoadMultiplier 1.120
// Amplification 1.050
// Latency p50 00:00:00.012
// Latency p99 00:00:02.450
// BreakerOpens 1
// TimeToRecover 00:00:04.200
Because the report is deterministic, you can turn it into a CI gate: Assert.True(report.LoadMultiplier <= 1.2);. If a change to your policy causes a retry storm, the test fails.
Chaos vs. Simulation
If you're wondering which to use:
- Use Chaos when you want to smoke-test your system. It's great for "Game Days" where you want to see if the app survives random instability.
- Use Simulation when you need to prove a specific behavior. It's for regression testing, tuning timeouts, and asserting that your retry budget actually bounds amplification.
Go deeper: Check out the Testing documentation to see how to combine Sequence with FakeTimeProvider for deterministic unit tests.