Contract Testing in .NET — PactNet Enum Removal Failures
A silent enum removal broke PayPal payments across services.
20+ years shipping production .NET services in enterprise systems. Everything here is grounded in real deployments.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- 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
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.
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.
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); }); } }
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.
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()); }); } }
- 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.
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.
Here's how to write provider verification in .NET:
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(); } }
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.
Here's how to handle a consumer test that uses multiple states:
// 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" } }); } }
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:
- Consumer PR: run pact tests, publish pact file to broker.
- Provider PR: fetch latest pacts from broker, verify all interactions.
- Provider can only merge if all pacts pass.
- 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.
- 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.
// 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 });
- 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.
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.
// 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 });
Why You Should Treat Pact Files Like Database Migrations
Pact files are not artifacts you casually regenerate. They are contracts that represent a binding agreement between consumer and provider. Treat them like database migrations: version-controlled, reviewed, and immutable after agreement. If you regenerate a pact file silently, you break your provider's verification without any trace. That leads to production incidents where the provider passes against stale expectations while the consumer sends data the provider rejects. The fix: store pact files in a separate repository or a dedicated branch. Consumer merges only after provider verifies. Use PactFlow or a simple git hook to enforce that no pact file changes without a corresponding provider verification run. Your CI pipeline should fail if a provider cannot verify the latest consumer pact. This is not overengineering. This is preventing the "works on my machine" scenario from reaching staging. Senior teams enforce this because they've been burned by silent contract drift.
// io.thecodeforge — csharp tutorial // Example: Enforcing pact verification before merge // Use in your CI pipeline check public class PactMigrationGate { public static bool CanMergeConsumer( string consumerPactPath, string providerVerificationUrl) { var pactFile = File.ReadAllText(consumerPactPath); using var client = new HttpClient(); // POST pact to provider's verification endpoint var response = client.PostAsync( providerVerificationUrl, new StringContent(pactFile, Encoding.UTF8, "application/json")) .GetAwaiter().GetResult(); return response.IsSuccessStatusCode; } } // Usage: if (!CanMergeConsumer(...)) -> block PR
Pact Brokers Are Not Optional — Here's the Bare Minimum Setup
Developers love skipping the infrastructure. They run pact verification locally with hardcoded files and call it done. That works until you have three consumers hitting one provider. Suddenly you need to know: which version of the contract did the mobile team publish? Did the web consumer break anything? That's why a Pact Broker exists. It's not a luxury. It's the source of truth for contract versions. Without it, you're guessing. The minimal setup: deploy Pact Broker (open-source on Docker) or use PactFlow (paid, but saves headaches). Store pact files there after consumer tests pass. Provider CI pulls the latest pacts from the broker, not from consumer repos. Consumer CI tags pacts with git commit hashes. Provider CI compares against those tags. This gives you a timeline of contract changes and who broke what. Setup time: two hours. Time saved on debugging: every production incident you prevent.
// io.thecodeforge — csharp tutorial // Minimal Pact Broker client setup for provider verification using PactNet.Verifier; var config = new PactVerifierConfig { LogLevel = LogLevel.Information, PublishVerificationResults = true }; using var verifier = new PactVerifier(config); verifier .ServiceProvider("PaymentProvider", new Uri("http://localhost:5001")) .WithPactBroker(new Uri("http://localhost:9292")) .WithProviderStateEndpoint(new Uri("http://localhost:5001/provider-states")) .Verify(); // Output shows each consumer pact and pass/fail status
The Silent Enum Removal That Brought Down Payments
- 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.
dotnet test --filter "Category=Verification" --verbosity normalCheck PactNet logs at Debug level: set PACT_VERBOSE=true in environmentSearch for 'ProviderState' in provider test project: grep -r "ProviderState" .Verify state name in consumer test matches exactly (case and spaces)Run consumer test with PactNet.Outputters for debug: set VERBOSE=trueTest broker connection: curl -I https://your-pact-broker/| Aspect | Contract Testing (PactNet) | Integration Testing |
|---|---|---|
| Test scope | Single consumer–provider interaction | End-to-end flow across multiple services |
| Environment required | Mock server for consumer; isolated provider instance | All dependent services must be running |
| Failure detection | Detects contract violations before deployment | Detects runtime integration issues in test environment |
| Deployment coupling | Consumer and provider can deploy independently | Coordinated deployment windows required |
| Speed | Fast — seconds per test | Slow — minutes per full integration test suite |
| Maintenance cost | Moderate — provider states require upkeep | High — managing test data across services is brittle |
| Who writes tests | Consumer team writes; provider team verifies | Usually provider team writes full end-to-end tests |
| File | Command / Code | Purpose |
|---|---|---|
| Consumer | using PactNet; | What is Contract Testing in .NET? |
| Consumer | public class OrderApiConsumerTests | Consumer-Driven Contract Testing with PactNet |
| Provider | using PactNet.Verifier; | Provider Verification with PactNet |
| Consumer | _pactBuilder | Handling Provider States and Edge Cases |
| Consumer | var publisher = new PactPublisher("https://your-pact-broker.com", | CI/CD Integration and Production Gotchas |
| Consumer | using PactNet.Matchers; | When Pact Verification Fails |
| PactAsMigration.cs | public class PactMigrationGate | Why You Should Treat Pact Files Like Database Migrations |
| PactBrokerSetup.cs | using PactNet.Verifier; | Pact Brokers Are Not Optional |
Key takeaways
Common mistakes to avoid
4 patternsUsing exact values for dynamic fields
Not treating pact files as versioned artifacts
Missing provider state teardown
Ignoring optional fields
Interview Questions on This Topic
How does PactNet differ from traditional integration testing for microservices?
Explain provider states in PactNet. Why are they necessary?
What are PactNet matchers and when should you use them?
How would you integrate PactNet into a CI/CD pipeline to prevent breaking changes from reaching production?
Frequently Asked Questions
Contract testing with PactNet in .NET is a consumer-driven testing approach where service consumers write tests that define their API expectations. These tests generate a pact file (JSON contract) that the provider then verifies independently. It ensures that changes to the provider don't break existing consumers without being detected.
No. Consumer tests use a mock server inside the test process. PactNet provides the mock server URI automatically. The consumer code makes requests to that mock, and PactNet checks that the code sends the expected request. The provider is never running during consumer tests.
PactNet primarily supports HTTP and asynchronous messaging (via MessagePact). For other protocols (gRPC, WebSockets), you may need custom adapters or consider alternative tools. PactNet V3 supports synchronous request-response and asynchronous messages.
An OpenAPI spec describes the full API surface (all endpoints, all possible responses). A pact file only describes the specific interactions a consumer actually uses. Pacts are consumer-specific and automatically derived from tests; OpenAPI is usually hand-written. Both are useful: OpenAPI for documentation, pacts for contract testing.
Include the expected authentication header in the consumer's WithRequest call. During provider verification, the provider must authenticate accordingly. You can choose to include an auth token as a provider state (e.g., 'user is logged in') or mock the authentication in the provider test setup. Be careful not to hardcode real credentials.
20+ years shipping production .NET services in enterprise systems. Everything here is grounded in real deployments.
That's Testing. Mark it forged?
5 min read · try the examples if you haven't