Your Integration Tests Are Lying to You
Unit tests mock what you assume, end-to-end tests tell you too late - consumer-driven contract testing closes the gap, and a small open-source .NET library called Pactify is the clearest way I know to learn how it works. Part 1 of a series.
The outage that sold me on contract testing started with a pull request that renamed a JSON property. The provider team changed available to availableQuantity on their stock endpoint — a tidy-up, reviewed and approved. Their unit tests passed, because their tests serialize their own models. Our unit tests passed, because our tests mock their API with our assumptions about its shape. Both pipelines were green. The shared integration environment caught it three days later, after two other teams had deployed on top, and untangling whose change broke the environment took longer than the fix.
Nothing in that story is unusual, and that's the problem. Every test we had was telling the truth about its own service and lying about the seam between them.
The gap in the pyramid
Look at what each layer of a typical microservices test suite actually asserts about an HTTP dependency:
Unit tests mock the collaborator. When my checkout service tests its stock-checking logic, it stubs the inventory API with a hand-written response. That stub encodes what I believe inventory returns. If my belief is wrong — or becomes wrong — the tests stay green forever. A mock is a photograph of an assumption, and photographs don't age with their subject.
End-to-end tests deploy everything. They exercise the real seam, but at brutal cost: a shared environment where every team's half-finished work collides, flaky orchestration, queues for test data, and feedback measured in days. Worse, the failure report is a symptom (“checkout returned 500”) rather than a cause (“inventory renamed a field”). You end up doing archaeology, as we did.
Between those layers there's a specific, narrow question nobody is answering cheaply: does the provider still produce what this consumer actually relies on? Not “does the provider work” — its own tests cover that. Not “does the whole system work” — that's E2E's expensive job. Just the seam.
Contracts, driven by the consumer
Consumer-driven contract testing answers exactly that question, and it does it with a document instead of an environment.
The idea, popularized by Pact, runs like this:
- The consumer writes a test that records its expectations of the provider — “when I GET
api/stock/some-sku, I expect a 200 with a JSON body containingskuandavailable.” The output isn't a pass/fail; it's a pact file, a JSON document listing every interaction the consumer depends on. - That pact is published somewhere both teams can reach — a shared folder at first, a broker service once you're serious.
- The provider's pipeline picks up the pact and replays it against the real provider code: fire each recorded request at the API, assert the real response satisfies the recorded expectations.
The direction is the interesting part. The provider doesn't publish a schema and hope consumers comply — the consumers declare what they use, and the provider verifies against that. Two consequences follow that took me a while to fully appreciate:
The provider learns exactly who depends on what. If nobody's pact mentions the warehouse field, deleting it is provably safe. Contract tests are as much a dependency inventory as a test suite.
Breakage is caught in the provider's own CI, before deploy. The available → availableQuantity rename fails the provider's build the moment it's made — with a message naming the consumer and the field — not the integration environment three days later.
And crucially, verification needs no deployed environment at all. The provider replays the pact against itself, in memory, in a unit test. The seam gets tested without either service leaving its own pipeline.
Why Pactify
The official .NET implementation of this idea is PactNet, and for production use at scale it's the safe default — it speaks the full Pact specification, matchers and all, by wrapping the Pact foundation's native core.
But this series is about a different library: Pactify, an open-source contract testing tool for .NET by Dariusz Pawlukiewicz of the DevMentors ecosystem, self-described as “very simple” and inspired by Pact.io. I want to be precise about the framing: I didn't write Pactify and I don't maintain it. I'm a user who has run it against real services and — because it's small — read every line of it. That's the point of the series.
Pactify is two NuGet packages (Pactify and Pactify.AspNetCore) totalling around thirty C# files. The whole consumer-driven loop is in there — a fluent API for defining interactions, JSON serialization of the pact, file and HTTP publishers, broker integration, an in-memory provider verifier built on Microsoft.AspNetCore.TestHost, and a matching engine — and you can genuinely read all of it in an afternoon. Here's the shape of both sides, from its own README example:
// Consumer side: record what checkout relies on
await PactMaker
.Create(options)
.Between("checkout", "inventory")
.WithHttpInteraction(cb => cb
.Given("A SKU exists with stock on hand")
.UponReceiving("A GET request for a stock level")
.With(request => request
.WithMethod(HttpMethod.Get)
.WithPath("api/stock/ABC-123"))
.WillRespondWith(response => response
.WithHeader("Content-Type", "application/json")
.WithStatusCode(HttpStatusCode.OK)
.WithBody<StockLevelModel>()))
.PublishedAsFile("../../../../../pacts")
.MakeAsync();
// Provider side: replay it against the real API, in memory
await PactVerifier
.CreateFor<Startup>()
.Between("checkout", "inventory")
.RetrievedFromFile("../../../../../pacts")
.VerifyAsync();
Two tests, two pipelines, one JSON file between them — that's the entire mechanism.
When a library is this small, every design decision is visible in plain C#: how a fluent builder hides its Build() method from consumers, why the example body in a pact file is full of zero-values, what it means that the consumer's matching options travel inside the pact document, how the verifier spins up your ASP.NET Core app without a network, and — because it's a young library — a couple of honest-to-goodness bugs you'd only find by reading the source. Studying Pactify taught me more about how contract testing actually works than years of using heavier tools through their documentation. That's the deal this series offers: use the small library to understand the machinery, so you can operate any implementation with your eyes open.
Where the series goes
- Your integration tests are lying to you — this post.
- The consumer writes the contract — the
PactMakerfluent API, the builder pattern behind it, and the uninitialized-object trick insideWithBody<T>(). - What's inside a pact file — the JSON artifact, its serializer settings, and the unusual decision to ship the consumer's matching options inside the contract.
- Publishing pacts: from shared folder to broker — file publishing, the Pact Broker, versioned URLs, and a silent-failure gotcha in the HTTP publisher.
- Replaying the contract against a real API —
TestServerin-memory verification, endpoint templating with SmartFormat, and whyGiven(...)doesn't do what you think. - How Pactify decides you broke the contract — the matching engine line by line: headers, bodies, casing, and two format-string bugs hiding in the error paths.
- Serving your contracts over HTTP — the
Pactify.AspNetCorepackage and its attribute-driven contracts endpoint. - When contract testing pays for itself — the retrospective: Pactify's real limits, PactNet's case, broker workflows, and when this discipline earns its keep.
If your teams are shipping services that only meet each other for the first time in a shared environment, start here. The lie isn't that your tests are wrong — it's that none of them are looking at the seam.