bigonotes
Engineering PracticesTesting

Integration and Contract Tests

Test your code against real databases and services, keep test data isolated, and use contract tests to stop API breaks.

8 min readLast updated

Unit tests prove your logic works on its own. They can't prove that your SQL runs on the real database, or that the service you call still sends the fields you expect. Integration tests check your code against real dependencies, and contract tests check the agreement between two services. Together they catch the bugs that live at the boundaries, which is where many production incidents start.

What an integration test is#

An integration test runs your code together with one real dependency. Typical examples are a repository class against a real database, a consumer against a real message queue, or an HTTP client against a local copy of the service.

Keep the scope narrow. Test one boundary per test, not the whole system. A test that starts every service at once is an end-to-end test, and it has all the costs that come with that (see the test pyramid).

Fakes, in-memory databases or the real thing#

The "one real dependency" can be more or less real. You have three main options, and each one trades speed for realism.

Option Speed Realism Catches
In-memory fake (a dict-backed repo) Very fast Low Logic errors in the caller
Embedded database (SQLite in memory) Fast Medium Most query and schema errors
Real database in a container Slower High Dialect, type, index and migration errors

A fake is good for unit tests of the code that uses the repository. To test the repository itself, use a real database. SQLite is a quick start, but it differs from PostgreSQL or MySQL in types, locking and SQL dialect. If production runs PostgreSQL, your integration tests should too.

Example: testing a repository#

Here's the middle option in practice. The repository below writes and reads orders. The fixture gives each test its own empty in-memory database, so no test can see data from another.

import sqlite3

import pytest


class OrderRepo:
    def __init__(self, conn: sqlite3.Connection) -> None:
        self.conn = conn

    def add(self, customer: str, total_cents: int) -> int:
        cur = self.conn.execute(
            "INSERT INTO orders (customer, total_cents) VALUES (?, ?)",
            (customer, total_cents),
        )
        return cur.lastrowid

    def total_for(self, customer: str) -> int:
        row = self.conn.execute(
            "SELECT COALESCE(SUM(total_cents), 0) FROM orders"
            " WHERE customer = ?",
            (customer,),
        ).fetchone()
        return row[0]


SCHEMA = """
CREATE TABLE orders (
    id INTEGER PRIMARY KEY,
    customer TEXT NOT NULL,
    total_cents INTEGER NOT NULL CHECK (total_cents >= 0)
)
"""


@pytest.fixture
def repo():
    conn = sqlite3.connect(":memory:")  # a new, empty DB per test
    conn.execute(SCHEMA)
    yield OrderRepo(conn)
    conn.close()


def test_total_sums_only_that_customer(repo):
    repo.add("ada", 1500)
    repo.add("ada", 500)
    repo.add("bob", 999)
    assert repo.total_for("ada") == 2000


def test_total_is_zero_with_no_orders(repo):
    assert repo.total_for("nobody") == 0


def test_database_rejects_negative_total(repo):
    with pytest.raises(sqlite3.IntegrityError):
        repo.add("ada", -1)

The last test checks a database constraint. A dict-backed fake would never catch that, because the rule lives in the schema.

Real databases with Testcontainers#

SQLite was enough for that example, but the table above warned that it differs from production databases. To test against the real engine, use Testcontainers, a library that starts a real service in a Docker container for your tests and removes it afterward. You get the same database engine as production with no shared test server to manage. It needs Docker on the machine that runs the tests.

import pytest
import sqlalchemy
from testcontainers.postgres import PostgresContainer


@pytest.fixture(scope="session")
def engine():
    # One container for the whole run: starting one takes seconds
    with PostgresContainer("postgres:16") as pg:
        engine = sqlalchemy.create_engine(pg.get_connection_url())
        # Apply your real migrations here, e.g. with Alembic
        yield engine
        engine.dispose()


@pytest.fixture
def conn(engine):
    # Each test runs in a transaction that is rolled back,
    # so no data leaks into the next test
    with engine.connect() as connection:
        tx = connection.begin()
        yield connection
        tx.rollback()

The two fixtures split the work. The session fixture pays the start-up cost once. The per-test fixture keeps tests isolated without restarting the container.

Test data and isolation#

Both examples above went out of their way to give each test clean data. That's on purpose: integration tests fail in confusing ways when they share data. Follow a few rules to keep each test independent.

  • Each test creates the data it needs. Don't rely on rows that another test left behind.
  • Reset state after each test: roll back a transaction, truncate tables, or use a fresh database.
  • Use unique values (such as a random suffix on an email) when rollback isn't possible.
  • Keep shared seed data small and read-only, such as a list of countries.
  • Run your real migrations in tests, so a broken migration fails in CI, not in production.
  • Build test objects with small factory functions instead of large JSON dumps.

Contract tests#

Integration tests cover your code against a dependency you control. When the dependency is another team's service, you need a different tool. A contract is the set of requests one service sends and the responses it relies on. A contract test checks that both sides still agree on it, and it's much cheaper than starting both services together for an end-to-end test.

The usual style is consumer-driven: the consumer (the caller) writes down what it needs, and the provider (the service being called) runs those expectations in its own CI. The provider can then change anything the consumer doesn't rely on.

Pact is a widely used tool for this. The flow is:

  1. The consumer team writes a test against a Pact mock of the provider.
  2. Running that test produces a pact file: a JSON record of each request and the response the consumer expects.
  3. The pact file is shared, often through a Pact Broker server.
  4. The provider's CI replays each request against the real provider and checks the responses.
  5. If the provider breaks the contract, its build fails before deploy.

Here's a simplified pact file from step 2.

{
  "consumer": { "name": "checkout" },
  "provider": { "name": "pricing" },
  "interactions": [
    {
      "description": "a request for the price of A1",
      "providerState": "product A1 exists",
      "request": { "method": "GET", "path": "/prices/A1" },
      "response": {
        "status": 200,
        "body": { "sku": "A1", "price_cents": 1299, "discount_cents": 0 }
      }
    }
  ]
}

Example: the bug a contract test catches#

The root cause analysis note walks through an incident where an upstream service started to send a null field. The consumer assumed the field was always there, and checkout failed. A contract test would have caught this in the provider's CI.

The sketch below shows the idea without Pact, so you can see the check itself. The consumer's contract says discount_cents is always an integer, and the provider's tests check every response against it.

# What the checkout service (consumer) needs from the
# pricing service (provider). The consumer team owns this.
PRICE_CONTRACT = {
    "sku": str,
    "price_cents": int,
    "discount_cents": int,  # must never be null
}


def check_contract(body: dict, contract: dict) -> list[str]:
    errors = []
    for field, kind in contract.items():
        if field not in body:
            errors.append(f"missing field: {field}")
        elif not isinstance(body[field], kind):
            got = type(body[field]).__name__
            errors.append(f"{field}: expected {kind.__name__}, got {got}")
    return errors


def get_price(sku: str) -> dict:
    # Stand-in for the provider's real handler
    return {"sku": sku, "price_cents": 1299, "discount_cents": 0}


def test_provider_meets_checkout_contract():
    assert check_contract(get_price("A1"), PRICE_CONTRACT) == []


def test_null_discount_breaks_contract():
    body = {"sku": "A1", "price_cents": 1299, "discount_cents": None}
    errors = check_contract(body, PRICE_CONTRACT)
    assert errors == ["discount_cents: expected int, got NoneType"]

End-to-end tests: keep them few#

Contract tests let you skip most end-to-end tests, but not all of them. An end-to-end (E2E) test drives the whole deployed system, often through a browser or the public API. It's the only test that proves all the parts work together. It's also the slowest, the most fragile, and the hardest to debug.

  • Cover only the few journeys that must never break, such as sign-up, log-in and checkout.
  • Don't use E2E tests for edge cases; test those at a lower layer.
  • Wait for conditions (an element appears, a status changes), never for a fixed time.
  • Give each run its own test account and data, so parallel runs don't collide.
  • Run them against a staging environment after deploy, and as smoke tests in production.

Testing in CI#

The last piece is running all these layers automatically. Continuous integration (CI) runs your tests on every push or pull request. Order the jobs so fast feedback comes first.

  1. Run linters and unit tests. They finish in seconds to minutes.
  2. Run integration and contract tests if the unit tests pass.
  3. Deploy to staging and run the small E2E suite.
  4. Block the merge or the deploy when any step fails.

A minimal GitHub Actions workflow for the first two steps looks like this. GitHub-hosted Ubuntu runners include Docker, so Testcontainers works there.

name: tests
on: [push, pull_request]

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements-dev.txt
      - run: pytest tests/unit -q

  integration:
    needs: unit
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements-dev.txt
      - run: pytest tests/integration -q

Keep the main branch green. If a test is flaky, quarantine it and fix it, rather than teaching the team to re-run failed builds.

Key takeaways#

  • Integration tests check one real boundary: your code plus a real database, queue or API.
  • Test a repository against the same database engine as production; Testcontainers makes that cheap.
  • Isolate test data: each test creates its own rows and cleans up, often with a rolled-back transaction.
  • Consumer-driven contract tests catch breaking API changes in the provider's CI, before deploy.
  • Keep E2E tests few and focused on critical user journeys.
  • In CI, run fast tests first and block merges on any failure.
Revision notes · 2 min

Core idea#

Integration tests check your code against one real dependency. Contract tests check the agreement between two services. Together they catch bugs at boundaries, where many incidents start.

Key points#

  • Test one boundary per integration test: a repository with a database, a consumer with a queue.
  • A test that starts every service is end-to-end, with all its costs.
  • Use a fake for code that uses a repository. Use a real database to test the repository itself.
  • SQLite differs from PostgreSQL in types, locking and dialect. Match the production engine.
  • Testcontainers starts a real service in Docker for tests and removes it afterward.
  • Start the container once per session. Wrap each test in a transaction and roll it back.

Dependency options#

Option Speed Realism Catches
In-memory fake Very fast Low Logic errors in the caller
SQLite in memory Fast Medium Most query and schema errors
Real DB in a container Slower High Dialect, type, index, migration errors

Test data#

  • Each test creates its own data. Never rely on rows another test left.
  • Reset state after each test: roll back, truncate, or use a fresh database.
  • Use unique values when rollback is not possible.
  • Run real migrations in tests, so a broken migration fails in CI.
  • Build test objects with small factory functions.

Contract tests#

  • Consumer-driven: the consumer states what it needs, and the provider runs those expectations in its CI.
  • The provider can change anything the consumer does not rely on.
  • Pact flow: consumer test against a mock, pact file, share it (often through a Pact Broker), provider replays it, provider build fails on a break.
  • Example: a provider starts sending a null discount_cents. The contract test fails in the provider's CI, before deploy.

E2E and CI#

  • Keep E2E tests to critical journeys: sign-up, log-in, checkout.
  • Wait for conditions, never a fixed time. Give each run its own account and data.
  • CI order: lint and unit tests, then integration and contract tests, then staging E2E. Block on any failure.

Pitfalls#

  • A dict-backed fake never catches a schema constraint, such as CHECK (total_cents >= 0).
  • Shared test data causes confusing, order-dependent failures.
  • Edge cases in E2E tests. Test them at a lower layer.
  • Re-running flaky builds. Quarantine and fix the test.

Say it in an interview#

  • "I test the repository against the same database engine as production, with Testcontainers."
  • "Consumer-driven contract tests catch breaking API changes in the provider's CI."
  • "Each test runs in a rolled-back transaction, so no data leaks between tests."

Found a mistake? Email ask@bigonotes.com

Further reading#