Faking External Dependencies at the Boundary: WireMock.Net, Fake SMTP, FakeTimeProvider and Failure Scenarios
Fake external services at the HTTP and SMTP boundary rather than by mocking your own client: WireMock.Net for the payment and shipping APIs, Mailpit for email, FakeTimeProvider for time. With real runs we show verifying the outgoing request, retry, timeout and circuit breaker really kicking in, POST retries against a slow provider turning into four payments, and a global fake clock freezing Polly.
Version note: The code in this post was built and run with .NET 10, xUnit.net v3 4.0.3, WireMock.Net 2.31, Microsoft.Extensions.Http.Resilience 10.11, Microsoft.Extensions.TimeProvider.Testing 10.11, Testcontainers for .NET 4.16 and Mailpit 1.33.
Guess First
Placing an order in Bookstore touches three external systems: the payment provider's POST /v1/charges endpoint, the shipping company's POST /v1/shipments endpoint and the SMTP server that sends the order confirmation. The payment client is registered with AddStandardResilienceHandler; in the tests each attempt is limited to 500 milliseconds and the number of retries stays at the default of 3.
Now the payment provider slows down: it answers every request after 2 seconds with 200 OK and a chargeId. A customer orders two copies of "Clean Code". Three questions: what does the API return to the customer? How many requests does the payment provider see? And how many payments are recorded as successful on the provider's side?
The Answer: Four Requests, Four Successful Payments, Zero Orders
The real outcome of the test: within 2–4 seconds the API returned 503 Service Unavailable with a ProblemDetails titled "Payment provider unavailable", no order was created and the stock did not change. WireMock.Net's request log, however, had four requests, and the fake provider answered all four with 200 after two seconds. Each attempt was cut off at 500 milliseconds, the resilience pipeline treated that as a transient error and retried; the provider kept processing every request.
With a real payment provider, that means charging the customer's card four times while no order shows up in Bookstore. What caught it was not a mock but a fake server working at the HTTP level: only someone standing at the other end of the wire can see how many requests the client really sent and what body it sent. This post builds those fake ends.
Faking the Payment and Shipping APIs with WireMock.Net
WireMock.Net opens a real HTTP server inside the test process; it matches requests against rules, returns the response you defined and records every request it receives. WireMockServer.Start() picks a free port, and the address is read from the Url property. I added two servers to the factory, one for payment and one for shipping, and pointed the typed clients' base addresses at them with the UseSetting we saw in post 2:
public sealed class BookstoreApiFactory(SqlServerFixture sql, MailpitFixture mail) : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly string connectionString = sql.ConnectionStringFor($"Bookstore_{Guid.NewGuid():N}");
private Respawner? respawner;
public WireMockServer PaymentApi { get; } = WireMockServer.Start();
public WireMockServer ShippingApi { get; } = WireMockServer.Start();
// InitializeAsync as in post 3: migrations + Respawn setup
public async Task ResetDatabaseAsync()
{
await using SqlConnection connection = new(connectionString);
await connection.OpenAsync();
await respawner!.ResetAsync(connection);
// Clear the previous test's stubs and request log as well
PaymentApi.Reset();
ShippingApi.Reset();
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.UseSetting("ConnectionStrings:Bookstore", connectionString);
builder.UseSetting("Payments:BaseUrl", $"{PaymentApi.Url}/");
builder.UseSetting("Payments:ApiKey", "test-key");
// The defaults are a 2 s delay and a 10 s attempt timeout; shorten them for tests
builder.UseSetting("Payments:Resilience:Retry:Delay", "00:00:00.050");
builder.UseSetting("Payments:Resilience:AttemptTimeout:Timeout", "00:00:00.500");
builder.UseSetting("Shipping:BaseUrl", $"{ShippingApi.Url}/");
builder.UseSetting("Smtp:Host", mail.SmtpHost);
builder.UseSetting("Smtp:Port", mail.SmtpMappedPort.ToString());
// ConfigureTestServices as in post 2
}
public override async ValueTask DisposeAsync()
{
PaymentApi.Stop();
ShippingApi.Stop();
await base.DisposeAsync();
}
}
What matters is that nothing changes in the application code. PaymentClient runs with its real HttpClient, real serialization, real headers and real resilience pipeline; only the address on the other side is different. That is also why reading the resilience settings from configuration pays off: according to the table on Microsoft Learn, the standard handler's retry uses a 2-second exponential delay and its attempt timeout is 10 seconds. With those values, a failure scenario in a test would take minutes.
The shared setup cleans the database before every test, adds a customer and a book, and defines a successful response for the shipping API:
public async ValueTask InitializeAsync()
{
await factory.ResetDatabaseAsync();
using IServiceScope scope = factory.Services.CreateScope();
BookstoreDbContext db = scope.ServiceProvider.GetRequiredService<BookstoreDbContext>();
Customer customer = new() { Email = customerEmail, Name = "Ada" };
Book book = new() { Isbn = "978-0132350884", Title = "Clean Code", Price = 54.90m, Stock = 3 };
db.AddRange(customer, book);
await db.SaveChangesAsync(Ct);
(customerId, bookId) = (customer.Id, book.Id);
factory.ShippingApi
.Given(Request.Create().WithPath("/v1/shipments").UsingPost())
.RespondWith(Response.Create().WithStatusCode(201).WithBodyAsJson(new { trackingNumber = "TRK-1001" }));
}
private void PaymentApproves() =>
factory.PaymentApi
// The stub only matches requests that carry the right API key
.Given(Request.Create().WithPath("/v1/charges").UsingPost().WithHeader("X-Api-Key", "test-key"))
.RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new { chargeId = "ch_1001", status = "succeeded" }));
Verifying the request, not the response
The happy path test doesn't stop at the order response; it also opens and reads the request that went to the provider:
[Fact]
public async Task Order_charges_payment_ships_and_sends_confirmation()
{
PaymentApproves();
HttpResponseMessage response = await PlaceOrderAsync();
response.AssertCreatedAt("/api/orders/");
OrderDto order = (await response.Content.ReadFromJsonAsync<OrderDto>(Ct))!;
Assert.Equal("TRK-1001", order.TrackingNumber);
// The provider saw exactly one request; does its body match the contract?
ILogEntry charge = Assert.Single(factory.PaymentApi.LogEntries);
JsonElement body = JsonDocument.Parse(charge.RequestMessage.Body!).RootElement;
Assert.Equal(109.80m, body.GetProperty("amount").GetDecimal());
Assert.Equal("TRY", body.GetProperty("currency").GetString());
Assert.Equal(order.Reference, body.GetProperty("orderReference").GetString());
Assert.Equal(1, await StockAsync());
MailpitMessage email = Assert.Single(await mail.MessagesToAsync(customerEmail));
Assert.Equal($"Order {order.Reference} confirmed", email.Subject);
}
Two books at 54.90 TRY come to 109.80 TRY; this log is the only place that shows the amount, the currency and the order reference were written into the request correctly. Assert.Single matters too: it guarantees the payment request was sent exactly once on the happy path.
Failure Scenarios: Retry, Timeout and Circuit Breaker
How to build a resilience pipeline and which values to pick is the subject of the upcoming External Service Integration and Resilience series. This post's question is narrower: does the pipeline you built really kick in? WireMock.Net's scenario feature lets you return different responses to consecutive requests to the same endpoint:
[Fact]
public async Task Two_transient_failures_are_retried_and_the_order_succeeds()
{
factory.PaymentApi
.Given(Request.Create().WithPath("/v1/charges").UsingPost())
.InScenario("flaky-payments").WillSetStateTo("failed-once")
.RespondWith(Response.Create().WithStatusCode(500));
factory.PaymentApi
.Given(Request.Create().WithPath("/v1/charges").UsingPost())
.InScenario("flaky-payments").WhenStateIs("failed-once").WillSetStateTo("failed-twice")
.RespondWith(Response.Create().WithStatusCode(503));
factory.PaymentApi
.Given(Request.Create().WithPath("/v1/charges").UsingPost())
.InScenario("flaky-payments").WhenStateIs("failed-twice")
.RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new { chargeId = "ch_1002", status = "succeeded" }));
HttpResponseMessage response = await PlaceOrderAsync();
response.AssertCreatedAt("/api/orders/");
Assert.Equal([500, 503, 200], factory.PaymentApi.LogEntries.Select(e => (int)e.ResponseMessage.StatusCode!));
}
The last assertion proves the retry directly rather than indirectly: the provider saw three requests, the first two failed and the third succeeded. When the provider keeps returning 500, the request count becomes 4 (one attempt plus three retries) and the API returns 503. That test finished in 434 milliseconds; with the default 2-second delay the same scenario would take seconds.
The slow provider test is exactly the Guess First scenario. A subtlety came up here: on the first try the assertion failed with Expected: 4, Actual: 1. WireMock.Net writes a request to its log when the response completes; when the client gave up at 500 milliseconds, the 2-second responses on the server side were still running. The test was fixed to wait briefly for the log to fill up:
[Fact]
public async Task Slow_payment_provider_times_out_each_attempt()
{
factory.PaymentApi
.Given(Request.Create().WithPath("/v1/charges").UsingPost())
.RespondWith(Response.Create().WithStatusCode(200).WithDelay(TimeSpan.FromSeconds(2))
.WithBodyAsJson(new { chargeId = "ch_late", status = "succeeded" }));
Stopwatch stopwatch = Stopwatch.StartNew();
HttpResponseMessage response = await PlaceOrderAsync();
stopwatch.Stop();
await response.AssertProblemAsync(HttpStatusCode.ServiceUnavailable);
Assert.InRange(stopwatch.Elapsed, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4));
// WireMock logs a request when its response completes; wait for the delayed responses
await WaitForRequestsAsync(factory.PaymentApi, expected: 4);
}
To see the circuit breaker I used a separate test class, because the circuit's state lives in the host together with the pipeline; a circuit left open affects later tests on the same host. The default thresholds (at least 100 requests, 10% failures) are too high to trigger in a test; I lowered them for this test only with WithWebHostBuilder:
await using WebApplicationFactory<Program> app = factory.WithWebHostBuilder(builder => builder
.UseSetting("Payments:Resilience:CircuitBreaker:MinimumThroughput", "4")
.UseSetting("Payments:Resilience:CircuitBreaker:FailureRatio", "0.5")
.UseSetting("Payments:Resilience:CircuitBreaker:BreakDuration", "00:00:30"));
// ... a customer and a book are added, the provider answers every request with 500
HttpResponseMessage first = await client.PostAsJsonAsync("/api/orders", request, Ct);
int callsAfterFirst = factory.PaymentApi.LogEntries.Count();
Stopwatch stopwatch = Stopwatch.StartNew();
HttpResponseMessage second = await client.PostAsJsonAsync("/api/orders", request, Ct);
stopwatch.Stop();
Assert.Equal(HttpStatusCode.ServiceUnavailable, first.StatusCode);
Assert.Equal(HttpStatusCode.ServiceUnavailable, second.StatusCode);
Assert.Equal(4, callsAfterFirst);
// The circuit is open: the second order never reached the provider
Assert.Equal(callsAfterFirst, factory.PaymentApi.LogEntries.Count());
In the run I measured, the first order sent four requests to the provider, the second order got 503 in 13 milliseconds and the request count stayed at 4. On the shipping side a different decision is tested: when the shipping API returns 500, the order is still created with 201, TrackingNumber stays empty and a warning saying the shipment will be retried later goes to the log. That is a business decision the test encodes; the mechanism that will do the retry is not this post's topic.
Email: A Fake SMTP Server
An HTTP fake is useless for email; the app speaks SMTP. Mailpit is a small SMTP server that stores incoming mail and lets you read it through an HTTP API; it is MIT-licensed and its image requires no license acceptance. It runs as an assembly fixture with Testcontainers' generic container builder:
public sealed class MailpitFixture : IAsyncLifetime
{
private const int SmtpPort = 1025;
private const int HttpPort = 8025;
private readonly IContainer container = new ContainerBuilder("axllent/mailpit:v1.33.1")
.WithPortBinding(SmtpPort, assignRandomHostPort: true)
.WithPortBinding(HttpPort, assignRandomHostPort: true)
.WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPort(HttpPort).ForPath("/readyz")))
.Build();
private HttpClient api = new();
public string SmtpHost => container.Hostname;
public int SmtpMappedPort => container.GetMappedPublicPort(SmtpPort);
public async ValueTask InitializeAsync()
{
await container.StartAsync();
api = new HttpClient { BaseAddress = new Uri($"http://{container.Hostname}:{container.GetMappedPublicPort(HttpPort)}/") };
}
// Parallel classes share the same Mailpit: search by recipient instead of deleting
public async Task<List<MailpitMessage>> MessagesToAsync(string address)
{
MailpitSearchResult? result = await api.GetFromJsonAsync<MailpitSearchResult>(
$"api/v1/search?query={Uri.EscapeDataString($"to:\"{address}\"")}");
return result!.Messages;
}
public async ValueTask DisposeAsync()
{
api.Dispose();
await container.DisposeAsync();
}
}
Mailpit is shared across all test classes and the classes run in parallel. Deleting all mail at the start of a test (DELETE /api/v1/messages) could wipe out a mail another class just sent; it is the same problem as the parallel Respawn issue in post 3. That is why each test uses a unique customer email (ada-{Guid}@example.com) and reads only the mail sent to its own recipient through Mailpit's search API. We verify that no mail is sent when the payment is declined the same way: Assert.Empty(await mail.MessagesToAsync(customerEmail)).
The same idea applies to messaging, but the tools differ: RabbitMQ and MassTransit tests are in the Message Queue series post "Testing Message Queue Code: Testcontainers, the MassTransit Test Harness and Contract Tests", and tests with Redis are in the upcoming Redis series.
Time: FakeTimeProvider
The rule that an order can be cancelled within 30 minutes is already tested at unit level (post 1). The integration test's question is whether the endpoint really reads the time from TimeProvider. When I put FakeTimeProvider into the factory's ConfigureTestServices, the cancellation tests passed, but one of the order tests hung:
failed (canceled) Bookstore.Api.IntegrationTests.OrderPlacementTests.Two_transient_failures_are_retried_and_the_order_succeeds (20s 004ms)
Xunit.MicrosoftTestingPlatform.XunitException: Test execution timed out after 20000 milliseconds
The retry's 50-millisecond wait between attempts never ended. To confirm, I started the request in the same test and advanced the fake clock by one second at a time; the request only completed after the clock had been advanced ten times. The resilience pipeline uses the TimeProvider in the DI container for its delays and timeouts; when you freeze the clock, you freeze Polly's clock too. The fix is to give the fake clock only to the tests that need it:
public sealed class OrderCancellationTests(BookstoreApiFactory factory) : IClassFixture<BookstoreApiFactory>, IAsyncLifetime
{
private readonly FakeTimeProvider clock = new(new DateTimeOffset(2026, 11, 14, 9, 0, 0, TimeSpan.Zero));
private WebApplicationFactory<Program> app = null!;
public async ValueTask InitializeAsync()
{
await factory.ResetDatabaseAsync();
// The fake clock lives only in this class's host; the resilience pipeline uses it too
app = factory.WithWebHostBuilder(builder =>
builder.ConfigureTestServices(services => services.AddSingleton<TimeProvider>(clock)));
// ... a customer and an order timestamped with clock.GetUtcNow() are added
}
[Fact]
public async Task Order_can_be_cancelled_within_the_window()
{
clock.Advance(TimeSpan.FromMinutes(29));
HttpResponseMessage response = await CancelAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("Cancelled", (await response.Content.ReadFromJsonAsync<OrderDto>(Ct))!.Status);
}
[Fact]
public async Task Order_cannot_be_cancelled_after_the_window()
{
clock.Advance(TimeSpan.FromMinutes(31));
HttpResponseMessage response = await CancelAsync();
ProblemDetails problem = await response.AssertProblemAsync(HttpStatusCode.Conflict);
Assert.Equal("Cancellation window closed", problem.Title);
}
}
The rule from post 2 applies here too: WithWebHostBuilder is used because a different registration is needed. 29 and 31 minutes test the two points on either side of the limit; the exact boundary and the other combinations stayed in the unit tests. With this setup the whole solution passed with 23 tests in 17.6 seconds.
In Production
The four payments in Guess First are a real risk. The resilience page on Learn states plainly that the standard handler retries all HTTP methods by default and that retrying a POST that creates a record can duplicate data. For a call like a payment, either use the provider's idempotency key support and send a single key per order, or turn off POST retries with DisableForUnsafeHttpMethods(). Whichever you choose, add a "one payment on the provider side" assertion to this post's slow provider test, and verify from the WireMock log that the idempotency key really goes out in the header.
Don't mix test settings with production. The 50-millisecond retry delay and the 500-millisecond attempt timeout exist to speed up tests; production values stay in appsettings.json and are overridden with UseSetting only for the test host. Write a one-line comment in the factory saying which value changes and why, so that six months later nobody asks "why is it 500 ms in production?"
Tie the WireMock servers to the factory's lifetime and call Reset() before every test. If stubs and the request log carry over between tests, one test's "single request" assertion also counts the previous test's request.
Common Pitfalls
Mocking your own client and never testing the HTTP layer. Putting PaymentClient behind an interface and mocking it tests the order endpoint's workflow, but switches off serialization, headers, the base address and the resilience pipeline. To see this, I renamed the OrderReference property in ChargeRequest to Reference; the build passed, and a test with a mock would have passed too. The WireMock test failed:
failed Bookstore.Api.IntegrationTests.OrderPlacementTests.Order_charges_payment_ships_and_sends_confirmation (918ms)
Xunit.MicrosoftTestingPlatform.XunitException: System.Collections.Generic.KeyNotFoundException : The given key was not present in the dictionary.
at System.Text.Json.JsonElement.GetProperty(String propertyName)
The provider no longer receives the orderReference field; in production that would have meant payments that cannot be matched.
WireMock stubs drifting from the real API (contract drift). A stub is your assumption about the provider's API; when the provider makes a field required or changes an error code, the tests stay green. Build stubs from the provider's published documentation or from recordings of real requests to its sandbox, refresh those recordings periodically, and keep a few tests that run against the provider's sandbox in a separate pipeline step. Consumer-driven contract tests between services are in post 7 of the Microservices series; the contract between Angular and the API is the next post's topic.
Making the fake clock global. The hanging test above showed that FakeTimeProvider affects much more than the application's own code: resilience delays, timeouts, background services that use PeriodicTimer. Give the fake clock only to the host of time-related tests.
Cleaning a shared fake server. Deleting all mail in Mailpit or all stubs of a shared WireMock server at the start of a test also deletes the data of another class running in parallel. On a shared resource, filter data by the test's own key (unique email, unique order reference).
A Small Experiment
★☆☆ Narrow the payment stub with .WithBody(new JsonPathMatcher("$.orderReference")) so it only matches requests that have the orderReference field. Rename the property again and run the test; this time, on which line and with which message does the failure arrive?
★★☆ Add an Idempotency-Key header per order to PaymentClient and extend the slow provider test to verify that all four requests carry the same key.
★★★ Write a stub for the shipping API that returns 429 (Too Many Requests) with a Retry-After header. Add the standard resilience handler to the shipping client and measure, using the timestamps in the WireMock log, whether the pipeline honors the Retry-After duration.
The boundaries where the API looks outward are now under test. In the next post we look the other way: the API contract the Angular UI relies on. Generating .NET 10's OpenAPI 3.1 document at build time, catching breaking changes in CI, and when you need Pact.