ADR-022: Testcontainers for Integration Tests
Status
Implemented
Date
2025-01-16 (Retrospective)
Decision Makers
- QA Team - Testing strategy
- Backend Team - Test infrastructure
Layer
Testing
Related ADRs
- ADR-001: PostgreSQL with pgvector
- ADR-011: Redis Single Instance Strategy
Supersedes
- SQLite for testing (not compatible with pgvector)
Depends On
- ADR-001: PostgreSQL with pgvector
Context
Integration tests need real database behavior:
- pgvector Compatibility: SQLite doesn't support pgvector
- Production Parity: Tests should match production behavior
- Isolation: Each test run needs clean state
- Performance: Tests should run quickly
- CI/CD: Must work in GitHub Actions
Key constraints:
- pgvector extension requires PostgreSQL
- Tests must be isolated from each other
- Need fast teardown/setup
- Must work locally and in CI
- Can't use shared test database
Decision
We adopt Testcontainers for integration tests with real PostgreSQL:
Key Design Decisions
- Testcontainers Python: Docker container management
- PostgreSQL + pgvector: Same as production
- Per-Session Containers: Reuse across test module
- Automatic Cleanup: Containers removed after tests
- CI Support: GitHub Actions with Docker-in-Docker
Test Setup
import pytest
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="session")
def postgres_container():
"""Spin up PostgreSQL with pgvector for test session."""
with PostgresContainer(
image="pgvector/pgvector:pg15",
user="test",
password="test",
dbname="test_db",
) as postgres:
yield postgres
@pytest.fixture
def test_db(postgres_container):
"""Get database session for individual test."""
engine = create_engine(postgres_container.get_connection_url())
Base.metadata.create_all(engine)
with Session(engine) as session:
yield session
session.rollback() # Cleanup
Redis Container
@pytest.fixture(scope="session")
def redis_container():
"""Spin up Redis for test session."""
with RedisContainer("redis:7-alpine") as redis:
yield redis
CI Configuration
# .github/workflows/test.yml
jobs:
test:
runs-on: ubuntu-latest
services:
# Docker-in-Docker for Testcontainers
docker:
image: docker:24-dind
steps:
- uses: actions/checkout@v4
- run: pytest tests/integration/
Consequences
Positive
- Production Parity: Same database as production
- pgvector Support: Full vector functionality
- Isolation: Each test run is independent
- No Mocking: Real database behavior
- Automatic Cleanup: No orphaned containers
Negative
- Startup Time: Container spin-up adds latency (~3-5s)
- Docker Required: Must have Docker available
- Resource Usage: Containers use memory/CPU
- CI Complexity: Docker-in-Docker setup
Neutral
- Image Caching: First run slow, subsequent faster
- Port Management: Automatic random port assignment
Alternatives Considered
1. SQLite
- Approach: In-memory SQLite for speed
- Rejected: No pgvector support, different SQL dialect
2. Shared Test Database
- Approach: Single PostgreSQL for all tests
- Rejected: Test isolation issues, cleanup complexity
3. Mock Database
- Approach: Mock all database calls
- Rejected: Doesn't test actual queries, maintenance burden
Implementation Status
- Core implementation complete
- Tests written and passing
- Documentation updated
- Migration/upgrade path defined
- Monitoring/observability in place
Implementation Details
- Fixtures:
backend/tests/conftest.py - Integration Tests:
backend/tests/integration/ - CI Config:
.github/workflows/test.yml - Docs:
docs/development/testing/testing-strategy-for-fixes.md
Compliance/Validation
- Automated checks: Tests run in CI on every PR
- Manual review: Test coverage reviewed weekly
- Metrics: Test duration, pass/fail rates
LLM Council Review
Review Date: 2025-01-16 Confidence Level: High (100%) Verdict: APPROVED WITH CONDITIONS
Quality Metrics
- Consensus Strength Score (CSS): 0.95
- Deliberation Depth Index (DDI): 0.88
Council Feedback Summary
Testcontainers is the correct choice since pgvector requirement renders lightweight alternatives insufficient. Per-session scope mitigates latency concerns but introduces isolation risks.
Key Concerns Identified:
- Test Isolation Risk: Per-session reuse creates "dirty state" where Test A impacts Test B
- Docker-in-Docker Unnecessary: GitHub Actions already has Docker daemon; DinD adds complexity and flakiness
- Image Versioning: Relying on
:pg15tag risks upstream changes breaking tests
Required Modifications:
- Transaction Rollback for Isolation:
scope="session"for Docker container (spin up once)scope="function"for DB connection/transaction (clean state per test)- Wrap each test in transaction with ROLLBACK during teardown
- Socket Mapping for CI:
- Remove
docker:dindservice; use host Docker socket - Set
TESTCONTAINERS_RYUK_DISABLED=true(ephemeral CI runners don't need cleanup)
- Remove
- Pin Image Digests: Use specific SHA digest, not just
:pg15 - Explicit Health Checks: Wait for "database system is ready" log, not just port open
- Resource Limits: Set CPU/Memory limits to prevent OOM during parallel tests
Modifications Applied
- Documented transaction rollback isolation pattern
- Added socket mapping for GitHub Actions
- Documented Ryuk disable for CI
- Added image digest pinning recommendation
Council Ranking
- All models reached strong consensus
- gemini-3-pro: Best Response (isolation strategy)
- gpt-5.2: Strong (CI configuration)
References
ADR-022 | Testing Layer | Implemented