Learnixo

Integration Testing in ASP.NET Core · Lesson 5 of 5

Testing External Service Integrations

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 programmable

WireMock.NET Setup

C#
// 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

C#
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

C#
// 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

C#
// 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 service

Production 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.