All posts

Contract testing on .NET - PactNet 4 after WireMock
Contract testing on .NET - PactNet 4 after WireMock

Polski

Contract testing on .NET - PactNet 4 after WireMock

July 2022: consumer-driven contracts after HTTP stubs. PactNet 4.1.0 (Rust, spec v3) vs WireMock. Pact dates to 2013; what is new is the .NET 4.x stack.

.NET

Introduction

In May last year, I described how I isolate myself from someone else’s API with WireMock in a container. That post ended with a paragraph that now reads like a note to myself: I wrote that a stub is my idea of how the partner responds, and that if the partner changes the price field to grossPrice, my tests will stay green while production breaks. I said then that contract testing in the style of Pact was outside the scope of that post.

I am returning to it today because the problem is no longer theoretical. The scenario I deal with every few months always looks the same. The integration test suite is green because it talks to stubs. The deployment to a shared environment fails because the provider changed the response shape. I go to the logs, then to the provider’s repository, and then someone says in chat that it was a “minor, backward-compatible change.” It was backward-compatible from their perspective because they do not know which fields I use. They do not know because I never recorded that information in a form their pipeline could read.

That is what is missing between a stub and integration. A stub isolates me from provider instability, but it tells the provider nothing about my expectations. Consumer-driven contracts are exactly that missing half: I, as the consumer, record what I need, and the provider replays it on their CI to learn that they have broken the contract before I learn about it from production.

The versions I use throughout this post are .NET 6, PactNet 4.1.0, and xUnit 2.4.1. The demo is synthetic, with two services on localhost: the consumer, Order API, and the provider, Inventory API. No real payloads and no project names.

Consumer-driven contracts are not a new framework

Before getting into the code, there is one point worth stating explicitly because it keeps coming up incorrectly in conversations. Contract testing was not invented this year, and Pact is not new in 2022.

Ian Robinson described the idea itself in the article Consumer-Driven Contracts in June 2006, sixteen years ago. Pact, as an implementation of that idea, was created in 2013 at realestate.com.au with DiUS, and implementations for more than a dozen languages have grown around it since then. If someone says they tried Pact on .NET in 2019 and found it unpleasant, they are probably right and not making it up.

Something else is new, and it is the only new part this post is about: the .NET layer. PactNet 4.x rewrites the library around a Rust core instead of the old Ruby core. Version 4.0.0 was released on June 6, and 4.1.0 came out two days ago, on July 13. That change is why I am returning after a year to a subject I had previously set aside.

It is also worth knowing that there are two schools of thought, and they solve slightly different problems. The interaction-first school, represented by Pact, creates the contract from consumer tests, so it describes only the fields and paths the consumer actually uses. The spec-first school, represented by tools such as Specmatic with OpenAPI, treats the specification as the contract and turns it into a test and a stub. The first says, “check whether you still deliver what matters to me.” The second says, “check whether you comply with the specification we both agreed on.” On .NET, I choose Pact because I have a consumer and its tests, but I do not have a negotiated OpenAPI specification that both sides actually maintain.

WireMock stays, and the contract is added

This is not a choice between “stub or contract.” They answer different questions, and I keep both.

WireMock answers the question, “how do I test my code when the provider is unavailable or I cannot make it return a 503?” I control the response, including delays, an empty body, and errors. That is both its strength and its limitation, because when I construct the response, whether it matches reality depends on my memory.

A contract answers the question, “how can the provider know that what I expect still works?” The same expectations that remained in my repository as stubs are sent to the provider’s pipeline and replayed there against the real implementation.

The practical division that works for me is this. Error paths, timeouts, and unusual states stay in WireMock because the provider is not required to reproduce them on demand, and a contract for “timeout after three seconds” makes no sense. The shape of the happy path and the few variants I genuinely support in the code go into the contract. A contract also does not replace a real database started from a test. A database container tests whether my own code saves data correctly. A contract tests whether two services still understand each other. These are two different layers, and mixing them produces a test that is both slow and imprecise.

PactNet 4 on .NET 6

I start with the consumer-side test project. The package list is shorter than it was a year ago, and that is the first piece of good news:

<PropertyGroup>
  <TargetFramework>net6.0</TargetFramework>
  <ImplicitUsings>enable</ImplicitUsings>
  <Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
  <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
  <PackageReference Include="PactNet" Version="4.1.0" />
  <PackageReference Include="xunit" Version="2.4.1" />
  <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
</ItemGroup>

There is one PactNet package, with no operating-system-specific variants. In the 3.x line, whose latest version was 3.0.2 from October last year, I had to install PactNet.Windows or the equivalent package for my system. The native library now ships in a single NuGet package, which is why that package is more than twenty megabytes. I pay that cost once during restore.

Other changes introduced in 4.0.0 that make a difference in daily work:

  • The mock server runs inside the test process, so an interrupted run no longer leaves ruby.exe processes behind. Anyone who has dealt with this knows how much time it can take to discover why a port is occupied.
  • The port is assigned automatically. I do not have to reserve it or ensure that two test projects on the same agent do not get in each other’s way.
  • The specification is Pact v3 rather than v2. In practical terms, this means, among other things, a list of provider states instead of a single string.
  • The API is fluent and is built per test rather than once per class.

Version 4.1.0 added two things. Both are narrow, but worth knowing about: support for request and response bodies other than JSON, and the ability to publish verification results for URI sources. The first is useful when a provider returns plain text or CSV, for example.

There is one limitation to know before the first CI run, or I will discover it at the worst possible moment. Because the core is a native library, the list of supported platforms is finite: Windows x64, Linux x64 with glibc, and macOS x64. Linux on musl, which includes Alpine-based images, will not work. Macs with ARM do not work at the moment either. If I build in an alpine container, I therefore either switch the agent image to a Debian-based one or do not run contract tests there.

Now for the consumer test. Order API asks Inventory API for the inventory level of a specific SKU:

public class InventoryApiConsumerTests
{
    private readonly IPactBuilderV3 _pact;

    public InventoryApiConsumerTests(ITestOutputHelper output)
    {
        var config = new PactConfig
        {
            PactDir = Path.Combine("..", "..", "..", "..", "pacts"),
            LogLevel = PactLogLevel.Information,
            Outputters = new[] { new XUnitOutput(output) }
        };

        IPactV3 pact = Pact.V3("Order API", "Inventory API", config);

        _pact = pact.UsingNativeBackend();
    }

    [Fact]
    public async Task GetInventory_WhenSkuExists_ReturnsAvailableQuantity()
    {
        _pact
            .UponReceiving("a request for the inventory level of an existing SKU")
                .Given("an inventory item with SKU ABC-1 exists")
                .WithRequest(HttpMethod.Get, "/api/inventory/ABC-1")
                .WithHeader("Accept", "application/json")
            .WillRespond()
                .WithStatus(HttpStatusCode.OK)
                .WithHeader("Content-Type", "application/json; charset=utf-8")
                .WithJsonBody(new
                {
                    sku = Match.Type("ABC-1"),
                    quantity = Match.Integer(12),
                    warehouse = Match.Regex("WA-1", "WA-\\d+")
                });

        await _pact.VerifyAsync(async ctx =>
        {
            var client = new InventoryClient(ctx.MockServerUri);

            InventoryItem item = await client.GetAsync("ABC-1");

            Assert.Equal(12, item.Quantity);
        });
    }
}

Several details in this code matter more than they may appear to.

The constructor builds a separate IPactBuilderV3 for every test, as it should. In the 3.x line, the mock server had to be started once with IClassFixture, and interactions had to be cleared between tests. The server now starts when VerifyAsync is called and stops when VerifyAsync returns. The entire API conversation must therefore happen inside that lambda, using the address from ctx.MockServerUri. If the client captures the address and tries to send a request after the lambda exits, it will get a connection refusal. This has a pleasant consequence: there is no need to compromise xUnit’s default instance isolation, because the consumer side no longer has any shared resource that would have to go into a collection.

Matchers are the point, not decoration. Match.Type("ABC-1") means “I expect a string here,” not “I expect exactly ABC-1.” If I used a raw value, the contract would require the provider to return that exact value, which is an assertion about data, not a contract. Match.Integer(12) is stricter than Match.Number because it rejects 12.0. That can be exactly what I want when the other side deserializes the value to an int. I use Match.Regex sparingly, only where the format is genuinely part of the agreement, such as warehouse identifiers.

The contract contains only what I requested. If the provider returns twenty fields and I describe three, the contract contains three, and the provider can freely add more. That is the entire value of the consumer-driven approach: I do not block the provider’s development. I block only the removal or modification of what I use.

I have to add XUnitOutput myself. It is a dozen or so lines because xUnit 2 does not capture console output, and PactNet’s default outputter writes to the console:

public class XUnitOutput : IOutput
{
    private readonly ITestOutputHelper _output;

    public XUnitOutput(ITestOutputHelper output) => _output = output;

    public void WriteLine(string line) => _output.WriteLine(line);
}

Without it, the first failed verification gives me only “test failed” in the report, and I have to guess which field did not match.

There is also one trap that does not hurt on a local machine but hurts badly on CI. Pact files are written in merge mode. If I run a single test, its result is merged into the existing file rather than replacing it. On CI, this means the pacts/ directory must be deleted before the consumer tests run. Otherwise, I will publish a contract containing interactions from tests that have not existed for months.

Minimal flow: consumer, artifact, provider

When the test passes, a file named with the consumer-provider pattern appears in the pacts/ directory. In my case, it is Order API-Inventory API.json. This file is the entire contract, and it is worth reading once so that it stops feeling like magic. Here is an abbreviated version with the metadata.pactRust block omitted:

{
  "consumer": { "name": "Order API" },
  "provider": { "name": "Inventory API" },
  "interactions": [
    {
      "description": "a request for the inventory level of an existing SKU",
      "providerStates": [
        { "name": "an inventory item with SKU ABC-1 exists" }
      ],
      "request": {
        "method": "GET",
        "path": "/api/inventory/ABC-1",
        "headers": { "Accept": "application/json" }
      },
      "response": {
        "status": 200,
        "headers": { "Content-Type": "application/json; charset=utf-8" },
        "body": { "sku": "ABC-1", "quantity": 12, "warehouse": "WA-1" },
        "matchingRules": {
          "body": {
            "$.sku": { "combine": "AND", "matchers": [{ "match": "type" }] },
            "$.quantity": { "combine": "AND", "matchers": [{ "match": "integer" }] },
            "$.warehouse": {
              "combine": "AND",
              "matchers": [{ "match": "regex", "regex": "WA-\\d+" }]
            }
          }
        }
      }
    }
  ],
  "metadata": { "pactSpecification": { "version": "3.0.0" } }
}

This shows exactly what I wrote in the test, plus the matchingRules section that turns example values into rules. The providerStates field is a list because this is specification v3.

The second step is to move this file into the provider’s pipeline. For the first implementation, I do not teach the team Pact Broker from scratch. Publishing the JSON file as an artifact of the consumer build and downloading it in the provider build is enough to determine whether the whole mechanism makes sense for a given project. The open-source Pact Broker is the natural next step when there is more than one consumer and it starts to matter which contract version was verified against which provider version. A hosted broker is a separate service and a separate decision. I am sticking with the file here.

The third step is verification on the provider side. There is one .NET-specific detail to watch out for, and it is easy to miss because it goes against familiar habits.

The Pact core is native, so it replays requests through a real TCP socket. This means I cannot expose the API for verification through TestServer or WebApplicationFactory from Microsoft.AspNetCore.Mvc.Testing. That host lives in the test process’s memory, and code outside .NET has no way to reach it. The API must listen on a port:

public class InventoryApiFixture : IDisposable
{
    private readonly IHost _server;

    public Uri ServerUri { get; }

    public InventoryApiFixture()
    {
        ServerUri = new Uri("http://localhost:9223");

        _server = Host.CreateDefaultBuilder()
                      .ConfigureWebHostDefaults(webBuilder =>
                      {
                          webBuilder.UseUrls(ServerUri.ToString());
                          webBuilder.UseStartup<TestStartup>();
                      })
                      .Build();

        _server.Start();
    }

    public void Dispose() => _server.Dispose();
}

This is a classic case for a fixture shared by a class because starting the host is expensive, while verification does not modify it in a way that would interfere with later tests. The verification test itself looks like this:

public class InventoryApiContractTests : IClassFixture<InventoryApiFixture>
{
    private readonly InventoryApiFixture _fixture;
    private readonly ITestOutputHelper _output;

    public InventoryApiContractTests(InventoryApiFixture fixture, ITestOutputHelper output)
    {
        _fixture = fixture;
        _output = output;
    }

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

        string pactPath = Path.Combine("..", "..", "..", "..", "pacts", "Order API-Inventory API.json");

        IPactVerifier verifier = new PactVerifier(config);

        verifier
            .ServiceProvider("Inventory API", _fixture.ServerUri)
            .WithFileSource(new FileInfo(pactPath))
            .WithProviderStateUrl(new Uri(_fixture.ServerUri, "/provider-states"))
            .Verify();
    }
}

Verify() is synchronous and throws an exception when an interaction does not match. I do not write any custom assertions here, and that is fine because the assertions are in the contract.

That leaves WithProviderStateUrl. It is the most important part of the whole setup and, at the same time, the part people most often misunderstand. A provider state is not a mock. It is an agreed hook to which the verifier sends a POST before every interaction, passing the state name from the contract. The provider’s job is to bring its data into that state:

public class ProviderStateMiddleware
{
    private readonly IDictionary<string, Action> _providerStates;
    private readonly RequestDelegate _next;

    public ProviderStateMiddleware(RequestDelegate next)
    {
        _next = next;
        _providerStates = new Dictionary<string, Action>
        {
            ["an inventory item with SKU ABC-1 exists"] = () => InventorySeed.Upsert("ABC-1", quantity: 12, warehouse: "WA-1")
        };
    }

    public async Task InvokeAsync(HttpContext context)
    {
        if (!context.Request.Path.StartsWithSegments("/provider-states"))
        {
            await _next.Invoke(context);
            return;
        }

        context.Response.StatusCode = (int)HttpStatusCode.OK;

        using var reader = new StreamReader(context.Request.Body, Encoding.UTF8);
        string body = await reader.ReadToEndAsync();

        var state = JsonConvert.DeserializeObject<ProviderState>(body);

        if (!string.IsNullOrEmpty(state?.State))
        {
            _providerStates[state.State].Invoke();
        }

        await context.Response.WriteAsync(string.Empty);
    }
}

public class ProviderState
{
    public string State { get; set; }
}

Two decisions deserve attention. First, a dictionary indexed by a string means a typo in a consumer-side state name fails only during verification, as a KeyNotFoundException. It is worth keeping state names in constants shared by both repositories whenever possible. Second, this code sets up data; it does not substitute responses. If I start mocking the repository here, I will verify the contract between the consumer and my mock, which verifies nothing. This boundary is easy to cross under deadline pressure, so I follow a rule that provider states may only insert rows or set flags. Everything I wrote about test data management strategies applies here because contracts only raise the stakes: the state must be idempotent, since the verifier invokes it separately for every interaction.

On CI, contract verification is a separate job for me, not another step in the unit test job. The reason is the same as with parallel tests on CI: I want the report to show that the pipeline is red because of the contract and not something else, and I want to be able to rerun that job without rerunning the rest. The provider job downloads the artifact from the consumer build and runs one test project with dotnet test --filter "Category=Contract".

Other tools, without a tutorial

Two names come up regularly in this conversation and are worth knowing even though neither is my path.

Spring Cloud Contract, currently at version 3.1.3 from the end of May, is the counterpart for the JVM world. Its contracts are written in a Groovy DSL or YAML, and the tool generates provider tests and a stub runner for consumers. Its direction is the opposite of Pact’s because the contract usually lives in the provider repository. If the team works in Spring, it is the natural choice, and there is no point trying to persuade them to use something else. It is not the path for a C# project.

Specmatic, artifact in.specmatic:specmatic-core at version 0.46.0 from July 2, represents the spec-first school. Its contract is an OpenAPI file, and the tool can turn it into both a provider test and a consumer stub. The project was previously called Qontract. This is a good approach where OpenAPI is actively maintained and treated as the source of truth. In my projects, it usually is not, so I choose a contract derived from consumer tests rather than a document that can drift away from the implementation.

This is not a market overview, and I deliberately do not turn this post into a review of every tool. My only point is that choosing PactNet is a stack choice, not the only possible answer.

What I deliberately keep separate

Four things that people tend to merge into one when discussing contracts, even though they should not.

A contract is not an E2E test. It does not check whether an order can be placed through the UI. It checks whether Inventory API still returns quantity as an integer. The former requires both systems to run at the same time. The latter completes in a few seconds on two independent pipelines, which is its entire advantage.

A contract is not retry logic. I described Polly policies a year ago and still use them, but they handle transient failures. A contract verification wrapped in retries has stopped meaning anything because either the provider satisfies the contract or it does not, and a second attempt will not change that.

A contract is not business logic validation. The response shape may be perfectly compliant while the calculated quantity is wrong. Provider tests still need to exist and still need to check what the system calculates.

Finally, the point I started with: a contract replaces neither WireMock nor a database container. It closes a gap that neither of them closed.

Summary

After a year of working with stubs, I am adding contracts exactly where a stub was hiding drift in someone else’s API, and nowhere else.

  • PactNet 4.1.0 on .NET 6 is the tool. One NuGet package, an in-process mock server, and specification v3. That change brought the subject back, because the idea itself dates to 2006 and Pact to 2013.
  • The contract covers the happy path and the variants I actually support in the code. Everything else, including a 503, a timeout, and an empty body, stays in WireMock stubs.
  • Provider states set up data and never substitute responses. This single rule determines whether verification means anything.
  • A JSON file in CI artifacts is enough to start. A broker answers the problem of multiple consumers; it is not an entry requirement.

In my review of last year, I wrote that isolation was the theme of 2021. Contracts are the next step after isolation and, in a way, its counterbalance: first I isolated myself from other teams’ environments so I could test at all, and now I need to rebuild a feedback channel to those environments so that the isolation does not turn into silence.