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:
- The consumer team writes a test against a Pact mock of the provider.
- Running that test produces a pact file: a JSON record of each request and the response the consumer expects.
- The pact file is shared, often through a Pact Broker server.
- The provider's CI replays each request against the real provider and checks the responses.
- 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.
- Run linters and unit tests. They finish in seconds to minutes.
- Run integration and contract tests if the unit tests pass.
- Deploy to staging and run the small E2E suite.
- 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.