Testing Backends: Unit, Integration, and Contract Tests
LESSON
Testing Backends: Unit, Integration, and Contract Tests
By the end of this lesson, you will be able to...
Choose a unit, integration, or contract test for a backend failure you want to catch.
Explain what evidence each test boundary provides and what it cannot prove.
Design a focused test set for one API change without turning every test into an end-to-end test.
Idea in one sentence: A backend test is useful when its boundary matches the failure it is meant to reveal.
Core Insight
The previous lesson separated authentication, authorization, and session state.
That lesson ended with a testing checklist: no session, expired session, wrong permission, wrong resource, stale role, allowed action.
Now we need to choose where those checks should live.
Imagine the orders API adds payment authorization during checkout:
POST /orders
The team writes a unit test for the function that calculates the order total. It passes.
The team writes a unit test for the handler branch that calls authorizePayment. It passes too, because the payment provider is mocked.
The release goes out. Production checkout fails.
The real payment provider expected this JSON:
{
"amount_cents": 4497,
"currency": "USD"
}
The backend sent this:
{
"amount": 4497,
"currency": "USD"
}
The tests were green. The system was still broken.
That does not mean unit tests are bad. It means they answered a different question.
They proved local calculation and local branching under controlled inputs. They did not prove that the backend and the provider agreed on the request shape sent across the network.
The missing idea is the test boundary.
Plain meaning:
A test boundary is the line around the real behavior a test exercises.
In this scenario:
A unit test around the handler replaced the provider with a mock. That boundary could not catch a provider contract mismatch.
Technical name:
Choosing that line is test boundary design. Unit, integration, and contract tests are different boundary choices.
The Small Situation
Use one checkout path:
HTTP request
-> JSON validation
-> authentication and authorization
-> calculate order total
-> write order rows in a database transaction
-> call payment provider
-> map provider result to API response
The backend owes several promises:
- invalid JSON returns a validation error with field paths,
- unauthenticated users get an authentication response,
- authenticated users can only create orders for themselves,
- order totals are rounded correctly,
- database writes are either committed coherently or not committed,
- the payment provider receives the field names and headers it expects,
- clients receive the API response shape they depend on.
One test cannot prove all of that cheaply.
Every test makes a trade:
more isolation -> faster feedback, less real wiring evidence
more real components -> stronger wiring evidence, slower and noisier tests
The beginner mistake is to ask:
Do we have tests?
A better question is:
Which failure would this test actually catch?
The Three Useful Boundaries
The names matter less than the evidence.
Still, these three categories are useful.
Unit Test
A unit test checks a small piece of behavior with most collaborators avoided or replaced.
Example:
input:
items:
prod_7 quantity 2 unit_price_cents 1999
prod_9 quantity 1 unit_price_cents 499
discount_percent: 10
run:
calculateOrderTotal(input)
expect:
total_cents = 4047
each line item is shown in the breakdown
What it proves well:
- arithmetic,
- edge cases,
- local policy functions,
- small transformations,
- behavior that does not require real infrastructure.
What it does not prove:
- the route calls this function,
- the database schema accepts the result,
- middleware ran first,
- a provider accepts the outbound JSON,
- the public API response stayed compatible.
This is not a weakness. It is the boundary.
Integration Test
An integration test exercises several real pieces together.
Example:
setup:
customer cus_42 exists
product prod_7 is active
test database is empty
request:
POST /orders as customer cus_42
expect:
response status is 201
orders row exists
order_items rows exist
total_cents is stored
audit event exists
What it proves well:
- route wiring,
- middleware order,
- JSON validation setup,
- database constraints,
- transaction behavior,
- migrations and queries working together.
What it does not prove:
- every provider contract,
- every browser or mobile client behavior,
- the exact behavior of production infrastructure,
- local calculation edge cases as cheaply as unit tests.
Integration tests are heavier because they need real setup. They are worth it when the risk lives in the wiring.
Contract Test
A contract test protects an agreement between a provider and a consumer.
There are two directions.
Your API as provider:
mobile app expects POST /orders 201 response:
order_id: string
status: "pending" or "paid"
total_cents: integer
Your backend as consumer:
payment provider expects:
amount_cents field
currency field
Idempotency-Key header
What it proves well:
- two sides agree on request or response shape,
- important fields keep their meaning,
- a provider fake or generated contract still matches expected behavior,
- independent teams can change code without silently breaking each other.
What it does not prove:
- all internal branches,
- the whole production journey,
- whether the contract itself was chosen wisely,
- behavior that nobody declared as part of the contract.
Contract tests are valuable when one side can change without the other side changing at the same time.
A Worked Test Design
Now design tests for one change.
Change:
POST /orders now requires idempotency_key.
The product promise is:
If a client retries the same checkout request with the same idempotency_key,
the backend should not create a duplicate order or charge twice.
The naive idea is:
Add a unit test for the handler branch.
That helps, but it cannot prove durable duplicate prevention. The bug may live in the database unique constraint, transaction order, retry behavior, or provider header.
Choose boundaries from the risks:
Risk A:
Missing idempotency_key should return validation_failed with field path.
Best boundary:
route integration test or API contract test
Why:
the public error shape matters to clients, and the request pipeline must run validation.
Does not prove:
duplicate prevention in storage.
Risk B:
Repeating the same key should not create a duplicate order.
Best boundary:
integration test with real database or real idempotency store
Why:
the risk is durable state under retry.
Does not prove:
the payment provider accepts the outbound request.
Risk C:
Payment provider request must include the key in the expected header.
Best boundary:
provider contract test or sandbox integration test
Why:
the failure lives at the provider boundary.
Does not prove:
local total calculation edge cases.
Risk D:
Order total calculation must round discounts correctly.
Best boundary:
unit test
Why:
the risk is local arithmetic.
Does not prove:
route wiring, database writes, or provider shape.
So far, the design rule is:
Start from the failure.
Choose the smallest boundary that can reveal that failure.
Name what the test still cannot prove.
Check: A route has perfect unit tests for authorization policy logic, but one endpoint forgot to call the policy. Which test boundary catches that wiring failure?
Think first, then reveal.
Answer: A route integration test. Send a request as an underprivileged actor and expect denial before the protected action runs. A unit test can prove the policy function works, but not that every route uses it.
What Test Doubles Change
A test double is a replacement used in a test: a fake, stub, mock, spy, in-memory store, fake server, or sandbox service.
Test doubles are useful. They are also a source of false confidence.
Suppose the real payment provider expects:
POST /authorize
Header: Idempotency-Key: idem_123
Body: {"amount_cents": 4497, "currency": "USD"}
Your mock accepts:
Body: {"amount": 4497, "currency": "USD"}
The mock is not lying. It is faithfully testing the world you invented for the test.
The danger is that the invented world does not match the real boundary.
A good test suite asks:
- Which doubles are only local conveniences?
- Which doubles are checked against a real contract?
- Which tests run against real infrastructure because the behavior depends on it?
- Which failures would pass because the double is too friendly?
Use doubles to keep feedback fast. Then add a smaller number of tests that verify the boundaries where friendly doubles would hide important disagreement.
Check: A unit test mocks the payment provider and passes. Can it prove the real provider accepts your JSON field names?
Think first, then reveal.
Answer: No. The mock may match your code's expectation instead of the provider's real contract. Use a provider contract test, a fake generated from the real contract, or a sandbox integration test for that boundary.
Common Confusions
Confusion: Unit Tests Are Less Serious
Why it is tempting:
Unit tests are small and often avoid real infrastructure, so they can feel less realistic.
Better model:
Unit tests are serious when the risk is local logic. A rounding rule, parser, permission decision, or state transition can be tested more clearly with a small boundary.
Confusion: Integration Tests Prove Production
Why it is tempting:
Integration tests use real pieces, so they feel close to reality.
Better model:
Integration tests prove the pieces included in the test environment. They may still use fake providers, test databases, smaller data, different network behavior, or simplified deployment settings.
Confusion: Contract Tests Are Full Behavior Tests
Why it is tempting:
Contracts can look official, so it is easy to treat them as complete truth.
Better model:
A contract test protects declared agreements. It does not prove every internal path or every business rule. A narrow contract is useful. A contract that includes every accidental field becomes expensive noise.
Trade-offs and Limits
Choosing boundaries deliberately improves signal.
Unit tests give fast feedback and precise failure messages. They cost design discipline because code must have testable local seams. They do not prove real wiring.
Integration tests catch wiring and state bugs. They cost setup, runtime, and isolation work. They can become flaky if tests share state or depend on timing.
Contract tests protect client and provider compatibility. They cost coordination. They can drift if generated files or fake providers are not checked against real consumers or providers.
Large end-to-end tests are still useful for a few critical journeys. They are not a replacement for every smaller test. If every failure requires starting the whole world, the suite becomes slow and hard to debug.
You can see the boundary is wrong when production incidents happen that the test suite could not possibly have caught. After the incident, ask:
Was this local logic?
Was this route or database wiring?
Was this a client/provider agreement?
Was this configuration or deployment behavior?
What is the smallest test boundary that would have revealed it?
Practice
Choose tests for this backend change:
Change:
DELETE /admin/products/:id now deactivates a product instead of physically deleting it.
Risks:
A. Support agents must still be denied.
B. Catalog admins may deactivate active products.
C. Products with shipped order history must remain queryable for old orders.
D. The mobile admin client expects the response field status to be "inactive".
E. The policy function should deny unknown actions.
A good answer should mention:
- A route integration test for A, because the risk is that request wiring skips authorization.
- An integration test for B, because the handler, policy, and database state should work together.
- A database-backed integration test for C, because the promise depends on durable state and relationships.
- An API contract test for D, because the client depends on a response shape.
- A unit test for E, because the policy function can be tested locally with actor, action, resource, and context.
- For each test, one thing it does not prove.
Model answer:
A should be a route integration test using a support session and expecting 403 before product state changes. B can be an integration test with a catalog admin session and a real test database, expecting the product state to become inactive. C should verify that old orders can still load the product after deactivation. D belongs in an API contract test because it protects the client-visible response. E is a unit test for policy logic because no HTTP request or database is needed to prove that unknown actions deny by default.
Connections
Lesson 009 gave us API response contracts. Lesson 010 gave us authorization and session boundaries. This lesson turns those boundaries into test choices.
The next lesson moves from tested code to deployed code: configuration, secrets, and the values that change between local, staging, and production.
Resources
- [ARTICLE] Martin Fowler: Test Pyramid
- Link: https://martinfowler.com/bliki/TestPyramid.html
- Focus: Use this for the trade-off between many fast lower-level tests and fewer broad tests.
- [ARTICLE] Martin Fowler: Contract Test
- Link: https://martinfowler.com/bliki/ContractTest.html
- Focus: Read for how contract tests protect calls across service boundaries.
- [DOC] Pact: Contract Testing
- Link: https://docs.pact.io/
- Focus: Explore one concrete tool ecosystem for consumer-driven contract tests.
- [ARTICLE] Google Testing Blog: Know Your Test Doubles
- Link: https://testing.googleblog.com/2013/07/testing-on-toilet-know-your-test-doubles.html
- Focus: Use this to distinguish fakes, stubs, mocks, and why test doubles shape evidence.
Key Takeaways
- A test boundary decides what evidence a test can provide.
- Unit tests are best for local logic; integration tests are best for real wiring and state; contract tests are best for client or provider agreements.
- Start from the failure you want to catch, then choose the smallest boundary that can reveal it.
- Test doubles keep feedback fast, but they can hide disagreement with real dependencies.
- A healthy backend suite mixes fast local tests with enough boundary evidence for the riskiest behavior.
← Back to Backend Development Foundations