Testing microservices in isolation (contract testing)

Daniel Dijkhuizen - Sep 9 - - Dev Community

In this article, I will not delve into testing principles, as there are already numerous resources available on that topic. Instead, I will share a test strategy based on my own experience for early and frequent testing of microservices with external service connectivity within a .NET development environment.

In many microservice-based projects, integration testing becomes significantly more challenging when a microservice is part of a distributed system and interacts with other microservices, whether they are within or outside the organization’s control. This often leads to shifting the testing of related functionality to end-to-end testing, which can place additional pressure on QA during this phase and violate testing principles such as shift-left and “F.I.R.S.T.” (Robert C. Martin – 2008 – “Clean Code: A Handbook of Agile Software Craftsmanship” – ISBN: 9780359582266).

Testing microservices in isolation that connect to external services can be managed by setting up Docker containers for these connections. However, this approach adds significant complexity to test projects and makes debugging failed test cases more difficult. Maintaining these Docker containers also requires resources and may be one of the first tasks to be neglected when a project is under pressure, such as from deadlines. This complexity can also make it harder for new team members to integrate into the project. Additionally, new defects can be easily introduced as developers may take shortcuts in testing due to the complexity of the test project or strategy. This approach also does not address testing connectivity to external services outside the development team’s or organization’s control.

As described in the article Integration tests in ASP.NET Core, microservice testing can be conducted early in the development stage as part of a test project using a WebApplicationFactory. However, this method does not cover connectivity to external services. Most articles on this topic describe integration testing as outlined in the aforementioned Microsoft article, still leaving out external service connectivity. A more thorough search may lead to articles on contract testing, such as Consumer-Driven Contract Testing (CDC). This article provides a good overview of integration testing with external service connectivity and test frameworks that can be used. However, the mentioned frameworks (Pact and Spring Cloud Contract) are not native to .NET and tend to be complex, making their implementation in a test project difficult and hard to maintain. Examples of Spring Cloud Contract in C# are hard to find, and Pact cannot be integrated with the WebApplicationFactory.

Recently, I developed a library to address the essential needs of consumer-driven contract testing in .NET. I released it as a NuGet package named Cympatic.Extensions.Stub. This package, compatible with .NET 6.0 and higher, can be seamlessly integrated into a custom WebApplicationFactory. It offers an in-memory test web host (StubServer) for external services, providing configurable responses to requests made to these services. Each request is recorded and can be validated as part of integration tests.

Usage

To use the StubServer, start by referencing the Microsoft.AspNetCore.Mvc.Testing and Cympatic.Extensions.Stub packages in your test project. Then, expose the implicitly defined Program class of the microservice project to the test project by doing one of the following:

  • Expose all internal types from the web app to the test project. Add the following to the microservice project’s .csproj file:
<ItemGroup>
  <InternalsVisibleTo Include="MyIntegrationTests" />
</ItemGroup>
Enter fullscreen mode Exit fullscreen mode
  • Make the Program class public by adding a partial class declaration at the end of the Program.cs file in the microservice project:
public partial class Program { }
Enter fullscreen mode Exit fullscreen mode

Setup

To set up the StubServer in a custom WebApplicationFactory, follow these steps:

  • Initialize the StubServer in the constructor of your custom WebApplicationFactory:
_stubServer = new StubServer();
Enter fullscreen mode Exit fullscreen mode
  • Add proxy methodes for adding responses to the StubServer:
public Task<ResponseSetup> AddResponseSetupAsync(ResponseSetup responseSetup, CancellationToken cancellationToken = default)
    => _stubServer.AddResponseSetupAsync(responseSetup, cancellationToken);

public Task AddResponsesSetupAsync(IEnumerable<ResponseSetup> responseSetups, CancellationToken cancellationToken = default)
    => _stubServer.AddResponsesSetupAsync(responseSetups, cancellationToken);
Enter fullscreen mode Exit fullscreen mode
  • Add proxy methods for reading requests from the StubServer:
public Task<IEnumerable<ReceivedRequest>> FindReceivedRequestsAsync(ReceivedRequestSearchParams searchParams, CancellationToken cancellationToken = default)
    => _stubServer.FindReceivedRequestsAsync(searchParams, cancellationToken);
Enter fullscreen mode Exit fullscreen mode
  • Add proxy methods for removing responses and received requests from the StubServer:
public Task ClearResponsesSetupAsync(CancellationToken cancellationToken = default)
    => _stubServer.ClearResponsesSetupAsync(cancellationToken);

public Task ClearReceivedRequestsAsync(CancellationToken cancellationToken = default)
    => _stubServer.ClearReceivedRequestsAsync(cancellationToken);
Enter fullscreen mode Exit fullscreen mode
  • Override the Dispose method since the StubServer is a disposable object:
protected override void Dispose(bool disposing)
{
    base.Dispose(disposing);

    if (disposing)
    {
        _stubServer.Dispose();
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Override the CreateHost method of the WebApplicationFactory to configure the base address of the used external service:
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    builder.ConfigureServices((context, services) =>
    {
        context.Configuration["ExternalApi"] = _stubServer.BaseAddressStub.ToString();
    });

    base.ConfigureWebHost(builder);
}
Enter fullscreen mode Exit fullscreen mode

For the full source file of a custom WebApplicationFactory, you can refer to this example.

Usage in a Test

Create a test class that implements a IClassFixture<> interface, referencing the custom WebApplicationFactory to share object instances across all tests within the class.

public class WeatherForecastTests : IClassFixture<ExampleWebApplicationFactory<Program>>
{
    private readonly ExampleWebApplicationFactory<Program> _factory;
    private readonly HttpClient _httpClient;

    public WeatherForecastTests(ExampleWebApplicationFactory<Program> factory)
    {
        _factory = factory;
        _httpClient = _factory.CreateClient();

        _factory.ClearResponsesSetupAsync();
        _factory.ClearReceivedRequestsAsync();
    }
}
Enter fullscreen mode Exit fullscreen mode

A typical test uses the factory to set up the response and process the request through the HttpClient. The request to the external service can then be validated.

[Fact]
public async Task GetAllWeatherForecasts()
{
    static IEnumerable<WeatherForecast> GetItems()
    {
        for (var i = 0; i < NumberOfItems; i++)
        {
            yield return GenerateWeatherForecast(i);
        }
    }

    // Arrange
    var expected = GetItems().ToList();
    var responseSetup = new ResponseSetup
    {
        Path = "/external/api/weatherforecast",
        HttpMethods = [HttpMethod.Get.ToString()],
        ReturnStatusCode = HttpStatusCode.OK,
        Response = expected
    };
    await _factory.AddResponseSetupAsync(responseSetup);

    var expectedReceivedRequests = new List<ReceivedRequest>
    {
        new(responseSetup.Path, responseSetup.HttpMethods[0], responseSetup.Query, responseSetup.Headers, string.Empty, true)
    };

    // Act
    var response = await _httpClient.GetAsync("/weatherforecast");

    // Assert
    var actual = await response.Content.ReadFromJsonAsync<IEnumerable<WeatherForecast>>();
    actual.Should().BeEquivalentTo(expected);

    var actualReceivedRequests = await _factory.FindReceivedRequestsAsync(new ReceivedRequestSearchParams("/external/api/weatherforecast", [HttpMethod.Get.ToString()]));
    actualReceivedRequests.Should().BeEquivalentTo(expectedReceivedRequests, options => options
        .Excluding(_ => _.Headers)
        .Excluding(_ => _.Id)
        .Excluding(_ => _.CreatedDateTime));
}
Enter fullscreen mode Exit fullscreen mode
  • Prepare the ResponseSetup:
    • Set Path and HttpMethods to a partial path and HttpMethod of the expected request used by the external service.
    • Set ReturnStatusCode to the desired HttpStatusCode.
    • Set Response to the desired response from the external service.
  • Add the ResponseSetup to the StubServer using the AddResponseSetupAsync method.

TIP: You can add multiple ResponseSetup instances in a single call using the AddResponsesSetupAsync method.

  • Process the request to the System Under Test (SUT) using the HttpClient.
  • Verify the response from the SUT.
  • Verify the request made to the external service:
    • Use the FindReceivedRequestsAsync method to locate the request made to the external service. The request can be found based on a combination of Path, HttpMethod, and Query.

NOTE: ReceivedRequest can only be found when there is a matching ResponseSetup.

Happy coding and testing! 🚀

.
Terabox Video Player