integration-testing-strategy
verified30c5e3b9-10ae-41d8-91e4-f5d4bcfb6093
Use when deciding where integration tests earn their cost — Testcontainers for real Postgres/Redis, testing contracts once, mocking everywhere else. The test pyramid applied.
Metadata
Skill file
# Integration Testing Strategy
**Use when** deciding where integration tests earn their cost, and how to set them up with real infrastructure without becoming flaky or slow.
## The Test Pyramid
```
/\\ E2E tests (few, slow, expensive)
/ \\ Integration (dozens, medium)
/____\\ Unit tests (hundreds, fast, cheap)
```
| Layer | Count | Speed | Purpose |
|-------|-------|-------|---------|
| Unit | Hundreds | ms each | Logic, edge cases |
| Integration | Dozens | seconds each | Contracts, wiring, serialization |
| E2E | A few | minutes | Happy-path user journeys |
### Where Integration Tests Earn Their Cost
Test the *boundaries you can't unit test*:
| Boundary | Test with real... | Once per... |
|----------|-------------------|-------------|
| DB schema / migrations | Postgres | Migration |
| SQL queries (joins, constraints) | Postgres | New query |
| Serialization (JSON/CSV) | In-memory | New codec |
| Message queue | Redis/RabbitMQ | New consumer |
| HTTP contract | WireMock/real service | Contract change |
## Testcontainers Pattern (Python)
Spin up a real Postgres/Redis in a test:
```python
# tests/integration/test_db.py
import pytest
from testcontainers.postgres import PostgresContainer
from sqlalchemy import create_engine, text
@pytest.fixture(scope="module")
def postgres():
"""Spin up a real Postgres for the whole module."""
with PostgresContainer("postgres:16-alpine") as pg:
yield pg.get_connection_url()
def test_migration_and_query(postgres):
engine = create_engine(postgres)
with engine.connect() as conn:
conn.execute(text("CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT)"))
conn.execute(text("INSERT INTO users (name) VALUES ('alice')"))
result = conn.execute(text("SELECT count(*) FROM users")).scalar()
assert result == 1
```
### Requirements
```bash
pip install testcontainers[postgres] testcontainers[redis]
# Requires Docker running:
docker info # verify daemon is up
```
## The Golden Rule: Test the Contract Once, Mock Everywhere Else
```python
# Contract test (integration) — test the DB contract once here:
def test_user_repo_roundtrip(postgres):
repo = UserRepo(create_engine(postgres))
repo.save(User(id="1", email="a@b.com"))
assert repo.find_by_email("a@b.com").id == "1"
# Everywhere else — mock the repo (unit):
def test_register_duplicate_email():
repo = Mock()
repo.find_by_email.return_value = User(id="1", email="a@b.com")
with pytest.raises(DuplicateError):
register("a@b.com", repo=repo)
```
This gives you: 1 real test proving the DB works + 100 fast unit tests that mock the DB.
## Testcontainers with Docker Compose
For multi-service integration (app + DB + Redis + queue):
```yaml
# docker-compose.test.yml
version: "3.8"
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: test
ports: ["5432:5432"]
redis:
image: redis:7-alpine
ports: ["6379:6379"]
```
```bash
# In CI or locally:
docker compose -f docker-compose.test.yml up -d --wait
pytest tests/integration/ -x
docker compose -f docker-compose.test.yml down -v # -v removes volumes
```
## Choosing the Boundary: Test vs Mock
```python
# Decision table
| Scenario | Action |
|----------|--------|
| Testing your SQL query | Real DB (integration) |
| Testing business logic that uses a repo | Mock the repo (unit) |
| Testing a migration | Real DB (integration) |
| Testing error when DB is down | Mock to raise (unit) |
| Testing serialization format | In-memory (unit) |
| Testing the wiring of 3 services | E2E (rare) |
```
## Cleanup and Isolation
```python
@pytest.fixture(autouse=True)
def clean_db(postgres):
"""Truncate all tables before each test — prevents cross-test coupling."""
engine = create_engine(postgres)
with engine.connect() as conn:
conn.execute(text("TRUNCATE users, orders RESTART IDENTITY CASCADE"))
yield
def test_insert_user(postgres):
# Starts clean every time
...
```
## Guardrails
- Integration tests should be **few and meaningful** — dozens, not hundreds
- Never share state between integration tests (truncate tables, isolated schemas)
- Never depend on test execution order
- Pin container versions (`postgres:16-alpine`, not `postgres:latest`)
- Clean up containers: use context managers (`with PostgresContainer(...)`) or compose `down -v`
## Pitfalls
| Pitfall | Fix |
|---------|-----|
| Integration tests depend on shared state/order | Truncate tables in `autouse` fixture |
| Flaky tests from shared containers | One container per module (`scope="module"`), pin versions |
| Every unit test hits the real DB | Mock the repo in unit tests; only contract tests hit the DB |
| Containers not cleaned up | Context managers + `down -v` |
| Slow suite (integration in the unit path) | Separate markers: `@pytest.mark.integration` |
### Marking Integration Tests
```python
# conftest.py
def pytest_configure(config):
config.addinivalue_line("markers", "integration: slow tests requiring Docker")
# Test file
@pytest.mark.integration
def test_db_roundtrip(postgres): ...
# Run only unit tests (fast):
pytest -m "not integration"
# Run everything:
pytest
```
## Verify / Checklist
- [ ] Integration tests use real containers (Postgres/Redis) with pinned versions
- [ ] Contract tested once per boundary; mocked elsewhere
- [ ] No shared state or ordering dependency between tests
- [ ] Cleanup via context managers or `docker compose down -v`
- [ ] Integration tests marked and excludable (`-m "not integration"`)
- [ ] Docker available (`docker info` succeeds) before running
Attached files
No attached files.