# The Test Mediator and the Transport Spy

A Test Mediator and transport spy control delivery and expose observations while production gateways run. Capture email content, script retries, and measure bounded response reads.

Published: 2026-06-15
Updated: 2026-09-27
Source: https://matlus.com/writing/the-test-mediator-and-the-transport-spy/
Tags: acceptance-testing, mocking, error-handling, test-mediator, transport-spy, factory-pattern, service-locator, gateway-pattern, design-patterns, architectural-patterns, csharp

---
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](/writing/functional-acceptance-testing-at-the-boundary/#the-email-problem-and-the-two-ways-to-handle-it).
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

> Diagram: Meridian keeps the production gateway and response handling. Its combined Test Mediator records the outgoing request and supplies a scripted transport response.

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:

<!-- Listing source: tests/MeridianOrdering.TestSupport/MediatorEmailService.cs; method: CreateHttpMessageHandler -->

```csharp
/// 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:

<!-- Listing source: tests/MeridianOrdering.TestSupport/MediatorEmailService.cs; method: HandleEmailServiceRequestAsync -->

```csharp
/// 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:

1. Simulated unreachability throws before a request is recorded.
2. Otherwise the method reads the body produced by the real gateway.
3. It deserializes the captured wire model and records the path and arrival time.
4. It constructs the scripted status, optional Retry-After header, and optional body.
5. 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:

<!-- Listing source: tests/MeridianOrdering.TestSupport/ServiceLocatorTesting.cs; method: CreateDomainFacade -->

```csharp
/// 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

> Diagram: For real delivery, preserve the original capture and redirect a forwarded copy. Compare the intended customer communication and the delivered message in the controlled inbox.

With a stable provider, such as SendGrid, the production arrangement can continue
past capture:

1. Prepare a unique, routable test address or alias and access to its inbox.
2. Capture the original customer recipient, subject, body, and order reference.
3. Preserve that capture unchanged. Replace the recipient in the forwarded
   request with the controlled delivery address.
4. Forward the request to the real provider and let it send the message.
5. Compare the original capture with the expected customer address and content.
6. Read this run's message from the controlled inbox within a bounded wait.
7. 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:

```csharp
/// 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:

<!-- Listing source: tests/MeridianOrdering.TestSupport/MediatorEmailService.cs; method: ResolveScriptedResponse -->

```csharp
/// 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:

<!-- Listing source: tests/MeridianOrdering.TestSupport/Asserters/AsserterConfirmationEmail.cs; method: AssertRetryWaitedForRetryAfter -->

```csharp
/// 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:

<!-- Listing source: tests/MeridianOrdering.TestSupport/MediatorEmailService.cs; method: Read -->

```csharp
/// 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.

---

[Previous article](/writing/assertions-that-verify-the-whole-outcome/) | [Series contents](/acceptance-testing/) | [Next article](/writing/refusals-failures-and-work-still-owed/)