Testing Strategy
Overview
Define what gets tested, at what level, with what mocking — before tests proliferate. Output is .forge/testing-strategy.md — the test pyramid for this project (unit/integration/e2e ratio), what each level is responsible for, mocking philosophy, per-component coverage targets, the critical-path inventory, the flake quarantine policy, and CI gate definitions. Consumed by incremental-implementation, tdd, and code-review-and-quality.
When to Use
- A new project is starting and needs test conventions before code lands
- The test suite has grown organically and is slow, flaky, or inconsistent
- Coverage % is being treated as a goal and producing meaningless tests
- A team is mocking everything internally and tests pass while features break
- CI takes >15 minutes and the team is starting to skip tests
When NOT to Use
- Single-task work where
tdd is the right skill (write the test, write the code)
- Reference implementations or learning projects where tests aren't shipped
- The strategy exists, is followed, and only one component needs adjustment
Common Rationalizations
| Thought |
Reality |
| "100% coverage is the goal" |
Coverage measures lines hit, not behavior verified. 100% coverage of a deleted file is meaningless. |
| "Mock everything" |
Mocking internals tests the mock, not the code. When the implementation changes, mocks lie. |
| "E2E is too slow, skip it" |
One e2e test catches what 50 unit tests miss — integration between layers is where most bugs live. |
| "We'll add tests after" |
Tests-after test imagined behavior, not actual behavior. They lock in accidents as decisions. |
| "Flaky tests just need a retry" |
Retries hide bugs. A flaky test is a heisenbug — quarantine and fix the root cause. |
| "Test the implementation so refactors stay safe" |
Refactors should change implementation freely. Tests on internals make refactoring expensive. |
Red Flags
- "100% coverage" as the explicit target
- A test file with
expect(spy).toHaveBeenCalledWith(...) and no other assertions
- Every external service mocked at function level (mock the boundary instead)
- Zero end-to-end tests for the primary user journey
- A test that takes >30 seconds and runs on every commit
- A test that passes when the underlying feature is manually broken (snapshot rot, weak assertions)
- A retry config on
pytest/vitest/jest to "fix" flakes
Core Process
Step 1: Identify critical user paths
Read .forge/prd.md if present. List the 3-7 user journeys that, if broken, ship a broken product. Each is an e2e candidate. Everything else is unit/integration.
Step 2: Decide test level per path
| Level |
Tests |
When |
| Unit |
Pure functions, business logic, transformations |
Single module, no I/O |
| Integration |
Module + its real adapter (real DB, real HTTP via testcontainers) |
Boundaries, contracts |
| E2E |
Full stack, real browser if frontend |
Critical user paths only |
Default ratio: 70% unit / 20% integration / 10% e2e. Adjust based on what's risky.
Step 3: Define mocking boundaries
The rule: mock at the seam, not inside the module.
- Mock the HTTP client, not your service-that-calls-HTTP
- Mock the message broker, not the consumer-that-uses-the-broker
- Mock time, randomness, and the file system at the lowest seam (injected clock, seeded RNG, in-memory FS)
- Never mock the database in integration tests — use testcontainers
Step 4: Set per-component coverage targets
Not "80% across the project." Per-component:
- Pure business logic: 90%+ line + branch coverage
- Adapters (HTTP/DB clients): integration-test coverage (line % less meaningful)
- UI components: behavior coverage (clicks, keyboard, screen reader), not snapshot
- Glue code: lower bar, but every error path tested
Document each target in .forge/testing-strategy.md with rationale.
Step 5: Write golden tests for invariants
Identify 3-10 invariants per critical module (e.g., "ownership isolation never broken", "no negative balance possible", "no orphan rows after delete"). Write a property/golden test per invariant. These survive refactors.
Step 6: Define flake quarantine policy
When a test flakes:
- Quarantine immediately (skip with TODO + owner + deadline).
- Find root cause within 5 business days (timing? shared state? real bug?).
- Re-enable only after the root cause is fixed.
- Quarantine over 5 days = delete or rewrite.
No retry-on-fail config in CI. Flakes are bugs, not noise.
Step 7: Define CI gates
In .forge/testing-strategy.md:
- Which tests run on every push, every PR, nightly
- Total CI budget (e.g., <10 min PR, <30 min nightly)
- What's allowed to block merge (failing tests, coverage drop on critical modules)
- What's monitoring-only (flake rate, suite duration trend)
Step 8: Header
Prepend a forge:meta header to .forge/testing-strategy.md (generated_by: testing-strategy, generated_at: <ISO 8601 UTC with Z>, depends_on: [.forge/prd.md] — paths only, never hashes, generated_from: {.forge/prd.md: <upstream content_hash AT generation time>}, content_hash: <sha256 first 8 of THIS file's body>). See forge-dependency-graph.
Verification
1---2name: testing-strategy3description: Use when defining a project's test approach, when choosing what gets tested at unit vs integration vs e2e level, when setting per-component coverage targets, when reviewing test quality, or when CI is slow / flaky and tests need a strategy reset.4---56# Testing Strategy78## Overview910Define what gets tested, at what level, with what mocking — *before* tests proliferate. Output is `.forge/testing-strategy.md` — the test pyramid for this project (unit/integration/e2e ratio), what each level is responsible for, mocking philosophy, per-component coverage targets, the critical-path inventory, the flake quarantine policy, and CI gate definitions. Consumed by `incremental-implementation`, `tdd`, and `code-review-and-quality`.1112## When to Use1314- A new project is starting and needs test conventions before code lands15- The test suite has grown organically and is slow, flaky, or inconsistent16- Coverage % is being treated as a goal and producing meaningless tests17- A team is mocking everything internally and tests pass while features break18- CI takes >15 minutes and the team is starting to skip tests1920## When NOT to Use2122- Single-task work where `tdd` is the right skill (write the test, write the code)23- Reference implementations or learning projects where tests aren't shipped24- The strategy exists, is followed, and only one component needs adjustment2526## Common Rationalizations2728| Thought | Reality |29|---------|---------|30| "100% coverage is the goal" | Coverage measures lines hit, not behavior verified. 100% coverage of a deleted file is meaningless. |31| "Mock everything" | Mocking internals tests the mock, not the code. When the implementation changes, mocks lie. |32| "E2E is too slow, skip it" | One e2e test catches what 50 unit tests miss — integration between layers is where most bugs live. |33| "We'll add tests after" | Tests-after test imagined behavior, not actual behavior. They lock in accidents as decisions. |34| "Flaky tests just need a retry" | Retries hide bugs. A flaky test is a heisenbug — quarantine and fix the root cause. |35| "Test the implementation so refactors stay safe" | Refactors should change implementation freely. Tests on internals make refactoring expensive. |3637## Red Flags3839- "100% coverage" as the explicit target40- A test file with `expect(spy).toHaveBeenCalledWith(...)` and no other assertions41- Every external service mocked at function level (mock the boundary instead)42- Zero end-to-end tests for the primary user journey43- A test that takes >30 seconds and runs on every commit44- A test that passes when the underlying feature is manually broken (snapshot rot, weak assertions)45- A retry config on `pytest`/`vitest`/`jest` to "fix" flakes4647## Core Process4849### Step 1: Identify critical user paths5051Read `.forge/prd.md` if present. List the 3-7 user journeys that, if broken, ship a broken product. Each is an e2e candidate. Everything else is unit/integration.5253### Step 2: Decide test level per path5455| Level | Tests | When |56|-------|-------|------|57| Unit | Pure functions, business logic, transformations | Single module, no I/O |58| Integration | Module + its real adapter (real DB, real HTTP via testcontainers) | Boundaries, contracts |59| E2E | Full stack, real browser if frontend | Critical user paths only |6061Default ratio: 70% unit / 20% integration / 10% e2e. Adjust based on what's risky.6263### Step 3: Define mocking boundaries6465The rule: **mock at the seam, not inside the module.**66- Mock the HTTP client, not your service-that-calls-HTTP67- Mock the message broker, not the consumer-that-uses-the-broker68- Mock time, randomness, and the file system at the lowest seam (injected clock, seeded RNG, in-memory FS)69- Never mock the database in integration tests — use testcontainers7071### Step 4: Set per-component coverage targets7273Not "80% across the project." Per-component:74- Pure business logic: 90%+ line + branch coverage75- Adapters (HTTP/DB clients): integration-test coverage (line % less meaningful)76- UI components: behavior coverage (clicks, keyboard, screen reader), not snapshot77- Glue code: lower bar, but every error path tested7879Document each target in `.forge/testing-strategy.md` with rationale.8081### Step 5: Write golden tests for invariants8283Identify 3-10 invariants per critical module (e.g., "ownership isolation never broken", "no negative balance possible", "no orphan rows after delete"). Write a property/golden test per invariant. These survive refactors.8485### Step 6: Define flake quarantine policy8687When a test flakes:881. Quarantine immediately (skip with TODO + owner + deadline).892. Find root cause within 5 business days (timing? shared state? real bug?).903. Re-enable only after the root cause is fixed.914. Quarantine over 5 days = delete or rewrite.9293No retry-on-fail config in CI. Flakes are bugs, not noise.9495### Step 7: Define CI gates9697In `.forge/testing-strategy.md`:98- Which tests run on every push, every PR, nightly99- Total CI budget (e.g., <10 min PR, <30 min nightly)100- What's allowed to block merge (failing tests, coverage drop on critical modules)101- What's monitoring-only (flake rate, suite duration trend)102103### Step 8: Header104105Prepend a `forge:meta` header to `.forge/testing-strategy.md` (`generated_by: testing-strategy`, `generated_at: <ISO 8601 UTC with Z>`, `depends_on: [.forge/prd.md]` — paths only, never hashes, `generated_from: {.forge/prd.md: <upstream content_hash AT generation time>}`, `content_hash: <sha256 first 8 of THIS file's body>`). See [forge-dependency-graph](../../references/forge-dependency-graph.md).106107## Verification108109- [ ] `.forge/testing-strategy.md` written with per-component coverage targets and rationale110- [ ] Critical user paths each have at least one e2e test111- [ ] Boundary modules (HTTP / DB / queue) have integration tests against real dependencies (testcontainers or similar)112- [ ] Pure logic modules have unit tests with >90% branch coverage113- [ ] No mock of an internal collaborator (only mocks at seams)114- [ ] No test takes >5 seconds without a documented exception115- [ ] Flake rate <1% over the trailing 30 days (or quarantine policy active)116- [ ] CI runs in <10 minutes for PRs117- [ ] No `retries: N` config used to mask flakes