Home » Contract Tests vs Unit Tests: Choosing What to Verify

Contract Tests vs Unit Tests: Choosing What to Verify

3 comments
Two dark metal couplings with green rings face each other across a narrow central gap.

Use unit tests to check behavior within a component, and contract tests to check that components agree on the messages they exchange. A client unit test can prove that it handles a mocked response correctly while the real provider has already stopped sending that response. A contract test can expose that mismatch, but a valid response shape can still contain a wrong business result.

These categories describe different questions, not competing test frameworks. A contract check may run inside the same unit-test runner as other checks. Decide what could fail and where the evidence must come from before choosing the label.

Separate three questions in one small system

Consider an order provider and a client that displays an order total. The client requests /orders/7 and expects a JSON object containing integer total_cents and string currency.

def display_total(fetch):
    body = fetch("/orders/7")
    return f"{body['total_cents'] / 100:.2f} {body['currency']}"

For this synthetic display example, 2500 cents becomes 25.00 USD. The example uses floating-point formatting for brevity; it is not a money-calculation library or a recommendation for financial arithmetic.

There are at least three useful checks:

Question Check Source of its expectation
Does the client request the right path and format the supplied data? Client unit test with a controlled fetch response. The client’s expected behavior.
Does the provider still produce fields the client can consume? Provider response contract check. The consumer’s message requirements.
Does the provider calculate the order total correctly? Business-behavior test. The pricing rule and inputs.

The tests overlap at a boundary, but they establish different things.

Why both unit suites can pass while integration breaks

A client unit test can inject a fetch function that always returns this object:

{"total_cents": 2500, "currency": "USD"}

The test checks the displayed result and that the client requested /orders/7. Python’s unittest.mock documentation describes this use of controlled return values and call assertions. The mock is useful precisely because it isolates the client from a real provider.

Now the provider renames total_cents to amount_cents. Its own tests may have been updated to expect the new field. The client test still passes because its mock did not change. The provider tests may also pass. The two sets of expectations have drifted apart.

A provider contract check tied to the consumer’s required field catches the rename before the components are deployed together. It should inspect an actual provider response from a known state, rather than validating another handwritten copy of the expected response.

An executed failure matrix

The local fixture uses Python 3.14.2 and unittest. It runs the client test, a narrow response contract check, and a business-result check against four provider variants.

The contract check deliberately requires only the fields and types consumed by this client:

def check_contract(body):
    assert type(body["total_cents"]) is int
    assert type(body["currency"]) is str

The exact type check excludes Python booleans, which are subclasses of int. In a real JSON schema, choose constraints consistent with the wire format and application contract.

The business check expects two items at 1,250 cents each to produce 2,500 cents. The executed results were:

Provider variant Client unit test Response contract Business result
Baseline: total_cents: 2500 Passed Passed Passed
Renamed field: amount_cents: 2500 Passed Failed Failed
Wrong arithmetic result: total_cents: 250 Passed Passed Failed
Added unused note field Passed Passed Passed

The client test stays green because it receives its controlled response in every run. The renamed field breaks the boundary requirement. The wrong total preserves the required field types, so only the business assertion exposes that particular error. The added field is accepted because this client ignores it.

The fixture directly calls provider functions and checks their returned dictionaries. It is a minimal contract exercise, not a Pact execution, HTTP integration test, or proof about serialization, routing, authentication, persistence, or deployment compatibility.

What a consumer-driven framework adds

Pact’s documented workflow connects a consumer test against a mock provider with verification against the real provider. The consumer’s interaction records what request it sends and what response it needs. Provider verification checks that the provider can satisfy those interactions in the specified states.

That linkage addresses a weakness in the small fixture: manually keeping an assertion and a mock aligned requires discipline. A shared contract artifact helps prevent each side from independently inventing a compatible-looking response. It still needs correct tests and representative provider states.

A provider state such as “order 7 exists with two items” should be established reproducibly during verification. A test that depends on whatever happens to be in a shared staging database can fail for reasons unrelated to compatibility, or pass accidentally because the data is favorable.

Contract publication, version selection, and deployment checks also need to reflect which consumer versions remain in use. Verifying only the newest consumer can miss a break for an older deployed client. This article does not run a contract broker or prescribe a version-selection configuration; use your chosen framework’s current documentation for that operational layer.

Keep business behavior at the appropriate level

A response contract can require a numeric field without exercising every calculation that produces it. Pact explicitly distinguishes message compatibility from functional side effects. An accepted create-order response does not by itself prove the order was persisted correctly.

Keep pricing edge cases close to the pricing component. Test persistence through the actual storage boundary where it matters. Test the client-facing representation through its contract. A small number of integrated workflows can then check that important pieces are connected correctly.

Do not make the contract unnecessarily strict. Requiring an exact response object when the client tolerates extra fields can turn harmless additions into failures. Conversely, a test that checks only “some JSON arrived” offers little protection. Include the method, path, status, headers, fields, and error forms the consumer actually relies on.

Enums deserve particular care. An added enum value may be an additive schema change yet still break a client with an exhaustive switch. Test the consumer’s fallback behavior or explicitly document the supported value set; “additive” does not automatically mean compatible.

Choose checks by the failure you need to detect

For a field rename, prioritize a consumer-linked provider contract. For a rounding bug, prioritize business examples at the calculation boundary. For a missing database write, verify persistence. For an incorrect client route, exercise request construction. For a proxy or TLS failure, use an appropriate integration environment.

The fixture’s most useful result is the wrong-total row: it shows why a passing contract test cannot stand in for a business test. The renamed-field row shows the opposite limit: a passing client unit test cannot establish provider compatibility. Together they give each test a clear job.

Documentation checked October 28, 2026. The fixture is synthetic, runs without network calls, and does not measure framework performance.

You may also like