Testing External Dependencies — Mocking HTTP and Third-Party Services
Test .NET services that depend on external HTTP APIs, FHIR servers, and third-party integrations: WireMock.NET for HTTP stubbing, Polly resilience testing, and contract testing patterns.
The External Dependency Problem
External dependencies in a clinical system:
→ NHS Patient Registry (FHIR R4 REST API)
→ MHRA Adverse Event Reporting API
→ SMS/Email gateway for ward notifications
→ Azure Cognitive Services for clinical document analysis
Testing against the real external service:
→ Slow (network latency + external processing)
→ Unreliable (external service may be down)
→ May cost money per call
→ May have no test environment for regulatory services
→ Cannot simulate failure scenarios (timeouts, 503s)
Solution: stub the HTTP layer with WireMock.NET
→ Real HTTP requests leave the application
→ WireMock intercepts them locally
→ Tests control what responses the "external service" returns
→ Failure scenarios (timeouts, 500s) are programmableWireMock.NET Setup
// NuGet: WireMock.Net
public sealed class FhirServerMock : IAsyncDisposable
{
private readonly WireMockServer _server;
public string BaseUrl { get; }
public FhirServerMock()
{
_server = WireMockServer.Start();
BaseUrl = _server.Url!;
}
// Set up a successful patient lookup response
public void SetupPatientFound(string patientId, object fhirPatient)
{
_server
.Given(Request.Create()
.WithPath($"/Patient/{patientId}")
.UsingGet())
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithHeader("Content-Type", "application/fhir+json")
.WithBodyAsJson(fhirPatient));
}
// Simulate patient not found
public void SetupPatientNotFound(string patientId)
{
_server
.Given(Request.Create()
.WithPath($"/Patient/{patientId}")
.UsingGet())
.RespondWith(Response.Create()
.WithStatusCode(404));
}
// Simulate upstream timeout
public void SetupTimeout(string patientId)
{
_server
.Given(Request.Create()
.WithPath($"/Patient/{patientId}")
.UsingGet())
.RespondWith(Response.Create()
.WithDelay(TimeSpan.FromSeconds(35)) // exceeds HttpClient timeout
.WithStatusCode(200));
}
public ValueTask DisposeAsync()
{
_server.Stop();
return ValueTask.CompletedTask;
}
}Integration Test with WireMock
public sealed class FhirPatientAdapterTests : IAsyncDisposable
{
private readonly FhirServerMock _mock = new();
private readonly FhirPatientAdapter _adapter;
public FhirPatientAdapterTests()
{
var httpClient = new HttpClient { BaseAddress = new Uri(_mock.BaseUrl) };
_adapter = new FhirPatientAdapter(httpClient);
}
[Fact]
public async Task GetPatientByIdAsync_PatientExists_ReturnsMappedPatient()
{
var fhirPatientId = "NHS-12345678";
_mock.SetupPatientFound(fhirPatientId, new
{
resourceType = "Patient",
id = fhirPatientId,
name = new[] { new { given = new[] { "Jane" }, family = "Doe" } },
identifier = new[] { new { value = "MRN001" } }
});
var result = await _adapter.GetPatientByIdAsync(fhirPatientId, CancellationToken.None);
result.Should().NotBeNull();
result!.FullName.Should().Be("Jane Doe");
result.Mrn.Should().Be("MRN001");
}
[Fact]
public async Task GetPatientByIdAsync_PatientNotFound_ReturnsNull()
{
_mock.SetupPatientNotFound("NHS-UNKNOWN");
var result = await _adapter.GetPatientByIdAsync("NHS-UNKNOWN", CancellationToken.None);
result.Should().BeNull();
}
[Fact]
public async Task GetPatientByIdAsync_FhirServerReturns500_ThrowsUpstreamException()
{
_mock.SetupServerError("NHS-12345678");
var act = () => _adapter.GetPatientByIdAsync("NHS-12345678", CancellationToken.None);
await act.Should().ThrowAsync<FhirUpstreamException>();
}
public ValueTask DisposeAsync() => _mock.DisposeAsync();
}Testing Polly Resilience Policies
// Test that retry and circuit breaker policies work correctly
[Fact]
public async Task GetPatientByIdAsync_TransientFailureThenSuccess_Retries()
{
var callCount = 0;
_mock.Server
.Given(Request.Create().WithPath($"/Patient/NHS-001").UsingGet())
.RespondWith(Response.Create()
.WithCallback(req =>
{
callCount++;
if (callCount < 3)
return new ResponseMessage { StatusCode = 503 }; // fail twice
return new ResponseMessage
{
StatusCode = 200,
BodyData = new BodyData { BodyAsJson = BuildFhirPatient() }
};
}));
var result = await _adapter.GetPatientByIdAsync("NHS-001", CancellationToken.None);
result.Should().NotBeNull();
callCount.Should().Be(3); // failed twice, succeeded on third attempt
}
[Fact]
public async Task GetPatientByIdAsync_CircuitBreakerOpen_ThrowsImmediately()
{
// Simulate 5 consecutive failures to open the circuit breaker
for (int i = 0; i < 5; i++)
_mock.SetupServerError("NHS-002");
// Trigger 5 failures
for (int i = 0; i < 5; i++)
{
try { await _adapter.GetPatientByIdAsync("NHS-002", CancellationToken.None); }
catch { }
}
// Circuit is now open — next call should throw immediately without hitting the server
_mock.SetupPatientFound("NHS-002", BuildFhirPatient()); // would succeed if called
var calls = _mock.Server.LogEntries.Count;
var act = () => _adapter.GetPatientByIdAsync("NHS-002", CancellationToken.None);
await act.Should().ThrowAsync<BrokenCircuitException>();
// Server was NOT called (circuit is open)
_mock.Server.LogEntries.Should().HaveCount(calls);
}Verifying Outbound Requests
// Verify the adapter sends the correct headers and request format to the external service
[Fact]
public async Task GetPatientByIdAsync_SendsAuthorizationHeader()
{
_mock.SetupPatientFound("NHS-001", BuildFhirPatient());
var adapter = BuildAdapterWithApiKey("test-api-key");
await adapter.GetPatientByIdAsync("NHS-001", CancellationToken.None);
// Verify the outbound request included the API key
var request = _mock.Server.LogEntries.Single().RequestMessage;
request.Headers.Should().ContainKey("Authorization");
request.Headers["Authorization"].Should().StartWith("Bearer test-api-key");
}
[Fact]
public async Task GetPatientByIdAsync_SendsFhirAcceptHeader()
{
_mock.SetupPatientFound("NHS-001", BuildFhirPatient());
await _adapter.GetPatientByIdAsync("NHS-001", CancellationToken.None);
var request = _mock.Server.LogEntries.Single().RequestMessage;
request.Headers["Accept"].Should().Be("application/fhir+json");
}Contract Testing (Consumer-Driven)
For internal services owned by the same organisation,
use Pact (consumer-driven contract testing) instead of WireMock:
Consumer (PrescriptionService) defines:
→ "I expect GET /Patient/{id} to return { name, mrn, wardId }"
→ This is the "contract" the consumer needs
Provider (PatientService) verifies:
→ "Our actual API satisfies the Pact contracts from all consumers"
→ Any change that breaks a contract fails the provider's CI build
WireMock vs Pact:
WireMock: you control both the stub and the test — no automatic contract verification
Pact: contracts are shared between teams — the provider must keep them satisfied
Use Pact when: multiple teams, explicit API contracts, automated drift detection
Use WireMock when: external service you don't control, or single-team serviceProduction issue I've seen: A team's FHIR patient adapter had no integration tests for failure scenarios. When the NHS Patient Registry started returning occasional 429 (rate limited) responses, the adapter had no retry logic and no tests that would have revealed the missing handling. The result: prescription lookups silently returned null during peak hours, and nurses saw "Patient not found" errors for valid patients. Adding WireMock tests for 429 responses drove implementation of exponential-backoff retry in the adapter, and the retries were verified to be correct via the test counting call attempts.
Key Takeaway
Use WireMock.NET to stub external HTTP services in integration tests — real HTTP client code runs, but responses are controlled locally. Test success paths, 404/500 error handling, and timeout scenarios explicitly. Verify that outbound requests include correct headers, authentication, and content types. Test Polly retry and circuit breaker policies by configuring WireMock to return transient failures. For internal service contracts owned by the same organisation, consider Pact for consumer-driven contract testing.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.