Skip to content

♻️ Extract shared test fixtures to tests/fixtures/ package #170

Description

@sodre

Summary

The test suite creates ~122 CloudFormation stacks per full run (one per test function or class), each taking 2-10 seconds. This makes integration and E2E tests painfully slow despite tests needing only data isolation, not infrastructure isolation. Using shared session-scoped stacks with namespace-based test isolation eliminates most stack creation overhead.

Problem

Current State

Layer Tests Stacks created Stack scope Time per stack
Unit many 0 (moto)
Integration 85 ~63 mostly function ~2-10s (LocalStack)
E2E 75 ~29 mixed function/class ~2-10s (LocalStack)
Benchmark 81 ~30 function (LocalStack ones) ~2-10s (LocalStack)

~122 CloudFormation stacks per full test run. Each stack creation includes CloudFormation waiter polling + optional Lambda ESM stabilization (~45s on real AWS). The majority (103/122) are function-scoped, meaning each test creates and tears down its own stack.

Root Cause

Tests only need data isolation (separate entities, buckets, configs), not infrastructure isolation (separate DynamoDB tables, Lambdas, IAM roles). Since v0.10.0, namespaces provide exactly this — every DynamoDB key is prefixed with {namespace_id}/, giving complete data isolation within a shared table.

Additional Issues

  1. Fixture duplication: integration/conftest.py and e2e/conftest.py are ~93% identical (~120 lines duplicated)
  2. Cross-module imports: benchmark/conftest.py imports from both, creating fragile dependencies
  3. No single source of truth: Hard to know which conftest is "canonical"

Proposed Solution

Shared Stacks (Session-Scoped)

Only 3-4 distinct stack configurations are needed across all tests:

Fixture Config Used by
shared_minimal_stack No aggregator, no alarms Most integration, some e2e
shared_aggregator_stack Aggregator enabled Aggregator tests, some benchmarks
shared_full_stack Aggregator + alarms Full e2e tests

Each creates the stack once per pytest session and deletes it at teardown.

Per-Test Namespace (Function-Scoped)

@pytest.fixture
async def test_namespace(shared_minimal_stack):
    """Register a unique namespace for this test, return a scoped Repository."""
    ns_name = f"test-{uuid.uuid4().hex[:8]}"
    repo = await (
        Repository.builder(shared_minimal_stack.name, "us-east-1",
                          endpoint_url=shared_minimal_stack.endpoint_url)
        .namespace(ns_name)
        .build()
    )
    yield repo
    # No cleanup needed — session teardown deletes the whole stack

Namespace registration cost: 2 WCU per namespace (~10ms on LocalStack), cached after first call.

Package Structure

Extract all shared fixtures to tests/fixtures/:

tests/fixtures/
├── __init__.py           # Re-exports
├── moto.py               # aws_credentials, mock_dynamodb, patch_aiobotocore_response
├── names.py              # unique_name, unique_namespace factories
├── stacks.py             # Session-scoped shared stack fixtures
├── repositories.py       # Namespace-scoped repo/limiter factories
├── aws_clients.py        # Boto3 client factories
├── polling.py            # E2E polling helpers
├── capacity.py           # Benchmark CapacityCounter
└── doctest_helpers.py    # Doctest mock classes and stubs

Key design: stacks.py manages session-scoped shared stacks. repositories.py creates function-scoped namespace-isolated repos within those shared stacks.

Impact on conftest.py Files

File Before After
unit/conftest.py (102 lines) Inline moto fixtures Imports from tests.fixtures.moto
integration/conftest.py (152 lines) Per-test stack creation Imports shared stack + namespace fixture
e2e/conftest.py (373 lines) Per-test stack creation Imports shared stack + namespace fixture + polling
benchmark/conftest.py (334 lines) Cross-imports from unit/integration Imports from tests.fixtures directly
doctest/conftest.py (547 lines) Manual _namespace_id hacking Uses make_moto_repo() from fixtures

Moto Fixtures (Unchanged Strategy)

Unit tests keep using create_table() + manual namespace registration (moto doesn't support CloudFormation). They benefit from the tests/fixtures/ extraction (eliminating duplication, adding namespace awareness).

Expected Performance Improvement

Layer Before After Savings
Integration (85 tests) ~63 stacks × ~5s ≈ 5 min 1 stack + 85 namespaces × ~10ms ≈ 6s ~98%
E2E (75 tests) ~29 stacks × ~5s ≈ 2.5 min 2-3 stacks + 75 namespaces × ~10ms ≈ 11s ~95%
Benchmark (81 tests) ~30 stacks × ~5s ≈ 2.5 min 1-2 stacks shared ≈ 10s ~93%

Edge Cases

  • Tests needing distinct stack configs (e.g., specific Lambda timeout): still create their own stack, but these are rare
  • Aggregator tests: share shared_aggregator_stack — all namespaces' stream records are processed by the same Lambda (which already extracts namespace_id from PKs via ✨ Aggregator: namespace extraction from stream records #367)
  • Parallel test execution (xdist): each worker gets its own namespace within the shared stack — natural isolation

Tasks

  • Create tests/fixtures/__init__.py package
  • Extract moto fixtures to tests/fixtures/moto.py
  • Create tests/fixtures/names.py with unique_name and unique_namespace factories
  • Create tests/fixtures/stacks.py with session-scoped shared stack fixtures
  • Create tests/fixtures/repositories.py with namespace-scoped repo/limiter factories
  • Create tests/fixtures/aws_clients.py with boto3 client factories
  • Create tests/fixtures/polling.py with E2E polling helpers
  • Create tests/fixtures/capacity.py with benchmark CapacityCounter
  • Create tests/fixtures/doctest_helpers.py with doctest mock classes
  • Refactor tests/unit/conftest.py to import from fixtures
  • Refactor tests/integration/conftest.py to use shared stack + namespace fixtures
  • Refactor tests/e2e/conftest.py to use shared stack + namespace fixtures
  • Refactor tests/benchmark/conftest.py to import from fixtures directly
  • Refactor tests/doctest/conftest.py to use fixture helpers
  • Update CLAUDE.md Testing section with fixture package documentation
  • Add .claude/rules/testing.md fixture organization guidelines

Acceptance Criteria

  • No duplicated fixture code between conftest files
  • Integration and E2E tests use session-scoped shared stacks (not per-test stacks)
  • Each test gets a unique namespace for data isolation
  • All tests pass with refactored fixtures
  • tests/fixtures/ is properly documented in CLAUDE.md
  • Test execution time reduced by >90% for integration and E2E suites
  • Parallel test execution (xdist) works with namespace isolation

Documentation Updates

CLAUDE.md Changes

Add to Testing section:

### Test Fixtures

Shared fixtures live in `tests/fixtures/` package:
- `moto.py` - Moto mocking, aiobotocore compatibility
- `names.py` - unique_name, unique_namespace factories
- `stacks.py` - Session-scoped shared CloudFormation stacks
- `repositories.py` - Namespace-scoped repo/limiter factories
- `aws_clients.py` - AWS client factories
- `polling.py` - E2E polling helpers
- `capacity.py` - Benchmark CapacityCounter

Tests use shared stacks (session-scoped) with namespace isolation (function-scoped):
- Stack creation: once per session per distinct config
- Namespace registration: once per test (~2 WCU, ~10ms)
- No cleanup needed: stack deletion wipes all namespaces

Metadata

Metadata

Assignees

Labels

area/ciCI/CD workflowstestingTest coverage

Type

Fields

No fields configured for Chore.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions