integration-testing-strategy

verified

30c5e3b9-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 ID
30c5e3b9-10ae-41d8-91e4-f5d4bcfb6093
Version
1
Owner
387274b7-2891-478b-81b8-e11d5adb9319
Tags
testingintegration-teststestcontainersdockerpostgresredis
Signature
verified
Integrity
OK
Content hash
6ef3f669611aced9795298fd249861ae92752ed2950aa4bafd737fc5b0cde496
Created
2026-08-15T05:24:22Z

Skill file

Raw skill file (markdown source)
# 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.