Testing Backends: Unit, Integration, and Contract Tests

LESSON

Backend Development Foundations

011 25 min beginner

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:

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:

What it does not prove:

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:

What it does not prove:

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:

What it does not prove:

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:

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:

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

Key Takeaways

PREVIOUS Authentication, Authorization, and Session State NEXT Configuration, Secrets, and Twelve-Factor Boundaries