The ordering application must send a confirmation to the customer. Our test needs to establish that the recipient, subject, and body are correct. It also needs to avoid sending test messages to real customers.
About the examples: Meridian Ordering is a private training reference project. The inline listings illustrate the techniques; the full C# and Python repositories are not currently public.
That gives us two practical problems: control outbound delivery and retain the values needed for assertions. A Test Mediator and a transport spy provide the communication between the test and that observation point.
We will use the same email example as the main article. Meridian deliberately has no separate running email service because it is a teaching application. It captures the request and supplies an arranged response. We will also explain the production arrangement with a stable real provider and a controlled inbox.
The goal is confidence to go to production after the business requirements, acceptance criteria, functional requirements, and non-functional requirements are established and the scenarios and expectations verified as correct. The observation boundary chosen for each scenario determines its available evidence.
Give the test a two-way communication path
- The Test Mediator carries instructions and exposes observations for assertions.
- The spy sits at the selected boundary, follows those instructions, and records what reached it.
Arrange can specify a service-unavailable response. The spy turns that instruction into the HTTP response seen by the production gateway. After Act, the test reads the recorded recipient, subject, body, path, and request timing.
The PWI pattern has two implementation shapes. A separate carrier can be shared by several spies. Alternatively, one service’s Test Mediator can contain its spy. Meridian uses the second shape: TestMediatorEmailService contains a private HTTP message handler. The test uses its public instructions and captures without depending on how internal production classes pass the request along.
This Test Mediator is the testing pattern described in the PWI chapter. It is distinct from the GoF Mediator pattern.
Capture the confirmation at the transport boundary
The real email gateway constructs an HTTP request. Its HttpClient receives a handler supplied by the service locator. In the test graph, that handler comes from TestMediatorEmailService:
/// Arrange: create the handler paired with this Test Mediator.
public HttpMessageHandler CreateHttpMessageHandler()
{
return new SpyingHttpMessageHandler(this);
}
The private SpyingHttpMessageHandler forwards SendAsync to its owning Test Mediator. Here is the complete method that processes the request:
/// Observe: capture the wire values and construct the arranged response.
private async Task<HttpResponseMessage> HandleEmailServiceRequestAsync(HttpRequestMessage emailServiceRequest)
{
if (_simulateUnreachable)
{
throw new HttpRequestException("the email service endpoint is unreachable (simulated by TestMediatorEmailService)");
}
string requestContentJson = emailServiceRequest.Content is null
? string.Empty
: await emailServiceRequest.Content.ReadAsStringAsync();
ScriptedEmailServiceResponse scriptedResponse;
lock (_captureLock)
{
scriptedResponse = ResolveScriptedResponse();
_capturedEmailRequests.Add(JsonSerializer.Deserialize<CapturedEmailRequest>(requestContentJson)!);
_capturedRequestPaths.Add(emailServiceRequest.RequestUri!.AbsolutePath);
_capturedRequestArrivalSeconds.Add(Stopwatch.GetElapsedTime(0).TotalSeconds);
}
var emailServiceResponse = new HttpResponseMessage((HttpStatusCode)scriptedResponse.StatusCode);
if (scriptedResponse.RetryAfterSeconds is not null)
{
emailServiceResponse.Headers.Add("Retry-After", scriptedResponse.RetryAfterSeconds.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
}
if (scriptedResponse.ResponseBody is not null)
{
int responseBodyIndex;
lock (_captureLock)
{
responseBodyIndex = _capturedResponseBodyBytesReadByResponse.Count;
_capturedResponseBodyBytesReadByResponse.Add(0);
}
emailServiceResponse.Content = new StreamContent(new ReadCountingResponseBodyStream(this, responseBodyIndex, scriptedResponse.ResponseBody));
}
return emailServiceResponse;
}
Read the sequence:
- Simulated unreachability throws before a request is recorded.
- Otherwise the method reads the body produced by the real gateway.
- It deserializes the captured wire model and records the path and arrival time.
- It constructs the scripted status, optional Retry-After header, and optional body.
- It returns the response to the production gateway.
The typed capture maps recipient_email_address, subject, body, and order_reference. The Test Mediator source contains those models, constructors, the private handler, response sequencing, and the lazy response stream with no omitted implementation.
Meridian’s normal success arrangement returns HTTP 200. Nothing is forwarded to an email provider. The actual email values are the captured submission, compared with the arranged customer’s address and expected confirmation.
Create the operation and its capture together
The test must inspect the capture object used by its own facade. Creating one for the facade and another for assertions would leave the assertions reading an object that observed nothing.
Here is the complete paired factory:
/// Arrange: return the facade and observation objects wired into it.
public static (DomainFacade DomainFacade, TestMediatorEmailService TestMediatorEmailService, LoggerTesting LoggerTesting) CreateDomainFacade(
IReadOnlyDictionary<string, string>? overrideByEnvironmentVariableName = null,
TestMediatorEmailService? testMediatorEmailService = null,
LoggerTesting? loggerTesting = null)
{
var pairedTestMediatorEmailService = testMediatorEmailService ?? new TestMediatorEmailService();
var pairedLoggerTesting = loggerTesting ?? new LoggerTesting();
var domainFacade = new DomainFacade(
new ServiceLocatorTesting(overrideByEnvironmentVariableName, pairedTestMediatorEmailService, pairedLoggerTesting));
return (domainFacade, pairedTestMediatorEmailService, pairedLoggerTesting);
}
The testing service locator retains the real configuration provider, supplies the Test Mediator’s handler, and supplies the capturing logger. Scenario configuration overrides are layered on shared configuration rather than changing process settings for other tests.
A fresh Test Mediator starts with a fresh script and captures. Use one per scenario, or deliberately share it across the Acts of a scenario needing cumulative observations. Independent tests get independent instances. Capture writes are locked; tests normally take snapshots after awaiting the operation.
Preserve production behavior above the handler
The seam retains the facade, orchestration, composer, gateway, HTTP request construction, response classification, retry policy, and error translation. The production gateway shows those paths. Database writes and broker publication use real infrastructure.
Refactoring an internal class behind the same observable contracts should not require changing assertions about confirmation content. Changing the email protocol requires updating the capture model or response arrangement and independently verifying that support. The spy’s wire contract is maintained code.
Capture establishes what the gateway submitted under the arranged conditions. Physical network acceptance and inbox arrival require real-delivery observations.
Redirect real delivery and read the inbox
With a stable provider, such as SendGrid, the production arrangement can continue past capture:
- Prepare a unique, routable test address or alias and access to its inbox.
- Capture the original customer recipient, subject, body, and order reference.
- Preserve that capture unchanged. Replace the recipient in the forwarded request with the controlled delivery address.
- Forward the request to the real provider and let it send the message.
- Compare the original capture with the expected customer address and content.
- Read this run’s message from the controlled inbox within a bounded wait.
- Compare the delivered subject and body and verify its controlled destination.
The original-recipient comparison establishes that the application selected the correct customer. The inbox establishes what arrived after redirection. Keeping both addresses explicit prevents successful redirection from concealing an original recipient error. The subject and body are checked at both observations.
Parallel runs need distinct delivery identities and a way to select their own messages, such as delivery address and order reference together. Each run owns cleanup. An arbitrary email-shaped string does not provision an inbox; addresses must belong to infrastructure the environment can receive and query. Provisioning details can remain outside the order scenario.
This is the production approach described by the author. Meridian implements capture and an arranged response. These listings contain no SendGrid adapter or inbox client, and this article does not claim an executed real-delivery example. Its steps explain the responsibilities that implementation must fulfill.
The delivery choice is separate from the pattern’s implementation shape. A carrier with separate spies or a combined service-specific Test Mediator can support either capture-only scenarios or capture-and-forward scenarios.
Script failures to verify recovery and outstanding work
Dependencies rarely fail on demand in the precise way a scenario requires. The handler arranges response conditions while production performs its actual handling. Meridian’s recovery scenario uses this script:
/// Arrange: refuse twice, then accept the gateway's third request.
const int ExpectedAttemptsBeforeAcceptance = 3;
var testMediatorEmailService = new TestMediatorEmailService(
scriptedResponses:
[
new ScriptedEmailServiceResponse(StatusCode: TestMediatorEmailService.HttpStatusServiceTooBusy),
new ScriptedEmailServiceResponse(StatusCode: TestMediatorEmailService.HttpStatusInternalServerError),
new ScriptedEmailServiceResponse(StatusCode: TestMediatorEmailService.HttpStatusOk),
]);
The complete recovery test also arranges the order, calls the public operation, reads the order and action state, checks three captured requests and their content, verifies Completed states and timestamps, and checks that no exception was logged. Its method name uses delivered for arranged service acceptance; it has no inbox observation.
Here is the complete sequence-selection method:
/// Observe: select the next response and repeat the final entry thereafter.
private ScriptedEmailServiceResponse ResolveScriptedResponse()
{
// Past the end of the script, the last answer repeats - so a one-entry script
// is "always this", and a sequence ends in whatever it should settle on.
int scriptedResponseIndex = Math.Min(_capturedEmailRequests.Count, _scriptedResponses.Count - 1);
return _scriptedResponses[scriptedResponseIndex];
}
The script must contain at least one response. A single failing entry represents continuing failure. A sequence ending in success represents recovery. The failure scenarios cover permanent rejection, transient exhaustion, unreachability, and Retry-After. They compare the accepted order, remaining action obligations, captures, and diagnostics required by each scenario.
CapturedAttemptCount counts requests recorded at the simulated endpoint. Unreachability throws before capture, so the count stays zero despite gateway attempts. Keep gateway attempts and endpoint arrivals distinct when interpreting the observed count.
For Retry-After, the spy records arrival times and the assertion checks the gap:
/// Assert: compare arrival spacing with the instructed minimum wait.
public static void AssertRetryWaitedForRetryAfter(
double expectedMinimumWaitSeconds, IReadOnlyList<double> actualRequestArrivalSeconds)
{
const int MinimumArrivalsToMeasureAWait = 2;
Assert.True(
actualRequestArrivalSeconds.Count >= MinimumArrivalsToMeasureAWait,
$"{RetryAfterAssertionFailedHeader}" +
$"Expected at least {MinimumArrivalsToMeasureAWait} attempts so the wait between them can be measured, " +
$"but the email service received {actualRequestArrivalSeconds.Count}.\n");
double actualWaitSeconds = actualRequestArrivalSeconds[1] - actualRequestArrivalSeconds[0];
Assert.True(
actualWaitSeconds >= expectedMinimumWaitSeconds,
$"{RetryAfterAssertionFailedHeader}" +
$"Field: seconds waited before the retry\n" +
$"Expected: at least {expectedMinimumWaitSeconds} (the Retry-After the service instructed)\n" +
$"Actual: {actualWaitSeconds:F3}\n" +
$"\nWhat could be wrong: the Retry-After header is being ignored, or jitter is being subtracted\n" +
$"from it instead of added to it - either way the system comes back sooner than it was told to.\n");
}
Arrival spacing includes preceding response processing and scheduling. It observes endpoint request spacing rather than isolating one sleep call. The scenario verifies the required minimum spacing through the production retry path.
Observe bytes consumed when bounded reads matter
A provider can return a huge error body. A short logged excerpt does not prove that the gateway read only a bounded prefix; it might buffer everything and then truncate it.
ReadCountingResponseBodyStream produces leading text and filler on demand, records bytes read, and can throw after a configured position. Here is its complete span-based read method:
/// Observe: produce bytes lazily and record how many the caller consumed.
public override int Read(Span<byte> buffer)
{
long readableEnd = _connectionDropsAfterByteCount ?? _totalByteCount;
if (_position >= readableEnd && readableEnd < _totalByteCount)
{
throw new IOException("the connection dropped mid-body (simulated by TestMediatorEmailService)");
}
int byteCount = (int)Math.Min(buffer.Length, Math.Min(readableEnd, _totalByteCount) - _position);
for (int index = 0; index < byteCount; index++)
{
long bodyOffset = _position + index;
buffer[index] = bodyOffset < _leadingBytes.Length ? _leadingBytes[bodyOffset] : FillerByte;
}
_position += byteCount;
_testMediatorEmailService.RecordResponseBodyBytesRead(_responseBodyIndex, byteCount);
return byteCount;
}
The complete source includes its asynchronous read implementations and separate counters for each scripted response. The body-handling scenarios cover oversized failures, escaped credentials, an excerpt ending inside an emoji, a dropped connection, and an oversized accepted response whose body should not be read. They compare per-response byte consumption and resulting diagnostic text.
Give the Test Mediator explicit instructions and narrowly named observations that assertions need. Each addition makes a particular requirement observable while keeping the public operation and production behavior in the test.
