Consumer Driven Contract Testing Using PactNet

Introduction

Often when your application communicates with other applications, you need to ensure that the application messages conform to a particular contract. For example, while communicating with web APIs, one must ensure that the client and server agree on request and response message structures/schema. Consumer-driven contract test helps assert this by letting consumers communicate the contract to providers and ensuring providers conform to the contract. 
Pact is a consumer-driven contract testing tool that automates contract generation using code-based tests. In this article, we will look at a basic example of how to set up contract testing with Pact.

Benefits of Contract Testing

  • Early detection of compatibility issues: Contract testing helps identify incompatibilities between services during development, preventing issues from surfacing in production.
  • Faster development iterations: Teams can work in parallel without waiting for other services to be fully implemented, leading to faster iteration cycles.
  • Improved collaboration: Clear contract definitions foster better communication and understanding between teams, promoting smoother collaboration.
  • Enhanced maintainability: Contracts act as living documentation, making it easier to maintain and evolve services over time.

Contract Testing vs Integration Testing

While both contract testing and integration testing may seem similar, they differ in scope and focus. Integration testing validates the interactions between different components, ensuring they work together correctly, whereas contract testing is more specific, focusing on the interactions between individual services and verifying that they adhere to the agreed-upon contracts. It ensures that the contract between a service provider and consumer is satisfied.

Using PactNet for Consumer-Driven Contract Testing

Let's explore how to use PactNet for contract testing in .NET Core 6.0 with a producer and a consumer API.

Create a Producer API

Create a new .NET Core Web API project to act as the producer API. In the producer API, implement an endpoint that the consumer API will use. For example, create an endpoint to retrieve data.

// DTO
public class DataResponse
{
	public IEnumerable<string> Data { get; set; }
}

// Controller
[Route("api/[controller]")]
[ApiController]
public class DataController : ControllerBase
{
    [HttpGet]
    public ActionResult<DataResponse> Get()
    {
        // Your data retrieval logic here
        return new DataResponse { Data = new List<string> { "Value1", "Value2" } };
    }

}

Create a Consumer API and a Pact Test

Create another .NET Core Web API project to act as the consumer API and add a service to consume the producer API endpoint.

// DTO
public class ProviderResponse
{
	public IEnumerable<string> Data { get; set; }
}

// Consumes Provider API
public class ProviderService
{
    private readonly HttpClient _client;

    public ProviderService(HttpClient client)
    {
        _client = client ?? throw new ArgumentNullException(nameof(client));
        if (_client.BaseAddress is null)
        {
            throw new ArgumentNullException(nameof(_client.BaseAddress));
        }
    }

    public async Task<ProviderResponse> GetData()
    {
        var httpResponse = await _client.GetAsync("/api/data");
        if (httpResponse.IsSuccessStatusCode)
        {
            return JsonSerializer.Deserialize<ProviderResponse>(
                    await httpResponse.Content.ReadAsStringAsync(),
                    new JsonSerializerOptions() { PropertyNameCaseInsensitive = true });
        }
        return new ProviderResponse() { Data = Enumerable.Empty<string>()};
    }
}

Create an xunit test project for the consumer API and install the PactNet NuGet package into the test project.

Note: To disable pactnet telemetry, set PACT_DO_NOT_TRACK environment variable to true.

Add a test to verify the contract with the producer API.

public class ConsumerPactTest
{
    private readonly IPactBuilderV3 pactBuilder;

    public ConsumerPactTest()
    {
        var pact = Pact.V3("Consumer API", "Provider API", new PactConfig());
        pactBuilder = pact.WithHttpInteractions();
    }

    [Fact]
    public async Task EnsureProducerHonorsContract()
    {
        pactBuilder
            .UponReceiving("A GET request to /api/data")
                .Given("There is available data")
                .WithRequest(HttpMethod.Get, "/api/data")
            .WillRespond()
                .WithStatus(HttpStatusCode.OK)
                .WithHeader("Content-Type", "application/json; charset=utf-8")
                .WithJsonBody(new
                {
                    data = new List<string> { "Value1", "Value2"}
                });

        await pactBuilder.VerifyAsync(async ctx =>
        {
            var httpClient = new HttpClient();
            httpClient.BaseAddress = ctx.MockServerUri;
            var provider = new ProviderService(httpClient);
            var response = await provider.GetData();
            Assert.NotEmpty(response.Data);
        });
    }
}

Generate a Pact File

A Pact file is a JSON file that captures the contract between a consumer and a provider in the context of consumer-driven contract testing. To generate the pact file, run the consumer tests. The pact file will be automatically created in pact folder inside the consumer API's test project. Here's what my pact file looks like.

{
  "consumer": {
    "name": "Consumer API"
  },
  "interactions": [
    {
      "description": "A GET request to /api/data",
      "providerStates": [
        {
          "name": "There is available data"
        }
      ],
      "request": {
        "method": "GET",
        "path": "/api/data"
      },
      "response": {
        "body": {
          "data": [
            "Value1",
            "Value2"
          ]
        },
        "headers": {
          "Content-Type": "application/json; charset=utf-8"
        },
        "status": 200
      }
    }
  ],
  "metadata": {
    "pactRust": {
      "ffi": "0.4.0",
      "models": "1.0.4"
    },
    "pactSpecification": {
      "version": "3.0.0"
    }
  },
  "provider": {
    "name": "Provider API"
  }
}

Verify the Contract

Create a new Xunit test project for the producer API and add PactNet nuget package. Create a new folder called pacts inside the test project and copy the pact file from the consumer API test project to the producer API test project's pact folder. In this example, we are copying the file manually to keep things simple, but there are better options for managing the pact file, like using a pact broker.

We cannot use TestServer or WebApplicationFactory to write contract tests in the provider API test projects because they use an in-memory host that PactNet cannot access. Hence we need to write a custom test fixture to host the api on a local TCP port so that PactNet can make API calls to run contract verification.

// Test fixture inside the producer api test project
public class ProducerWebApiTestServer : IDisposable
{
    private readonly IHost server;
    public string ServerUrl { get { return "http://localhost:9050"; } }

    public ProducerWebApiTestServer()
    {

        server = Host.CreateDefaultBuilder()
                        .ConfigureWebHostDefaults(webBuilder =>
                        {
                            webBuilder.UseUrls(ServerUrl);
                            webBuilder.UseStartup<Startup>();
                        })
                        .Build();

        server.Start();
    }

    private bool disposedValue = false;

    protected virtual void Dispose(bool disposing)
    {
        if (!disposedValue)
        {
            if (disposing)
            {
                server.StopAsync().GetAwaiter().GetResult();
                server.Dispose();
            }

            disposedValue = true;
        }
    }

    public void Dispose() => Dispose(true);
}

Also, in order to see the output of PactNet inside xunit console, we need to write a custom xunit outputter.

// Custom outputter inside the producer api test project
using PactNet.Infrastructure.Outputters;
using Xunit.Abstractions;

namespace Producer.Test;

public class XunitOutput : IOutput
{
    private readonly ITestOutputHelper _output;

    public XunitOutput(ITestOutputHelper output)
    {
        _output = output;
    }


    public void WriteLine(string line)
    {
        _output.WriteLine(line);
    }
}

Now that we have all the setup ready, let's write a test to verify the contract inside the producer API test project.

public class VerifyPactWithConsumer : IClassFixture<ProducerWebApiTestServer>
{
    private readonly ProducerWebApiTestServer _testServer;
    private readonly ITestOutputHelper _output;

    public VerifyPactWithConsumer(ProducerWebApiTestServer testServer, ITestOutputHelper output)
    {
        _testServer = testServer;
        _output = output;
    }

    [Fact]
    public void VerifyPact()
    {
        var config = new PactVerifierConfig
        {
            LogLevel = PactLogLevel.Information,
            Outputters = new List<IOutput>()
            {
                new XunitOutput(_output)
            }
        };

        string pactPath = Path.Join("..",
                                    "..",
                                    "..",
                                    "pacts",
                                    "Consumer API-Provider API.json");

        IPactVerifier verifier = new PactVerifier(config);
        
        verifier
                .ServiceProvider("Profile API", new Uri(_testServer.ServerUrl))
                .WithFileSource(new FileInfo(pactPath))
                .WithRequestTimeout(TimeSpan.FromMinutes(20))
                .Verify();
    }
}

Run the test, and you will be able to see it passing along with the PactNet logs in the xunit console.

Validate the Contract Test Fails When Contract is Breached

Let's update the producer API endpoint by adding a required parameter to the GetData action. Note that this is a breaking change and is breaching the contract with the consumer API.

[Route("api/[controller]")]
[ApiController]
public class DataController : ControllerBase
{
    [HttpGet]
    public ActionResult<DataResponse> Get([Required] int pageNo)
    {
        // Your data retrieval logic here
        return new DataResponse { Data = new List<string> { "Value1", "Value2" } };
    }

}

Run the producer API test. It should fail because the producer API is not honoring the contract. You are also able to see the reason for failure in the xunit console.

Code Output

The test would also fail if you change the response schema or format.

This is also the reason why we call it a consumer-driven contract test - because the contract, once established, cannot be changed until all consumers are ready to accept the change. Consumers are responsible for defining the contract, and producers are responsible for honoring the contract.

Conclusion

Embracing consumer-driven contract testing with PactNet can lead to more resilient microservice architectures, facilitating seamless collaboration between teams and driving faster application delivery.

References


Similar Articles