Senior 5 min · March 06, 2026

Contract Testing in .NET — PactNet Enum Removal Failures

A silent enum removal broke PayPal payments across services.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Consumer-Driven Contracts (CDC) let each consumer define its API expectations as a pact file
  • PactNet generates pact files from consumer-side tests, then provider verifies them independently
  • Provider states simulate exact conditions (e.g. 'user exists') during verification
  • A pact mismatch fails the provider build, not the consumer — breaking the cycle early
  • Biggest mistake: treating pacts as static specs instead of versioned, evolving contracts
  • Key rule: pact files must be shared via a broker or repo — never email or wiki
Plain-English First

Imagine you order a custom-made key from a locksmith to fit your front door. The locksmith makes the key based on a mould (the contract) you gave them — so when you pick it up, it fits perfectly without you needing to be there while they cut it. Contract testing works the same way: the team who uses an API writes down exactly what they expect it to return (the contract), and the team who owns the API runs tests against that contract independently. No more 'it worked on my machine' surprises when services talk to each other.

In a microservices architecture, the scariest failures aren't the ones your unit tests catch — they're the silent ones that only blow up in production when Service A calls Service B and gets back a response shape it never expected. A field gets renamed, a nullable becomes required, an enum value disappears. Your 2000-unit-test suite is all green. Your integration environment is down for maintenance. And at 2am, your on-call phone rings. Contract testing exists precisely to close this gap.

The problem is subtle: traditional integration tests force you to spin up every dependent service simultaneously, coordinate deployment windows, and pray the test data lines up. That's slow, brittle, and it couples your CI pipelines together in ways that feel manageable on day one and catastrophic at scale. Consumer-Driven Contract (CDC) testing flips the model — each consumer declares what it needs, each provider proves it can satisfy those needs, and they do it asynchronously, independently, and fast.

By the end of this article you'll know how to implement full consumer-driven contract testing in .NET using PactNet, understand the internals of how pact files are generated and verified, handle edge cases like optional fields and provider states, integrate pact verification into your CI/CD pipeline, and avoid the production gotchas that catch even experienced teams off guard.

What is Contract Testing in .NET?

Contract testing in .NET is about defining a formal agreement between a service consumer (e.g., an API client) and a provider (e.g., a REST API). The agreement, called a pact, describes the exact request the consumer will make and the exact response it expects. With PactNet—the .NET implementation of the Pact framework—you write these expectations as unit tests in the consumer project.

The key insight: the consumer's tests generate a JSON file (the pact). The provider then runs its own tests that replay those expectations against the real provider server. No need to deploy both services together, no fragile integration environment.

Let's see a concrete example. Suppose we have an Orders API that returns order details. The consumer (a web frontend) expects a GET /orders/1 to return a 200 with body { id: 1, status: "shipped" }. The consumer test in PactNet builds that expectation.

Consumer/OrderApiConsumerTests.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
using PactNet;
using PactNet.Infrastructure.Outputters;
using Xunit;

public class OrderApiConsumerTests
{
    private readonly IPactBuilderV3 _pactBuilder;

    public OrderApiConsumerTests()
    {
        var pactConfig = new PactConfig
        {
            PactDir = @"..\..\pacts",
            Outputters = new[] { new ConsoleOutput() }
        };
        _pactBuilder = PactNet.Pact.V3(
            "WebFrontend",   // consumer name
            "OrderApi",      // provider name
            pactConfig);
    }

    [Fact]
    public async Task GetOrder_ReturnsOrder_WhenOrderExists()
    {
        _pactBuilder
            .UponReceiving("a GET request for order 1")
                .Given("order 1 exists")
                .WithRequest(HttpMethod.Get, "/orders/1")
                .WithHeader("Accept", "application/json")
            .WillRespond()
                .WithStatus(HttpStatusCode.OK)
                .WithHeader("Content-Type", "application/json")
                .WithJsonBody(new
                {
                    id = 1,
                    status = "shipped"
                });

        await _pactBuilder.VerifyAsync(async ctx =>
        {
            // Real consumer code that calls the mock server
            var client = new HttpClient { BaseAddress = ctx.MockServerUri };
            var response = await client.GetAsync("/orders/1");
            var body = await response.Content.ReadAsStringAsync();
            Assert.Equal(HttpStatusCode.OK, response.StatusCode);
            Assert.Contains("\"status\": \"shipped\"", body);
        });
    }
}
Output
Test run generates pact file at ../pacts/WebFrontend-OrderApi.json
PactNet naming conventions
Consumer and provider names must be unique across your organisation. If you have multiple consumers of the same API, each will generate its own pact file. The provider will verify all of them.
Production Insight
If the consumer and provider names change between branches, the broker treats them as separate pacts.
This causes 'orphan' pacts that never get verified.
Rule: use consistent naming across all environments — consumer name = service name, not branch name.
Key Takeaway
A pact file is a contract generated by consumer tests.
Provider tests verify the contract against real provider code.
The pact is the single source of truth — never modify it by hand.

Consumer-Driven Contract Testing with PactNet

The consumer-driven approach means the consumer dictates what it needs from the API. The provider doesn't decide the contract unilaterally. This sounds backward, but it's the only way to catch breaking changes before they reach production.

In practice, the consumer team writes PactNet tests that define every interaction they rely on. For each interaction, they specify: - The HTTP method and path - Query parameters, headers, and body (if any) - Expected response status, headers, and body structure - Provider states — named conditions the provider must set up (e.g., "order exists")

Once all consumer tests pass, the pact file is published to a Pact Broker (or stored in a shared artifact repository). The provider's CI pipeline then runs verification tests against that pact. If the provider breaks a contract, the provider build fails — not the consumer's. This shifts the failure left, before the change is ever deployed.

Consumer/OrderApiConsumerTests.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class OrderApiConsumerTests
{
    // ... setup as before ...

    [Fact]
    public async Task CreateOrder_Returns201_WhenValidRequest()
    {
        _pactBuilder
            .UponReceiving("a POST request to create order")
                .Given("the system is ready to accept orders")
                .WithRequest(HttpMethod.Post, "/orders")
                .WithHeader("Content-Type", "application/json")
                .WithJsonBody(new
                {
                    productId = 42,
                    quantity = 2
                })
            .WillRespond()
                .WithStatus(HttpStatusCode.Created)
                .WithHeader("Location", "/orders/500");

        await _pactBuilder.VerifyAsync(async ctx =>
        {
            var client = new HttpClient { BaseAddress = ctx.MockServerUri };
            var response = await client.PostAsJsonAsync("/orders", new { productId = 42, quantity = 2 });
            Assert.Equal(HttpStatusCode.Created, response.StatusCode);
            Assert.Contains("/orders/", response.Headers.Location.ToString());
        });
    }
}
Output
Consumer tests pass. Pact file now contains two interactions: GET and POST.
Contracts as Reverse Tests
  • The consumer defines what the API should do (like a specification).
  • The provider must implement to that specification — not the other way around.
  • If the provider changes the API, the consumer's pact will catch it in the provider's CI.
  • This inverts the usual testing flow: the downstream team protects itself.
Production Insight
Consumer tests that use random data (e.g., a new ID each run) will break pact verification because the provider state needs a deterministic ID.
Solution: use fixed test data in consumer tests and match it in provider states.
Rule: every pact interaction must have a deterministic provider state.
Key Takeaway
Consumer drives the contract.
Provider verifies it.
Pact file is the handshake — version it like any other artifact.

Provider Verification with PactNet

Now the provider team sets up verification tests. They don't need to write test scenarios — they simply load the pact file and replay it against a running instance of their API. PactNet's verification engine calls each endpoint with the exact request from the pact and checks the response matches expectations.

The provider must register provider states — methods that set up the exact data conditions required by the consumer tests. For example, if the consumer expects "order 1 exists", the provider must have a method that inserts an order with ID 1 into the test database.

Provider/OrderApiProviderTests.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
using PactNet.Verifier;
using Xunit;

public class OrderApiProviderTests : IClassFixture<OrderApiFixture>
{
    private readonly OrderApiFixture _fixture;

    public OrderApiProviderTests(OrderApiFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public void VerifyPacts()
    {
        var config = new PactVerifierConfig
        {
            Outputters = new[] { new ConsoleOutput() },
            LogLevel = PactNet.LogLevel.Debug
        };

        var verifier = new PactVerifier(config);

        verifier
            .ServiceProvider("OrderApi", _fixture.ServerUri)
            .HonoursPactWith("WebFrontend")
            .PactUri(@"..\..\pacts\WebFrontend-OrderApi.json")
            .WithProviderStateUrl(new Uri(_fixture.ServerUri, "/provider-states"))
            .Verify();
    }
}

// Provider states controller (run in-memory with WebApplicationFactory)
[ApiController]
[Route("provider-states")]
public class ProviderStatesController : ControllerBase
{
    [HttpPost("setup")]
    public IActionResult Setup([FromBody] ProviderState state)
    {
        if (state.State == "order 1 exists")
        {
            // Arrange: insert order with id=1 into test database
            TestData.Orders.Add(new Order { Id = 1, Status = "shipped" });
        }
        return Ok();
    }

    [HttpPost("teardown")]
    public IActionResult Teardown()
    {
        TestData.Orders.Clear();
        return Ok();
    }
}
Output
PactNet replays each interaction against the real API and reports any mismatches.
Provider State Overhead
Provider states add test setup complexity. Limit them to only what the consumer truly needs. Over-specific states (e.g., 'order exists with status shipped and total > 100 and discount applied') will make the provider test suite brittle and slow.
Production Insight
In CI, the provider must start a real instance of the API (with test database) before running verification.
Use WebApplicationFactory from ASP.NET Core to spin up a lightweight in-memory server.
Rule: never verify against a shared integration environment — state pollution will cause false negatives.
Key Takeaway
Provider verification replays pacts against real provider code.
Provider states are the glue between consumer expectations and test data.
Use in-memory test servers for fast, isolated verification.

Handling Provider States and Edge Cases

Provider states are the trickiest part of PactNet. They let the consumer describe preconditions without knowing how the provider sets them up. Common edge cases include:

  • Missing state: the consumer says "order exists" but the provider hasn't registered that state. Verification fails.
  • State with dynamic data: the consumer expects a specific ID, but the provider's test data generates random IDs. The consumer must use a fixed ID.
  • Multiple states per interaction: some interactions need multiple conditions (e.g., "user is logged in" AND "order exists"). PactNet supports combining states as an array.
  • Teardown: after each state, the provider should clean up any data to avoid test pollution. Use teardown endpoint.
Consumer/OrderApiConsumerTests.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Consumer test with multiple provider states
_pactBuilder
    .UponReceiving("a GET request for pending orders by user")
        .Given(new[] { "user exists", "user has pending orders" })
        .WithRequest(HttpMethod.Get, "/orders/pending")
        .WithQuery("userId", "42")
    .WillRespond()
        .WithStatus(HttpStatusCode.OK)
        .WithJsonBody(new[]
        {
            new { id = 1, status = "pending" },
            new { id = 2, status = "pending" }
        });

// Provider verification setup
public class ProviderStates
{
    [ProviderState("user exists")]
    public static void UserExists() => TestData.Users.Add(new User { Id = 42, Name = "Alice" });

    [ProviderState("user has pending orders")]
    public static void UserHasPendingOrders()
    {
        TestData.Orders.AddRange(new[]
        {
            new Order { Id = 1, UserId = 42, Status = "pending" },
            new Order { Id = 2, UserId = 42, Status = "pending" }
        });
    }
}
Output
Both provider states are called in sequence, then the request is made.
State naming convention
Use names that describe the observable condition, not the setup steps. 'user exists' is good. 'insert user into database' is bad because it couples consumer to provider's implementation.
Production Insight
Provider states that fail to clean up after themselves cause test pollution across pact interactions.
In CI with parallel verification, shared state corrupts unrelated tests.
Rule: always wipe test data between states — use teardown endpoint or TransactionScope rollback.
Key Takeaway
Provider states declare preconditions for each interaction.
They must be deterministic and idempotent.
Teardown is as important as setup — avoid state pollution.
When to add a provider state
IfConsumer needs data that is not present by default
UseCreate a provider state that sets up that exact data
IfConsumer only needs default data (e.g., empty list)
UseNo state needed — provider should return default response
IfState setup is expensive (e.g., populating 1000 records)
UseReconsider if the consumer really needs that exact data; use simpler state

CI/CD Integration and Production Gotchas

The real value of contract testing comes from integrating it into your CI/CD pipelines. Both consumer and provider pipelines must run pact tests on every pull request. The typical flow:

  1. Consumer PR: run pact tests, publish pact file to broker.
  2. Provider PR: fetch latest pacts from broker, verify all interactions.
  3. Provider can only merge if all pacts pass.
  4. Consumer can deploy independently — the broker tells them which provider versions are safe.

PactNet supports publishing to a Pact Broker. The broker stores all versions of all pacts and can verify compatibility matrix.

Common production gotchas
  • Missing broker authentication: use API tokens, never hardcode secrets.
  • Pact file versioning: always tag pacts with the consumer version (git commit SHA). The broker uses version tags to maintain history.
  • Race condition in CI: if consumer publishes pact after provider starts verification, provider might use stale pact. Use webhooks or scheduled verification.
  • Pact file not published: consumer test may pass locally but publishing step may be missing in CI.
Consumer/PactPublisherSetup.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
// In consumer test setup, after all tests pass
var publisher = new PactPublisher("https://your-pact-broker.com",
    new PactUriOptions("broker-token-here"));
await publisher.PublishToBroker(
    pactFilePath,
    new Version(1, 0, 0),        // consumer version
    new Dictionary<string, string>
    {
        ["branch"] = "main",
        ["buildUrl"] = buildUrl
    });
Output
Pact file published to broker with version and tags. Provider CI will fetch it.
Pact Broker as Source of Truth
  • Every pact version is tagged with consumer version, branch, and environment.
  • Provider verification results are recorded against each pact version.
  • You can query the broker: 'Which version of OrderApi is verified against WebFrontend v1.0?'
  • This enables safe deployment — deploy only when compatible versions exist.
Production Insight
A common CI mistake: running provider verification against a pact file that was published 30 seconds earlier but the CI node fetches a cached old version.
Solution: always specify pact version tag or use broker's webhook to trigger provider verification.
Rule: never verify against latest — pin to a version tag (e.g., consumer branch name).
Key Takeaway
Integrate pact tests at PR gate, not after merge.
Use Pact Broker for versioned storage and compatibility matrix.
Tag pacts with consumer version — never rely on 'latest'.

When Pact Verification Fails: Real Debugging Patterns

Even with well-written pacts, verification failures happen. Here's how to debug the most common causes:

1. Response field mismatch: PactNet compares actual response against expected using structural tolerance by default. If a field is missing or has wrong type, the verifier reports the exact path. Check the verifier log at debug level for details.

2. Dynamic fields: If your API returns timestamps or GUIDs, the pact must use a matcher (like PactNet's Match.Type or Match.Regex) to say 'I expect a string of this pattern, not an exact value'.

3. Query parameter order: Some HTTP clients order query parameters alphabetically. If your consumer sends them in a different order, the pact request will differ. Use List matching or ignore order.

4. Provider state setup failure: If the provider state method throws an exception, the verification stops. Check that the state name matches exactly (case-sensitive) and that all dependencies (e.g., test database) are available.

5. Missing headers: PactNet expects exact header values. If your provider adds additional headers (like Server or X-Request-Id), the verifier will fail. Use 'header blacklist' or match headers loosely.

Consumer/OrderApiConsumerTests.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Using matchers for dynamic content
using PactNet.Matchers;

_pactBuilder
    .UponReceiving("a GET request for order 1")
        .Given("order 1 exists")
        .WithRequest(HttpMethod.Get, "/orders/1")
    .WillRespond()
        .WithStatus(HttpStatusCode.OK)
        .WithJsonBody(new
        {
            id = Match.Type(1),              // integer, exact value not important
            status = Match.Regex("shipped", "^(pending|shipped|cancelled)$"),
            created_at = Match.Type("2026-01-01T00:00:00") // datetime string
        });
Output
Verification uses matchers — actual response can have different values, as long as types and regex patterns match.
Overuse of matchers weakens contracts
Matchers are powerful but can make your contract too loose. Use them only for truly dynamic data (timestamps, IDs). For enums, docodes, and status, use exact values or Match.Regex with a strict pattern.
Production Insight
The most common production-breaking pact failure is a field type change (e.g., 'userId' from int to string).
PactNet spots this, but only if the consumer used strict matching.
Rule: use matchers sparingly — prefer exact values for fixed fields.
Key Takeaway
Debug pact failures by checking exact field names, types, and ordering.
Use matchers only for dynamic data.
Provider state errors are usually naming mismatches.
Verification failure diagnosis
IfMismatch says 'field missing' but field is in response
UseCheck field name spelling — case sensitivity? Underscore vs camelCase?
IfMismatch says 'type mismatch'
UseCheck if consumer used Match.Type correctly. Actual may be string, pact expects integer.
IfProvider state not found
UseVerify state name exactly. Check for extra spaces or different casing.
● Production incidentPOST-MORTEMseverity: high

The Silent Enum Removal That Brought Down Payments

Symptom
Payments service returned 500 for PayPal transactions; other payment methods worked fine.
Assumption
The billing team assumed PaymentMethod enum values were documented in an internal wiki and that consumers would be notified of changes. No automated contract check existed.
Root cause
Two teams used separate deployment pipelines with no shared contract verification. The billing API's removal of a previously documented enum value went undetected until production traffic hit it.
Fix
Implemented consumer-driven contracts with PactNet: the payments service generated a pact file listing exactly which PaymentMethod values it expected. The billing pipeline now verifies against that pact before deploying. Enum removal now fails the billing build immediately.
Key lesson
  • A contract must be machine-readable and verified every deployment.
  • Never rely on documentation or manual communication for API changes.
  • Every consumer must declare exactly what it expects, including enum values, optional fields, and response codes.
Production debug guideQuick symptom-to-action reference for PactNet failures5 entries
Symptom · 01
Consumer test passes but provider verification fails
Fix
Check if the provider's response matches the pact exactly. Common causes: extra fields, wrong types, missing headers. Use PactNet's verbose verification logs.
Symptom · 02
Pact file not found on broker
Fix
Verify the broker URL and API token in both consumer and provider config. Ensure consumer publishes after test run.
Symptom · 03
Provider state not found
Fix
Register all provider states in the provider's test class. The state name must match exactly (case-sensitive) the one used in consumer test.
Symptom · 04
Verification passes locally but fails in CI
Fix
Check environment-specific variables (database connection, provider URI). Use Pact's log level 'debug' to capture HTTP interactions.
Symptom · 05
Consumer test flaky due to provider state setup
Fix
Ensure provider states are idempotent and transactional. Use a setup/teardown pattern to clean test data after each state.
★ 5-Minute PactNet Debug Cheat SheetCommands and checks for the most common PactNet contract failures in production pipelines.
Verification fails with 'missing field'
Immediate action
Check the pact file (in broker UI or local) to see expected fields vs actual response
Commands
dotnet test --filter "Category=Verification" --verbosity normal
Check PactNet logs at Debug level: set PACT_VERBOSE=true in environment
Fix now
Add the missing field to the provider's response or update the consumer pact to not expect it
Provider state 'user_exists' not found+
Immediate action
Confirm the provider test class has a method decorated with [ProviderState("user_exists")]
Commands
Search for 'ProviderState' in provider test project: grep -r "ProviderState" .
Verify state name in consumer test matches exactly (case and spaces)
Fix now
Add the missing provider state method or correct the name in consumer test
Pact file not published to broker+
Immediate action
Check consumer CI logs for 'Publish pacts' step
Commands
Run consumer test with PactNet.Outputters for debug: set VERBOSE=true
Test broker connection: curl -I https://your-pact-broker/
Fix now
Ensure PactPublisher is configured in consumer's test setup, with correct broker URI and token
Contract Testing vs Integration Testing
AspectContract Testing (PactNet)Integration Testing
Test scopeSingle consumer–provider interactionEnd-to-end flow across multiple services
Environment requiredMock server for consumer; isolated provider instanceAll dependent services must be running
Failure detectionDetects contract violations before deploymentDetects runtime integration issues in test environment
Deployment couplingConsumer and provider can deploy independentlyCoordinated deployment windows required
SpeedFast — seconds per testSlow — minutes per full integration test suite
Maintenance costModerate — provider states require upkeepHigh — managing test data across services is brittle
Who writes testsConsumer team writes; provider team verifiesUsually provider team writes full end-to-end tests

Key takeaways

1
Contract testing inverts the testing flow
consumers define expectations, providers prove they meet them.
2
PactNet generates pact files from consumer tests; provider verification replays them against real provider code.
3
Provider states are essential for deterministic test data—but keep them simple and always tear down.
4
Integrate pact verification at the PR gate in both consumer and provider pipelines.
5
Use Pact Broker for versioned pact storage and compatibility tracking.
6
Matchers loosen contracts—only use them for truly dynamic values like timestamps and IDs.

Common mistakes to avoid

4 patterns
×

Using exact values for dynamic fields

Symptom
Pact verification fails on every run because timestamps and IDs don't match the expected literal values.
Fix
Use PactNet matchers (Match.Type, Match.Regex) for dynamic fields. Only use exact values for things that must stay fixed (enums, status codes).
×

Not treating pact files as versioned artifacts

Symptom
Provider verifies against a pact generated from a different version of the consumer, causing false failures or missed breaks.
Fix
Always publish pacts with consumer version (git SHA). Provider CI should fetch pacts tagged with a specific branch or version, never 'latest'.
×

Missing provider state teardown

Symptom
Provider test pollution: one test leaves data that breaks the next interaction. Flaky verification results.
Fix
Implement a teardown endpoint that cleans all test data after each state. Use TransactionScope to rollback changes automatically.
×

Ignoring optional fields

Symptom
Consumer expects a field but the provider stops returning it (or marks it as non-nullable). Non-optional fields cause crashes in production.
Fix
Mark truly optional fields with nullability in the pact. Use Match.Type and ensure the consumer code handles missing values.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How does PactNet differ from traditional integration testing for microse...
Q02SENIOR
Explain provider states in PactNet. Why are they necessary?
Q03SENIOR
What are PactNet matchers and when should you use them?
Q04SENIOR
How would you integrate PactNet into a CI/CD pipeline to prevent breakin...
Q01 of 04SENIOR

How does PactNet differ from traditional integration testing for microservices?

ANSWER
PactNet uses consumer-driven contracts: the consumer writes tests that generate a pact file, which the provider verifies independently. No need to deploy all services together. Integration testing requires all dependencies running and is slower. PactNet catches contract violations earlier and allows independent deployments.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is contract testing in .NET using PactNet?
02
Do I need to run the provider service when running consumer tests?
03
Can I use PactNet with non-HTTP protocols?
04
What is the difference between a pact file and an OpenAPI specification?
05
How do I handle authentication headers in PactNet?
🔥

That's Testing. Mark it forged?

5 min read · try the examples if you haven't

Previous
BDD with SpecFlow in C#
5 / 5 · Testing