Skip to content
Back to Quality Assurance

Integration Testing

Test how multiple modules work together, including real databases and APIs where it matters.

Why

Unit tests prove each function works alone, but most bugs live in the seams — the format the database returns, the shape the API expects, the transaction that rolls back incorrectly. Integration tests exercise the real wiring between modules and catch mismatched assumptions that unit tests with mocks cannot see. A service that returns {id: 1} in a unit test but {userId: 1} from the real database will pass every unit test and fail in production. Integration tests are the cheapest way to catch contract drift before deploy.

When

After the unit tests pass and before end-to-end tests. Write one per API endpoint, one per database repository method, and one per cross-module workflow. Run on every PR (not every save — they are slower).

How

1. Choose the boundaries to test across
   - API handler → service → repository → database (full vertical slice)
   - Service A → message queue → Service B (async integration)
   - Module → third-party API (use a sandbox or recorded responses)

2. Stand up real dependencies in a test environment
   - Database: a containerized instance (Testcontainers, docker-compose)
   - Queue: an in-memory broker or a disposable container
   - Third-party: record real responses once, replay (e.g. nock, VCR)

3. Seed known data, run the flow, assert the observable outcome
   - Insert a user with known fields, call the endpoint, query the DB
   - Assert on the persisted state, not just the HTTP response body
   - Clean up between tests (truncate tables, drop temp queues)

4. Keep them independent and parallelizable
   - Each test creates its own data; no shared fixtures that mutate
   - Use unique IDs or namespaces per test so parallel runs don't collide

5. Run in CI on every PR, not on every save
   - Tag them separately from unit tests (pytest -m integration)
   - Time-box the suite; if it exceeds 5 min, investigate the slow tests

Example

# tests/integration/test_create_order.py
import pytest
from httpx import AsyncClient
from app.main import app
from app.db import get_session

@pytest.mark.integration
async def test_create_order_persists_and_decrements_stock():
    async with get_session() as session:
        # Seed a product with 5 in stock
        await session.execute("INSERT INTO products (id, stock) VALUES (42, 5)")

    async with AsyncClient(app=app) as client:
        resp = await client.post("/orders", json={"product_id": 42, "qty": 2})

    assert resp.status_code == 201
    order_id = resp.json()["id"]

    # Verify the order was persisted
    async with get_session() as session:
        order = await session.fetch_one("SELECT * FROM orders WHERE id = $1", order_id)
        assert order["qty"] == 2
        product = await session.fetch_one("SELECT stock FROM products WHERE id = 42")
        assert product["stock"] == 3  # 5 - 2 = 3

Common Problems

  • ⚠Using a shared database across all integration tests causes cascading failures — one test leaves dirty data and the next ten tests fail for reasons unrelated to their own logic, making the root cause very hard to find.
  • ⚠Mocking the database in an integration test defeats the entire purpose — you are back to testing against a fake that behaves differently from the real Postgres/MySQL, so the contract drift you wanted to catch slips through.
  • ⚠Integration tests that take 30+ seconds each get skipped or marked optional, and then the regressions they were meant to catch show up in staging or production instead.
  • ⚠Asserting only on the HTTP response and never querying the database means a handler can return 200 but silently fail to persist, and the test passes green.

Best Practices

  • ✓Give each integration test its own database schema or transaction that rolls back, so tests are isolated and can run in parallel without colliding.
  • ✓Use Testcontainers or a docker-compose override to spin up a real database per CI run — the few seconds of startup is worth catching real-engine behavior.
  • ✓Assert on the persisted state (query the DB directly) in addition to the API response, because the response can lie about what actually got saved.
  • ✓Tag integration tests separately from unit tests so you can run the fast suite locally on save and the full suite in CI on every PR.