Design Testing Strategy
A reference manual for designing a fit-for-purpose, fit-for-criticality testing strategy.
This skill is decision-oriented, not philosophical: every gate is deterministic (ON when X / OFF when Y), every schema is enforced (field ordering matters), every example is worked end-to-end.
How To Use This Skill
- Read Decision Gates in order (Gate 0 -> Gate 6). Each gate is independent — you may finish with any subset of test types ON.
- Apply Strategic Skip Heuristics to remove ON gates that would yield low ROI for this artifact.
- For each ON gate, fill the Test Matrix Schema (
selected_types entry) — the field order is load-bearing.
- List rejected types in
rejected_types and deliberate skips in deliberately_skipped.
- Produce a Test Cases to Cover markdown bullet list using ISTQB techniques from Case Design Techniques.
- Cross-check against the matching Worked Example (A pure function / B HTTP+DB endpoint / C UI component).
Decision Gates
Apply gates in numeric order. Each gate produces an independent boolean (applies: true|false). Gates do NOT veto each other — a single artifact may have unit + integration + contract + property-based all ON.
| # |
Type |
ON when |
OFF when |
Source |
| 0 |
Skip All |
Criticality is NONE (docs-only, comments, formatting, generated code, config without logic, throwaway prototypes) |
Anything with branching, computed output, side effects, or user-visible behavior |
Pragmatic Programmer — "Test ruthlessly and effectively" implies effective skipping when ROI is zero |
| 1 |
Unit |
Code contains any logic: branches, loops, conditionals, computation, transformation, parsing, validation, formatting |
Pure declarative wiring (DI registration, route table) with no behavior |
Test Pyramid (Vocke) base layer + Beck TDD Red-Green-Refactor unit |
| 2 |
Integration |
Boundary crossing: HTTP call, DB query, external SDK, message queue, filesystem I/O, OR collaboration with >=2 distinct collaborators where unit doubles distort behavior |
Pure function with no I/O and 0-1 stable collaborators |
Testing Trophy (Dodds) — integration is the highest-ROI layer; Google "Follow the User" |
| 3 |
Component or E2E |
UI surface AND criticality >= MEDIUM-HIGH AND user-facing critical path (signup, checkout, auth, payment, primary CTA) |
Internal admin-only screens, dev tooling, or non-critical UI |
Test Pyramid top + ISO/IEC/IEEE 29119 risk ranking + Google e2e principles |
| 4 |
Contract |
Public API consumed by >=1 distinct clients (mobile + web, multiple internal services, external partners) AND independent deploy cadence |
API where consumer and provider deploy together |
Pact / CDC + Pactflow CDC explainer |
| 5 |
Smoke |
Deployable surface (web app, API, service) AND a deploy/CI pipeline exists where post-deploy validation is meaningful |
Library, internal helper, or no deploy pipeline |
Google "What Makes a Good End-to-End Test" — smoke = minimal e2e for deploy gate |
| 6 |
Property-Based |
Input domain is large or unbounded (numeric ranges, strings, lists, parsers, serializers, encoders, math) AND invariants are stable (round-trip, idempotency, monotonicity, commutativity) AND criticality >= MEDIUM-HIGH |
Small finite input domain, unstable invariants, or LOW criticality |
Hypothesis / QuickCheck |
Gate Application Algorithm
for gate in [Gate 0, Gate 1, ..., Gate 6]:
if gate.ON_condition_met(artifact):
result[gate.type] = applies: true
else:
result[gate.type] = applies: false
if Gate 0 is true:
short-circuit: emit empty selected_types, document criticality=NONE, stop
Criticality Scale (used by Gates 3 and 6):
| Level |
Definition |
NONE |
Docs, formatting, generated code, throwaway code, configs without logic |
LOW |
Internal dev tooling, admin-only screens, logging formatters |
MEDIUM |
Standard CRUD, internal APIs with a single team consumer, non-critical UI, helpers and utilities |
MEDIUM-HIGH |
User-facing UI on critical paths, public APIs with multiple consumers, business workflows |
HIGH |
Money movement, auth/authz decisions, security-critical validation, data integrity, regulated domains |
Test Type Reference
| Type |
Use when |
Do NOT use when |
Frameworks |
Typical dependencies |
Google Size |
| unit |
Pure logic, single function/method/class, deterministic inputs |
Code is just I/O orchestration with no logic |
vitest, jest, pytest, go test, JUnit, xUnit, RSpec |
None (or in-memory fakes) |
Small |
| integration |
Boundary crossing (DB, HTTP, queue, FS); multiple collaborators where mocking distorts behavior |
Pure function with no boundary |
vitest, jest, pytest, go test, JUnit + Testcontainers, supertest, TestRestTemplate |
Real Postgres/Redis/Kafka via Testcontainers, in-process HTTP server, real FS in tmpdir |
Medium (single machine, localhost OK) |
| component |
UI rendering + interaction within a single component, no full app context |
Backend-only logic; multi-page user flow |
React Testing Library, Vue Test Utils, Angular TestBed, Storybook interaction tests |
jsdom or happy-dom, mocked network at fetch/axios level |
Small to Medium |
| e2e |
Full user path through running app: real browser, real backend, real DB |
Internal helper, single component, non-critical UI |
Playwright, Cypress, Selenium |
Real running app + Testcontainers-backed DB or seeded staging |
Large (multi-process, possibly multi-machine) |
| smoke |
Post-deploy go/no-go: hit / health, key endpoints respond, login works |
Detailed correctness; smoke is shallow by design |
Playwright (1-3 critical paths), HTTP probe scripts, k6 minimal scenarios |
Real deployed environment |
Large |
| contract |
Public API consumed by 2+ distinct clients with independent deploy cadence |
Single-consumer internal API; provider and consumer deploy together |
Pact, Spring Cloud Contract, OpenAPI schema validators |
Pact broker or contract files in repo |
Medium |
| property-based |
Large/unbounded input domain with stable invariants (parser, serializer, encoder, math) |
Small finite input space; unstable invariants |
Hypothesis (Python), fast-check (TS), QuickCheck (Haskell), jqwik (Java), proptest (Rust) |
Same as unit |
Small |
Google Test Size Mapping
Google Test Sizes (Bland) and SWE at Google Ch.11 classify tests by resources (size), independent of scope (paths covered):
| Size |
Process model |
Network |
Filesystem |
Time budget |
Notes |
small |
Single process, single thread |
None |
None (in-memory only) |
< 100ms |
Fast, hermetic, parallelizable |
medium |
Single machine, multiple processes allowed |
localhost only |
tmpdir allowed |
< 1s |
Testcontainers fits here |
large |
Multi-machine |
External network allowed |
Persistent FS allowed |
< 15min |
Full e2e |
enormous |
Distributed |
Wide network |
Anywhere |
longer |
Cluster / chaos |
A test's type (unit/integration/e2e) and size (small/medium/large) are orthogonal: a small integration test (Testcontainers Postgres in same process via JDBC) is legitimate.
Playwright vs Cypress (UI e2e)
| Dimension |
Playwright |
Cypress |
| Browsers |
Chromium, Firefox, WebKit |
Chromium, Firefox, WebKit (limited) |
| Multi-tab / multi-origin |
Yes |
Limited |
| Parallelism |
Built-in shards |
Paid dashboard or external |
| Network interception |
Robust route-level |
cy.intercept |
| Default |
Choose Playwright for new projects unless team already standardized on Cypress |
Choose Cypress when team has heavy investment |
Case Design Techniques
Use ISTQB Foundation Level black-box techniques to derive what to test inside each chosen test type. References: ISTQB BVA white paper, ASTQB black-box techniques.
1. Equivalence Partitioning (EP)
Divide input domain into partitions where the system is expected to behave the same way; ONE test per partition is sufficient.
Worked example — discount(orderTotal: number) -> number:
| Partition |
Range |
Representative test input |
Expected |
| Below threshold |
0 <= total < 100 |
50 |
0% discount |
| Mid tier |
100 <= total < 500 |
250 |
5% discount |
| Top tier |
total >= 500 |
1000 |
10% discount |
| Invalid (negative) |
total < 0 |
-1 |
throw / error |
Four tests cover all partitions. EP alone misses boundaries — combine with BVA.
2. Boundary Value Analysis (BVA)
Bugs cluster at boundaries. For every boundary value B, test B-1, B, B+1 (or for floats, the smallest representable step).
Worked example — same discount function, boundary at 100:
| Test input |
Why |
Expected |
99 (= B-1) |
Last value of "below threshold" partition |
0% discount |
100 (= B) |
First value of "mid tier" partition |
5% discount |
101 (= B+1) |
Confirms not off-by-two |
5% discount |
Repeat for boundary at 500: test 499, 500, 501. Total: 6 boundary tests + 4 EP tests = 10 cases.
The B-1 / B / B+1 triplet has the same shape across boundaries (vary input, vary expected output, identical assertion); this is a natural fit for a table-driven test (see sub-section 5 below).
3. Decision Tables
When output depends on combinations of conditions. Each column is a rule.
Worked example — canCheckout(cartHasItems, paymentValid, addressOnFile):
| Condition / Rule |
R1 |
R2 |
R3 |
R4 |
| cartHasItems |
T |
T |
T |
F |
| paymentValid |
T |
T |
F |
* |
| addressOnFile |
T |
F |
* |
* |
| Result |
allow |
block:address |
block:payment |
block:cart |
Four tests, one per rule (* = don't care, dropped via merging).
4. State Transition
When behavior depends on history. Identify states, events, and forbidden transitions.
Worked example — Order state machine with states {draft, submitted, paid, shipped, cancelled}:
| From |
Event |
To |
Test |
| draft |
submit |
submitted |
happy path |
| submitted |
pay |
paid |
happy path |
| paid |
ship |
shipped |
happy path |
| draft |
cancel |
cancelled |
early cancel |
| paid |
cancel |
reject |
forbidden — refund flow required, NOT direct cancel |
| shipped |
submit |
reject |
forbidden |
Cover one test per legal transition + one per forbidden transition (negative path).
5. Table-Driven Tests
When EP, BVA, or decision-table analysis yields 3+ cases with the same shape (same setup, same assertion, only inputs and expected outputs differ — e.g., parsing valid/invalid date formats; computing tax across brackets; routing rules) collapse them into a single table-driven test. The cases become rows in a data table; the test body iterates the rows and runs one assertion per row. References: Dave Cheney, Prefer table-driven tests; Go wiki: TableDrivenTests.
Do NOT force a table when setup, framework calls, or the assertion shape varies substantially across cases. Forced uniformity hides real differences behind a single name and produces obscure failure messages — keep those as separate, individually named tests.
Worked example — six EP+BVA cases for discount(orderTotal) (boundary at 100) collapsed into one table-driven unit test (TS / vitest syntax; the same pattern applies to Go t.Run, JUnit @ParameterizedTest, pytest parametrize):
describe("discount", () => {
const cases: Array<{ name: string; input: number; expected: number }> = [
{ name: "EP: below threshold (typical)", input: 50, expected: 0 },
{ name: "BVA: B-1 at boundary 100", input: 99, expected: 0 },
{ name: "BVA: B at boundary 100", input: 100, expected: 0.05 },
{ name: "BVA: B+1 at boundary 100", input: 101, expected: 0.05 },
{ name: "EP: mid tier (typical)", input: 250, expected: 0.05 },
{ name: "EP: top tier (typical)", input: 1000, expected: 0.10 },
];
for (const c of cases) {
it(c.name, () => {
expect(discount(c.input)).toBe(c.expected);
});
}
});
The name column is mandatory: each row must produce an individually addressable test so failures point to the specific case, not "row 3 of 6". Rows that need a different assertion (e.g., the negative-input case throws) stay as separate tests outside the table.
Dependency Decision
For Gate 2 (Integration) and Gate 3 (Component/E2E), choose dependencies deliberately. The goal is maximum realism that still runs deterministically in CI.
| Dependency style |
Use when |
Avoid when |
Notes |
| Real infra via Testcontainers |
DB/Redis/Kafka/Browser, dev needs real driver behavior, hermetic CI required |
Cold-start budget < 1s, no Docker available |
Default for integration tests on Postgres / Redis / Kafka / Localstack |
| In-memory fake |
Owned interface, semantics are simple (key-value, list), test speed critical |
Fake diverges from real — silent bugs at integration boundary |
Acceptable for repository ports in hexagonal architectures, IF the port has its own contract test against real infra |
| Mock (test double) |
Single collaborator with pure interface; test focuses on protocol (was X called with Y) |
You're mocking >2 collaborators or mocking data structures (anti-pattern: incomplete mocks) |
Mocks are tools to isolate, not things to test |
| Stubbed HTTP |
Calling external SaaS where Testcontainers / Localstack option doesn't exist |
When Pact / CDC is needed (use contract tests instead) |
nock (Node), responses (Python), WireMock (JVM) |
| Real external service |
Smoke test in staging only |
Unit / integration / CI — always non-deterministic |
Reserve for smoke tests against staging |
Tradeoff summary: Testcontainers > in-memory fake > mock, but cost goes the same direction. Pick the cheapest level that doesn't lie about the boundary's behavior.
Strategic Skip Heuristics
Explicit "don't bother" rules. Skipping these is not laziness — it is risk-adjusted ROI per ISO/IEC/IEEE 29119 risk-based testing and Risk-Based Testing.
| Skip |
Rule |
| No e2e for internal helpers |
If artifact has no UI surface and no user-facing path, skip e2e. Unit + integration is sufficient. |
| No contract test for bound by deploy consumer API |
If only one client consumes the API and they deploy together, contract testing adds maintenance with no decoupling benefit. |
| No property-based on small finite domains |
If input space is enum {A, B, C}, EP + BVA already covers it; property-based adds infra without finding more bugs. |
| No integration test for pure functions |
Adding a Postgres container to test a formatCurrency helper is waste. Unit only. |
| No component test for static markup |
If the component has no state, no events, no conditional rendering, a snapshot is enough — or skip entirely. |
| No unit test for declarative wiring |
DI bindings, route registration, schema declarations: assert at integration level (does the route serve the right handler) instead. |
| No e2e for things integration covers reliably |
Per Google e2e principles: the smaller the test you can use to cover a behavior, the better. e2e is the exception, not the default. |
| No tests for spike/throwaway code |
Per Beck TDD: if the artifact will be deleted within hours, document the exception with the human partner. Then write tests on the kept version. |
| No "and" tests |
If a test name contains "and", split it into separate tests (one assertion per behavior). |
Test Matrix Schema
Every test strategy MUST be expressed as the YAML block below. Field ordering inside each list entry is load-bearing — judges and downstream tools parse the first key as the critical one (rationale / reason / why), and the second key as the categorical one (type / what).
Schema
test_strategy:
artifact: "<path or short identifier>"
rationale: "Why this test strategy is being applied to this artifact (specific, evidence-based)"
criticality: "NONE | LOW | MEDIUM | MEDIUM-HIGH | HIGH"
selected_types:
- rationale: "Why this type is being applied to this artifact (specific, evidence-based)"
type: "unit | integration | component | e2e | smoke | contract | property-based"
size: "small | medium | large | enormous"
framework: "vitest | jest | pytest | go test | JUnit | playwright | cypress | pact | hypothesis | ..."
dependencies:
- "List of dependencies: real Postgres via Testcontainers, in-memory fake, mocked HTTP via nock, etc."
gate: "Gate N (the gate that triggered this selection)"
rejected_types:
- reason: "Why this type does NOT apply to this artifact (cite Strategic Skip Heuristic or gate that did not trigger)"
type: "unit | integration | component | e2e | smoke | contract | property-based"
deliberately_skipped:
- why: "Cost / risk justification for skipping despite a partial signal"
what: "A specific category of test cases being skipped (e.g., 'browser compatibility on IE11', 'load testing beyond 100 RPS')"
Worked YAML Example
test_strategy:
artifact: "POST /users (user registration endpoint)"
rationale: "User registration is a critical user-facing path; can be used by web and mobile apps independently of each other."
criticality: "MEDIUM-HIGH"
selected_types:
- rationale: "Endpoint contains validation logic (email format, password rules, uniqueness) — Gate 1 ON for branch coverage"
type: "unit"
size: "small"
framework: "vitest"
dependencies: ["in-memory user repository fake"]
gate: "Gate 1"
- rationale: "Endpoint writes to Postgres and emits user.created event to Kafka — Gate 2 ON, real boundary behavior matters"
type: "integration"
size: "medium"
framework: "vitest + supertest + Testcontainers"
dependencies: ["Postgres 15 via Testcontainers", "Kafka via Testcontainers"]
gate: "Gate 2"
- rationale: "Consumed by mobile app and web app on independent deploy cadences — Gate 4 ON, prevents drift"
type: "contract"
size: "medium"
framework: "Pact"
dependencies: ["Pact broker"]
gate: "Gate 4"
rejected_types:
- reason: "No UI surface in this artifact — Gate 3 OFF"
type: "component"
- reason: "No UI surface — Gate 3 OFF; e2e covered by web/mobile apps separately"
type: "e2e"
- reason: "Input domain (email, password) is large but invariants are well-covered by EP+BVA at unit level — property-based ROI is low at MEDIUM-HIGH criticality, only triggers Gate 6 partially"
type: "property-based"
deliberately_skipped:
- why: "Project does not have post-deploy probe pipeline yet; smoke would be no-op"
what: "Smoke test for /users after deploy"
- why: "Non-functional load testing is out of scope for this task; tracked separately in performance backlog"
what: "Load test verifying p99 < 200ms at 1000 RPS"
Field ordering checklist (judges check this verbatim):
test_strategy: artifact BEFORE rationale BEFORE criticality.
selected_types[*]: rationale BEFORE type BEFORE size BEFORE framework BEFORE dependencies BEFORE gate.
rejected_types[*]: reason BEFORE type.
deliberately_skipped[*]: why BEFORE what.
Case Listing Schema
After the matrix, produce a flat markdown bullet list of test cases to be implemented. This is separate from the YAML matrix because:
- a. it lists what to test, not how
- b. it links back to acceptance criteria
Format
## Test Cases to Cover
### AC-N: [criterion title]
- [type] description
- [type] description
### AC-N: [criterion title]
- [type] description
- [type] description
Where:
type matches one of selected_types[*].type from the matrix
description follows AAA / Given-When-Then (Dan North BDD) shape — see Bill Wake AAA (2001)
AC-N references the acceptance criterion the case verifies (omit if non-AC-bound, e.g., infrastructure smoke)
Worked Example
## Test Cases to Cover
### AC-1: Discount returns the correct percentage based on the total
- [unit] discount returns 0% when total = 0 [EP partition: below threshold]
- [unit] discount returns 0% when total = 99 [BVA: B-1 at boundary 100]
- [unit] discount returns 5% when total = 100 [BVA: B at boundary 100]
- [unit] discount returns 5% when total = 101 [BVA: B+1 at boundary 100]
### AC-2: Discount fails when total is invalid
- [unit] discount throws when total = -1 [EP partition: invalid]
### AC-3: /orders saves the order to the database
- [integration] POST /orders persists order to Postgres and returns 201 with order id
### AC-4: /orders rejects duplicate idempotency key
- [integration] POST /orders rejects duplicate idempotency key with 409
### AC-5: /orders/:id returns order by id
- [contract] GET /orders/:id returns schema matching mobile-app pact
Sources & Further Reading
These 14 sources back every gate and rule above. When in doubt, consult the source linked at that gate.
- Test Pyramid — Mike Cohn (2009, Succeeding with Agile) + Ham Vocke, The Practical Test Pyramid, martinfowler.com.
- Testing Trophy — Kent C. Dodds (2018), The Testing Trophy and Testing Classifications and Write Tests.
- Google Test Sizes — Mike Bland (2011), Small / Medium / Large; Software Engineering at Google Ch.11; Test Sizes (Google Testing Blog).
- Google Testing on the Toilet — What Makes a Good End-to-End Test, Testing UI Logic - Follow the User, Origins (Mike Bland).
- ISTQB Foundation Level — Black-box techniques: Boundary Value Analysis white paper; ASTQB Black-Box Techniques.
- ISO/IEC/IEEE 29119 — Risk-based test process standard. Wikipedia overview.
- Kent Beck — Test Driven Development: By Example (Addison-Wesley, 2002). Publisher page. ISBN 978-0321146533.
- The Pragmatic Programmer (20th Anniversary Edition) — Hunt & Thomas (2019). pragprog.com.
- AAA pattern — Bill Wake (2001), 3A — Arrange, Act, Assert. Given-When-Then — Dan North, Introducing BDD.
- Property-based testing — Hypothesis: What is property-based testing?; QuickCheck (Haskell), fast-check (TS).
- Contract testing / Consumer-Driven Contracts — Pact docs; Pactflow CDC explainer.
- Testcontainers — testcontainers.com.
- Table-driven tests — Dave Cheney, Prefer table-driven tests; Go wiki: TableDrivenTests.
- Risk-based testing — Risk Management During Test Planning (softwaretestinghelp.com).
Worked Examples
Each example shows:
- a. the artifact and acceptance criteria
- b. gate-by-gate walkthrough
- c.
test_strategy YAML following the schema
- d.
Test Cases to Cover list
- e. commentary on rejected types
Example A — Pure Helper Function: formatCurrency(amount: number, code: string): string
Artifact
function formatCurrency(amount: number, code: string): string;
// e.g. formatCurrency(1234.5, "USD") -> "$1,234.50"
// formatCurrency(1234.5, "EUR") -> "€1.234,50"
Acceptance criteria:
- AC-1: USD output uses
$ prefix, comma thousands, period decimal, two decimal places.
- AC-2: EUR output uses
€ prefix, period thousands, comma decimal, two decimal places.
- AC-3: Throws
Error("Unknown currency code") for unsupported codes.
- AC-4:
amount = 0 formats as "$0.00" / "€0,00".
Criticality: LOW (helper used in display only, no money movement here).
Gate Walkthrough
| Gate |
Decision |
Reason |
| 0 Skip |
OFF |
Has logic |
| 1 Unit |
ON |
Pure logic with branches per currency code — Test Pyramid base |
| 2 Integration |
OFF |
No I/O, no boundary — Skip Heuristic: no integration for pure functions |
| 3 Component/E2E |
OFF |
No UI surface |
| 4 Contract |
OFF |
Not a public API |
| 5 Smoke |
OFF |
Not deployable |
| 6 Property-Based |
ON (partial) |
Numeric input is unbounded, but invariants exist (round-trip via parse, monotonicity in amount) — Hypothesis. Promote at MEDIUM-HIGH; here LOW criticality means we apply it sparingly (1-2 properties) |
test_strategy YAML
test_strategy:
artifact: "src/util/formatCurrency.ts"
rationale: "Pure helper function used in display only; no money movement here."
criticality: "LOW"
selected_types:
- rationale: "Pure logic with currency-specific branches and number formatting; EP+BVA on amount, decision table on currency code"
type: "unit"
size: "small"
framework: "vitest"
dependencies: []
gate: "Gate 1"
- rationale: "Amount domain is unbounded floats; invariant 'parseCurrency(formatCurrency(x, c)) ~= x' is stable; sparingly applied (1-2 properties) at LOW criticality"
type: "property-based"
size: "small"
framework: "fast-check"
dependencies: []
gate: "Gate 6"
rejected_types:
- reason: "No I/O, no boundary, no collaborators - Gate 2 OFF"
type: "integration"
- reason: "No UI surface - Gate 3 OFF"
type: "component"
- reason: "No UI surface - Gate 3 OFF"
type: "e2e"
- reason: "Internal helper, not consumed across deploys - Gate 4 OFF"
type: "contract"
- reason: "Library helper, no deploy pipeline target - Gate 5 OFF"
type: "smoke"
deliberately_skipped:
- why: "Locale list is finite (USD, EUR); exhaustive enumeration via decision table is sufficient and more maintainable than i18n property tests"
what: "Property-based fuzzing of currency code beyond known list"
Test Cases to Cover
### AC-1: USD output uses `$` prefix, comma thousands, period decimal, two decimal places.
- [unit] formatCurrency(1234.5, "USD") returns "$1,234.50" [EP: typical USD]
- [unit] formatCurrency(0.01, "USD") returns "$0.01" [BVA: B+1 smallest non-zero]
- [unit] formatCurrency(-0.01, "USD") returns "-$0.01" [BVA: B-1 negative side]
### AC-2: EUR output uses `€` prefix, period thousands, comma decimal, two decimal places.
- [unit] formatCurrency(1234.5, "EUR") returns "€1.234,50" [EP: typical EUR]
- [property-based] for any non-NaN finite x in [-1e9, 1e9] and code in {USD, EUR}: parseCurrency(formatCurrency(x, code)) is within 0.005 of x [round-trip invariant]
### AC-3: Throws `Error("Unknown currency code")` for unsupported codes.
- [unit] formatCurrency(1, "XYZ") throws Error("Unknown currency code") [Decision table: unknown code]
### AC-4: `amount = 0` formats as `"$0.00"` / `"€0,00"`.
- [unit] formatCurrency(0, "USD") returns "$0.00" [BVA: B at amount=0]
- [unit] formatCurrency(0, "EUR") returns "€0,00" [BVA: B at amount=0 for EUR]
Why types were rejected: Helper has no boundaries (no integration), no UI (no component/e2e), is internal and library-style (no contract/smoke), and at LOW criticality the cost of additional test types far exceeds the benefit.
Example B — HTTP POST Endpoint with DB and Multi-Consumer: POST /users
Artifact
A user-registration endpoint that:
- Validates request body (email format, password complexity, age >= 13).
- Checks email uniqueness against Postgres.
- Inserts user record (transactional).
- Emits
user.created event to Kafka.
- Returns
201 with {id, email, createdAt}.
- Returns
400 for invalid input, 409 for duplicate email.
Consumed by: mobile app (iOS/Android) and web app on independent deploy cadences.
Acceptance criteria:
- AC-1: Valid request returns
201 and persists user.
- AC-2: Invalid email format returns
400 with field-level error.
- AC-3: Password not meeting policy returns
400.
- AC-4: Duplicate email returns
409.
- AC-5: Successful registration emits exactly one
user.created event.
- AC-6: Response schema is stable for mobile + web consumers.
Criticality: MEDIUM-HIGH (auth surface, identity domain, multi-consumer public API).
Gate Walkthrough
| Gate |
Decision |
Reason |
| 0 Skip |
OFF |
Has substantial logic |
| 1 Unit |
ON |
Validators (email, password, age) are pure logic — Test Pyramid base |
| 2 Integration |
ON |
Boundary crossing: HTTP, Postgres, Kafka — Testing Trophy ROI sweet spot |
| 3 Component/E2E |
OFF (here) |
No UI in this artifact; UI lives in mobile + web repos and tests itself |
| 4 Contract |
ON |
Two distinct consumers (mobile + web) on independent deploy cadences — Pact CDC |
| 5 Smoke |
ON |
Deployable HTTP service; post-deploy probe of /users registration is meaningful — Google e2e |
| 6 Property-Based |
OFF |
Input domain (email, password, age) is constrained and well-covered by EP+BVA at unit; criticality is MEDIUM-HIGH but Gate 6 OFF on bounded inputs — Skip Heuristic |
test_strategy YAML
test_strategy:
artifact: "POST /users (user registration endpoint)"
rationale: "User registration is a critical user-facing path; can be used by web and mobile apps independently of each other."
criticality: "MEDIUM-HIGH"
selected_types:
- rationale: "Validators (email, password, age) are pure logic; EP+BVA on each field; one test per partition"
type: "unit"
size: "small"
framework: "vitest"
dependencies: ["in-memory user repository fake (for service-level unit if needed)"]
gate: "Gate 1"
- rationale: "Endpoint writes to Postgres and emits to Kafka; mocking these distorts transactional and ordering behavior - Testcontainers gives real boundary fidelity"
type: "integration"
size: "medium"
framework: "vitest + supertest + Testcontainers"
dependencies: ["Postgres 15 via Testcontainers", "Kafka via Testcontainers"]
gate: "Gate 2"
- rationale: "Public API consumed by mobile + web on independent deploy cadences; contract testing prevents schema drift breaking either consumer"
type: "contract"
size: "medium"
framework: "Pact (provider verification)"
dependencies: ["Pact broker", "consumer-published pacts from mobile and web"]
gate: "Gate 4"
- rationale: "Deployable HTTP service with a post-deploy pipeline; one minimal smoke verifies /users responds 201 in the deployed environment"
type: "smoke"
size: "large"
framework: "Playwright (1 critical path)"
dependencies: ["deployed environment URL", "test account seeding"]
gate: "Gate 5"
rejected_types:
- reason: "No UI surface in this artifact - Gate 3 OFF; mobile and web repos own their own component tests"
type: "component"
- reason: "No UI surface - Gate 3 OFF; consumer e2e lives in mobile/web repos"
type: "e2e"
- reason: "Input domain is bounded and EP+BVA at unit level covers it; property-based on this glue endpoint adds infra without finding more bugs - Gate 6 OFF"
type: "property-based"
deliberately_skipped:
- why: "Performance/load testing is out of scope here; tracked in dedicated performance backlog"
what: "Load test verifying p99 < 200ms at 1000 RPS"
- why: "Cross-region failover is owned by infrastructure team, not this endpoint"
what: "Multi-region availability test"
Test Cases to Cover
### AC-1: Valid request returns `201` and persists user.
- [unit] validateEmail accepts "alice@example.com" [EP: well-formed]
- [integration] POST /users with valid body returns 201 and persists row in Postgres
- [smoke] POST /users in deployed environment returns 201 for a synthetic test account
### AC-2: Invalid email format returns `400` with field-level error.
- [unit] validateEmail rejects "alice@" [EP: missing domain]
- [unit] validateEmail rejects "" [BVA: empty boundary]
- [integration] POST /users with invalid email returns 400 and does NOT persist
### AC-3: Password not meeting policy returns `400`.
- [unit] validatePassword rejects 7-char password [BVA: B-1 at min length 8]
- [unit] validatePassword accepts 8-char password meeting policy [BVA: B at min length]
- [unit] validatePassword accepts 9-char password [BVA: B+1]
- [unit] validateAge rejects 12 [BVA: B-1 at boundary 13]
- [unit] validateAge accepts 13 [BVA: B at boundary 13]
### AC-4: Duplicate email returns `409`.
- [integration] POST /users with duplicate email returns 409 and does NOT emit event
### AC-5: Successful registration emits exactly one `user.created` event.
- [integration] POST /users emits exactly one user.created event to Kafka on success
- [integration] POST /users transaction rolls back when Kafka publish fails [State Transition: failure path]
### AC-6: Response schema is stable for mobile + web consumers.
- [contract] Provider satisfies mobile pact: POST /users response shape matches mobile contract
- [contract] Provider satisfies web pact: POST /users response shape matches web contract
Why types were rejected: No UI surface (component/e2e belong to consumer apps), bounded input space (property-based ROI low), out-of-scope concerns (load, multi-region) deliberately skipped with rationale.
Example C — UI Form Component: <RegistrationForm /> (web)
Artifact
A React form component:
- Fields: email, password, confirmPassword, age.
- Client-side validation: email format, password >= 8 chars with mixed case + digit, passwords match, age >= 13.
- Submits to
POST /users.
- Shows inline field errors and submit-level errors (network, 409 duplicate).
- Disables submit button while pending; re-enables on response.
- WCAG 2.1 AA: labels bound to inputs, errors announced via
aria-live, focus moves to first error on validation failure.
Acceptance criteria:
- AC-1: User can submit a valid form and is navigated to
/welcome.
- AC-2: Invalid email shows inline
"Enter a valid email".
- AC-3: Mismatched passwords show inline
"Passwords must match".
- AC-4: Submit is disabled while request is in flight.
- AC-5: 409 response from server shows
"This email is already registered" at form level.
- AC-6: Form is keyboard navigable; focus moves to first error on validation failure.
- AC-7: All inputs have programmatic labels; errors are announced via
aria-live="polite".
Criticality: MEDIUM-HIGH (registration is a critical user-facing path; accessibility is regulated in many jurisdictions).
Gate Walkthrough
| Gate |
Decision |
Reason |
| 0 Skip |
OFF |
Behavior + accessibility logic |
| 1 Unit |
ON |
Validation helpers (validateEmail, passwordsMatch, parseAge) are pure logic |
| 2 Integration |
OFF (here) |
The component itself does not cross a real boundary; network is mocked at fetch level. Network integration is owned by POST /users (Example B) |
| 3 Component/E2E |
ON (component) + ON (e2e for the registration path) |
UI surface, criticality MEDIUM-HIGH, user-facing critical path — Test Pyramid top + Follow the User |
| 4 Contract |
OFF |
UI consumes API; provider-side contract tests live in Example B |
| 5 Smoke |
ON |
Web app is deployed; smoke for "registration page renders and submits" is meaningful |
| 6 Property-Based |
OFF |
Bounded form inputs; EP+BVA covers them |
test_strategy YAML
test_strategy:
artifact: "src/components/RegistrationForm.tsx"
rationale: "React form component used in web app; registration is a business-critical user-facing path."
criticality: "MEDIUM
…(truncated)
1---2name: design-testing-strategy3description: Use before writing any type of tests. Distills 14 industry sources into deterministic decision gates, schemas, and worked test examples.4---5
6# Design Testing Strategy
7
8A reference manual for designing a fit-for-purpose, fit-for-criticality testing strategy.
9
10This skill is **decision-oriented**, not philosophical: every gate is deterministic (ON when X / OFF when Y), every schema is enforced (field ordering matters), every example is worked end-to-end.
11
12## How To Use This Skill
13
141. Read **Decision Gates** in order (Gate 0 -> Gate 6). Each gate is independent — you may finish with any subset of test types ON.
152. Apply **Strategic Skip Heuristics** to remove ON gates that would yield low ROI for this artifact.
163. For each ON gate, fill the **Test Matrix Schema** (`selected_types` entry) — the field order is load-bearing.
174. List rejected types in `rejected_types` and deliberate skips in `deliberately_skipped`.
185. Produce a **Test Cases to Cover** markdown bullet list using ISTQB techniques from **Case Design Techniques**.
196. Cross-check against the matching **Worked Example** (A pure function / B HTTP+DB endpoint / C UI component).
20
21---
22
23## Decision Gates
24
25Apply gates in numeric order. Each gate produces an independent boolean (`applies: true|false`). Gates do NOT veto each other — a single artifact may have unit + integration + contract + property-based all ON.
26
27| # | Type | ON when | OFF when | Source |
28|---|------|---------|----------|--------|
29| 0 | **Skip All** | Criticality is `NONE` (docs-only, comments, formatting, generated code, config without logic, throwaway prototypes) | Anything with branching, computed output, side effects, or user-visible behavior | [Pragmatic Programmer](https://pragprog.com/titles/tpp20/the-pragmatic-programmer-20th-anniversary-edition/) — "Test ruthlessly and effectively" implies effective skipping when ROI is zero |
30| 1 | **Unit** | Code contains any logic: branches, loops, conditionals, computation, transformation, parsing, validation, formatting | Pure declarative wiring (DI registration, route table) with no behavior | [Test Pyramid (Vocke)](https://martinfowler.com/articles/practical-test-pyramid.html) base layer + [Beck TDD](https://www.oreilly.com/library/view/test-driven-development/0321146530/) Red-Green-Refactor unit |
31| 2 | **Integration** | Boundary crossing: HTTP call, DB query, external SDK, message queue, filesystem I/O, OR collaboration with >=2 distinct collaborators where unit doubles distort behavior | Pure function with no I/O and 0-1 stable collaborators | [Testing Trophy (Dodds)](https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications) — integration is the highest-ROI layer; [Google "Follow the User"](https://testing.googleblog.com/2020/10/testing-on-toilet-testing-ui-logic.html) |
32| 3 | **Component or E2E** | UI surface AND criticality >= MEDIUM-HIGH AND user-facing critical path (signup, checkout, auth, payment, primary CTA) | Internal admin-only screens, dev tooling, or non-critical UI | [Test Pyramid top](https://martinfowler.com/articles/practical-test-pyramid.html) + [ISO/IEC/IEEE 29119](https://en.wikipedia.org/wiki/ISO/IEC_29119) risk ranking + [Google e2e principles](https://testing.googleblog.com/2016/09/testing-on-toilet-what-makes-good-end.html) |
33| 4 | **Contract** | Public API consumed by >=1 distinct clients (mobile + web, multiple internal services, external partners) AND independent deploy cadence | API where consumer and provider deploy together | [Pact / CDC](https://docs.pact.io/) + [Pactflow CDC explainer](https://pactflow.io/what-is-consumer-driven-contract-testing/) |
34| 5 | **Smoke** | Deployable surface (web app, API, service) AND a deploy/CI pipeline exists where post-deploy validation is meaningful | Library, internal helper, or no deploy pipeline | [Google "What Makes a Good End-to-End Test"](https://testing.googleblog.com/2016/09/testing-on-toilet-what-makes-good-end.html) — smoke = minimal e2e for deploy gate |
35| 6 | **Property-Based** | Input domain is large or unbounded (numeric ranges, strings, lists, parsers, serializers, encoders, math) AND invariants are stable (round-trip, idempotency, monotonicity, commutativity) AND criticality >= MEDIUM-HIGH | Small finite input domain, unstable invariants, or LOW criticality | [Hypothesis / QuickCheck](https://hypothesis.works/articles/what-is-property-based-testing/) |
36
37### Gate Application Algorithm
38
39```
40for gate in [Gate 0, Gate 1, ..., Gate 6]:
41 if gate.ON_condition_met(artifact):
42 result[gate.type] = applies: true
43 else:
44 result[gate.type] = applies: false
45
46if Gate 0 is true:
47 short-circuit: emit empty selected_types, document criticality=NONE, stop
48```
49
50**Criticality Scale** (used by Gates 3 and 6):
51
52| Level | Definition |
53|-------|------------|
54| `NONE` | Docs, formatting, generated code, throwaway code, configs without logic |
55| `LOW` | Internal dev tooling, admin-only screens, logging formatters |
56| `MEDIUM` | Standard CRUD, internal APIs with a single team consumer, non-critical UI, helpers and utilities |
57| `MEDIUM-HIGH` | User-facing UI on critical paths, public APIs with multiple consumers, business workflows |
58| `HIGH` | Money movement, auth/authz decisions, security-critical validation, data integrity, regulated domains |
59
60---
61
62## Test Type Reference
63
64| Type | Use when | Do NOT use when | Frameworks | Typical dependencies | Google Size |
65|------|----------|-----------------|------------|----------------------|-------------|
66| **unit** | Pure logic, single function/method/class, deterministic inputs | Code is just I/O orchestration with no logic | vitest, jest, pytest, go test, JUnit, xUnit, RSpec | None (or in-memory fakes) | [Small](https://testing.googleblog.com/2010/12/test-sizes.html) |
67| **integration** | Boundary crossing (DB, HTTP, queue, FS); multiple collaborators where mocking distorts behavior | Pure function with no boundary | vitest, jest, pytest, go test, JUnit + [Testcontainers](https://testcontainers.com/), supertest, TestRestTemplate | Real Postgres/Redis/Kafka via Testcontainers, in-process HTTP server, real FS in tmpdir | [Medium](https://testing.googleblog.com/2010/12/test-sizes.html) (single machine, localhost OK) |
68| **component** | UI rendering + interaction within a single component, no full app context | Backend-only logic; multi-page user flow | React Testing Library, Vue Test Utils, Angular TestBed, Storybook interaction tests | jsdom or happy-dom, mocked network at fetch/axios level | Small to Medium |
69| **e2e** | Full user path through running app: real browser, real backend, real DB | Internal helper, single component, non-critical UI | [Playwright](https://playwright.dev/), [Cypress](https://www.cypress.io/), Selenium | Real running app + Testcontainers-backed DB or seeded staging | [Large](https://abseil.io/resources/swe-book/html/ch11.html) (multi-process, possibly multi-machine) |
70| **smoke** | Post-deploy go/no-go: hit / health, key endpoints respond, login works | Detailed correctness; smoke is shallow by design | Playwright (1-3 critical paths), HTTP probe scripts, k6 minimal scenarios | Real deployed environment | Large |
71| **contract** | Public API consumed by 2+ distinct clients with independent deploy cadence | Single-consumer internal API; provider and consumer deploy together | [Pact](https://docs.pact.io/), Spring Cloud Contract, OpenAPI schema validators | Pact broker or contract files in repo | Medium |
72| **property-based** | Large/unbounded input domain with stable invariants (parser, serializer, encoder, math) | Small finite input space; unstable invariants | [Hypothesis](https://hypothesis.works/) (Python), fast-check (TS), QuickCheck (Haskell), jqwik (Java), proptest (Rust) | Same as unit | Small |
73
74### Google Test Size Mapping
75
76[Google Test Sizes (Bland)](https://mike-bland.com/2011/11/01/small-medium-large.html) and [SWE at Google Ch.11](https://abseil.io/resources/swe-book/html/ch11.html) classify tests by **resources** (size), independent of **scope** (paths covered):
77
78| Size | Process model | Network | Filesystem | Time budget | Notes |
79|------|---------------|---------|------------|-------------|-------|
80| `small` | Single process, single thread | None | None (in-memory only) | < 100ms | Fast, hermetic, parallelizable |
81| `medium` | Single machine, multiple processes allowed | localhost only | tmpdir allowed | < 1s | Testcontainers fits here |
82| `large` | Multi-machine | External network allowed | Persistent FS allowed | < 15min | Full e2e |
83| `enormous` | Distributed | Wide network | Anywhere | longer | Cluster / chaos |
84
85A test's **type** (unit/integration/e2e) and **size** (small/medium/large) are orthogonal: a small integration test (Testcontainers Postgres in same process via JDBC) is legitimate.
86
87### Playwright vs Cypress (UI e2e)
88
89| Dimension | [Playwright](https://playwright.dev/) | [Cypress](https://www.cypress.io/) |
90|-----------|---------------------------------------|-----------------------------------|
91| Browsers | Chromium, Firefox, WebKit | Chromium, Firefox, WebKit (limited) |
92| Multi-tab / multi-origin | Yes | Limited |
93| Parallelism | Built-in shards | Paid dashboard or external |
94| Network interception | Robust route-level | cy.intercept |
95| Default | Choose Playwright for new projects unless team already standardized on Cypress | Choose Cypress when team has heavy investment |
96
97---
98
99## Case Design Techniques
100
101Use ISTQB Foundation Level black-box techniques to derive **what** to test inside each chosen test type. References: [ISTQB BVA white paper](https://istqb.org/wp-content/uploads/2025/10/Boundary-Value-Analysis-white-paper.pdf), [ASTQB black-box techniques](https://astqb.org/4-2-black-box-test-techniques/).
102
103### 1. Equivalence Partitioning (EP)
104
105Divide input domain into partitions where the system is expected to behave the same way; ONE test per partition is sufficient.
106
107**Worked example** — `discount(orderTotal: number) -> number`:
108
109| Partition | Range | Representative test input | Expected |
110|-----------|-------|---------------------------|----------|
111| Below threshold | `0 <= total < 100` | `50` | `0% discount` |
112| Mid tier | `100 <= total < 500` | `250` | `5% discount` |
113| Top tier | `total >= 500` | `1000` | `10% discount` |
114| Invalid (negative) | `total < 0` | `-1` | `throw / error` |
115
116Four tests cover all partitions. EP alone misses boundaries — combine with BVA.
117
118### 2. Boundary Value Analysis (BVA)
119
120Bugs cluster at boundaries. For every boundary value `B`, test **`B-1`, `B`, `B+1`** (or for floats, the smallest representable step).
121
122**Worked example** — same `discount` function, boundary at `100`:
123
124| Test input | Why | Expected |
125|------------|-----|----------|
126| `99` (= B-1) | Last value of "below threshold" partition | `0% discount` |
127| `100` (= B) | First value of "mid tier" partition | `5% discount` |
128| `101` (= B+1) | Confirms not off-by-two | `5% discount` |
129
130Repeat for boundary at `500`: test `499`, `500`, `501`. Total: 6 boundary tests + 4 EP tests = 10 cases.
131
132The `B-1 / B / B+1` triplet has the same shape across boundaries (vary input, vary expected output, identical assertion); this is a natural fit for a **table-driven test** (see sub-section 5 below).
133
134### 3. Decision Tables
135
136When output depends on combinations of conditions. Each column is a rule.
137
138**Worked example** — `canCheckout(cartHasItems, paymentValid, addressOnFile)`:
139
140| Condition / Rule | R1 | R2 | R3 | R4 |
141|------------------|----|----|----|----|
142| cartHasItems | T | T | T | F |
143| paymentValid | T | T | F | * |
144| addressOnFile | T | F | * | * |
145| **Result** | allow | block:address | block:payment | block:cart |
146
147Four tests, one per rule (`*` = don't care, dropped via merging).
148
149### 4. State Transition
150
151When behavior depends on history. Identify states, events, and forbidden transitions.
152
153**Worked example** — Order state machine with states `{draft, submitted, paid, shipped, cancelled}`:
154
155| From | Event | To | Test |
156|------|-------|----|----|
157| draft | submit | submitted | happy path |
158| submitted | pay | paid | happy path |
159| paid | ship | shipped | happy path |
160| draft | cancel | cancelled | early cancel |
161| paid | cancel | reject | forbidden — refund flow required, NOT direct cancel |
162| shipped | submit | reject | forbidden |
163
164Cover one test per legal transition + one per forbidden transition (negative path).
165
166### 5. Table-Driven Tests
167
168When EP, BVA, or decision-table analysis yields **3+ cases with the same shape** (same setup, same assertion, only inputs and expected outputs differ — e.g., parsing valid/invalid date formats; computing tax across brackets; routing rules) collapse them into a single **table-driven test**. The cases become rows in a data table; the test body iterates the rows and runs one assertion per row. References: Dave Cheney, [Prefer table-driven tests](https://dave.cheney.net/2019/05/07/prefer-table-driven-tests); [Go wiki: TableDrivenTests](https://go.dev/wiki/TableDrivenTests).
169
170Do **NOT** force a table when setup, framework calls, or the assertion shape varies substantially across cases. Forced uniformity hides real differences behind a single name and produces obscure failure messages — keep those as separate, individually named tests.
171
172**Worked example** — six EP+BVA cases for `discount(orderTotal)` (boundary at `100`) collapsed into one table-driven unit test (TS / vitest syntax; the same pattern applies to Go `t.Run`, JUnit `@ParameterizedTest`, pytest `parametrize`):
173
174```ts
175describe("discount", () => {
176 const cases: Array<{ name: string; input: number; expected: number }> = [
177 { name: "EP: below threshold (typical)", input: 50, expected: 0 },
178 { name: "BVA: B-1 at boundary 100", input: 99, expected: 0 },
179 { name: "BVA: B at boundary 100", input: 100, expected: 0.05 },
180 { name: "BVA: B+1 at boundary 100", input: 101, expected: 0.05 },
181 { name: "EP: mid tier (typical)", input: 250, expected: 0.05 },
182 { name: "EP: top tier (typical)", input: 1000, expected: 0.10 },
183 ];
184
185 for (const c of cases) {
186 it(c.name, () => {
187 expect(discount(c.input)).toBe(c.expected);
188 });
189 }
190});
191```
192
193The `name` column is mandatory: each row must produce an individually addressable test so failures point to the specific case, not "row 3 of 6". Rows that need a different assertion (e.g., the negative-input case throws) stay as separate tests outside the table.
194
195---
196
197## Dependency Decision
198
199For Gate 2 (Integration) and Gate 3 (Component/E2E), choose dependencies deliberately. The goal is **maximum realism that still runs deterministically in CI**.
200
201| Dependency style | Use when | Avoid when | Notes |
202|------------------|----------|------------|-------|
203| **Real infra via [Testcontainers](https://testcontainers.com/)** | DB/Redis/Kafka/Browser, dev needs real driver behavior, hermetic CI required | Cold-start budget < 1s, no Docker available | Default for integration tests on Postgres / Redis / Kafka / Localstack |
204| **In-memory fake** | Owned interface, semantics are simple (key-value, list), test speed critical | Fake diverges from real — silent bugs at integration boundary | Acceptable for repository ports in hexagonal architectures, IF the port has its own contract test against real infra |
205| **Mock (test double)** | Single collaborator with pure interface; test focuses on protocol (was X called with Y) | You're mocking >2 collaborators or mocking data structures (anti-pattern: incomplete mocks) | Mocks are tools to isolate, not things to test |
206| **Stubbed HTTP** | Calling external SaaS where Testcontainers / Localstack option doesn't exist | When Pact / CDC is needed (use contract tests instead) | nock (Node), responses (Python), WireMock (JVM) |
207| **Real external service** | Smoke test in staging only | Unit / integration / CI — always non-deterministic | Reserve for smoke tests against staging |
208
209**Tradeoff summary**: Testcontainers > in-memory fake > mock, but cost goes the same direction. Pick the cheapest level that doesn't lie about the boundary's behavior.
210
211---
212
213## Strategic Skip Heuristics
214
215Explicit "don't bother" rules. Skipping these is not laziness — it is risk-adjusted ROI per [ISO/IEC/IEEE 29119 risk-based testing](https://en.wikipedia.org/wiki/ISO/IEC_29119) and [Risk-Based Testing](https://www.softwaretestinghelp.com/risk-management-during-test-planning-risk-based-testing/).
216
217| Skip | Rule |
218|------|------|
219| **No e2e for internal helpers** | If artifact has no UI surface and no user-facing path, skip e2e. Unit + integration is sufficient. |
220| **No contract test for bound by deploy consumer API** | If only one client consumes the API and they deploy together, contract testing adds maintenance with no decoupling benefit. |
221| **No property-based on small finite domains** | If input space is `enum {A, B, C}`, EP + BVA already covers it; property-based adds infra without finding more bugs. |
222| **No integration test for pure functions** | Adding a Postgres container to test a `formatCurrency` helper is waste. Unit only. |
223| **No component test for static markup** | If the component has no state, no events, no conditional rendering, a snapshot is enough — or skip entirely. |
224| **No unit test for declarative wiring** | DI bindings, route registration, schema declarations: assert at integration level (does the route serve the right handler) instead. |
225| **No e2e for things integration covers reliably** | Per [Google e2e principles](https://testing.googleblog.com/2016/09/testing-on-toilet-what-makes-good-end.html): the smaller the test you can use to cover a behavior, the better. e2e is the exception, not the default. |
226| **No tests for spike/throwaway code** | Per [Beck TDD](https://www.oreilly.com/library/view/test-driven-development/0321146530/): if the artifact will be deleted within hours, document the exception with the human partner. Then write tests on the kept version. |
227| **No "and" tests** | If a test name contains "and", split it into separate tests (one assertion per behavior). |
228
229---
230
231## Test Matrix Schema
232
233Every test strategy MUST be expressed as the YAML block below. **Field ordering inside each list entry is load-bearing** — judges and downstream tools parse the first key as the critical one (rationale / reason / why), and the second key as the categorical one (type / what).
234
235### Schema
236
237```yaml
238test_strategy:
239 artifact: "<path or short identifier>"
240 rationale: "Why this test strategy is being applied to this artifact (specific, evidence-based)"
241 criticality: "NONE | LOW | MEDIUM | MEDIUM-HIGH | HIGH"
242
243 selected_types:
244 - rationale: "Why this type is being applied to this artifact (specific, evidence-based)"
245 type: "unit | integration | component | e2e | smoke | contract | property-based"
246 size: "small | medium | large | enormous"
247 framework: "vitest | jest | pytest | go test | JUnit | playwright | cypress | pact | hypothesis | ..."
248 dependencies:
249 - "List of dependencies: real Postgres via Testcontainers, in-memory fake, mocked HTTP via nock, etc."
250 gate: "Gate N (the gate that triggered this selection)"
251
252 rejected_types:
253 - reason: "Why this type does NOT apply to this artifact (cite Strategic Skip Heuristic or gate that did not trigger)"
254 type: "unit | integration | component | e2e | smoke | contract | property-based"
255
256 deliberately_skipped:
257 - why: "Cost / risk justification for skipping despite a partial signal"
258 what: "A specific category of test cases being skipped (e.g., 'browser compatibility on IE11', 'load testing beyond 100 RPS')"
259```
260
261### Worked YAML Example
262
263```yaml
264test_strategy:
265 artifact: "POST /users (user registration endpoint)"
266 rationale: "User registration is a critical user-facing path; can be used by web and mobile apps independently of each other."
267 criticality: "MEDIUM-HIGH"
268
269 selected_types:
270 - rationale: "Endpoint contains validation logic (email format, password rules, uniqueness) — Gate 1 ON for branch coverage"
271 type: "unit"
272 size: "small"
273 framework: "vitest"
274 dependencies: ["in-memory user repository fake"]
275 gate: "Gate 1"
276 - rationale: "Endpoint writes to Postgres and emits user.created event to Kafka — Gate 2 ON, real boundary behavior matters"
277 type: "integration"
278 size: "medium"
279 framework: "vitest + supertest + Testcontainers"
280 dependencies: ["Postgres 15 via Testcontainers", "Kafka via Testcontainers"]
281 gate: "Gate 2"
282 - rationale: "Consumed by mobile app and web app on independent deploy cadences — Gate 4 ON, prevents drift"
283 type: "contract"
284 size: "medium"
285 framework: "Pact"
286 dependencies: ["Pact broker"]
287 gate: "Gate 4"
288
289 rejected_types:
290 - reason: "No UI surface in this artifact — Gate 3 OFF"
291 type: "component"
292 - reason: "No UI surface — Gate 3 OFF; e2e covered by web/mobile apps separately"
293 type: "e2e"
294 - reason: "Input domain (email, password) is large but invariants are well-covered by EP+BVA at unit level — property-based ROI is low at MEDIUM-HIGH criticality, only triggers Gate 6 partially"
295 type: "property-based"
296
297 deliberately_skipped:
298 - why: "Project does not have post-deploy probe pipeline yet; smoke would be no-op"
299 what: "Smoke test for /users after deploy"
300 - why: "Non-functional load testing is out of scope for this task; tracked separately in performance backlog"
301 what: "Load test verifying p99 < 200ms at 1000 RPS"
302```
303
304**Field ordering checklist** (judges check this verbatim):
305
306- `test_strategy`: `artifact` BEFORE `rationale` BEFORE `criticality`.
307- `selected_types[*]`: `rationale` BEFORE `type` BEFORE `size` BEFORE `framework` BEFORE `dependencies` BEFORE `gate`.
308- `rejected_types[*]`: `reason` BEFORE `type`.
309- `deliberately_skipped[*]`: `why` BEFORE `what`.
310
311---
312
313## Case Listing Schema
314
315After the matrix, produce a flat markdown bullet list of test cases to be implemented. This is separate from the YAML matrix because:
316- a. it lists *what* to test, not *how*
317- b. it links back to acceptance criteria
318
319### Format
320
321```markdown
322## Test Cases to Cover
323
324### AC-N: [criterion title]
325- [type] description
326- [type] description
327
328### AC-N: [criterion title]
329- [type] description
330- [type] description
331```
332
333Where:
334
335- `type` matches one of `selected_types[*].type` from the matrix
336- `description` follows AAA / [Given-When-Then (Dan North BDD)](https://dannorth.net/introducing-bdd/) shape — see [Bill Wake AAA (2001)](https://xp123.com/articles/3a-arrange-act-assert/)
337- `AC-N` references the acceptance criterion the case verifies (omit if non-AC-bound, e.g., infrastructure smoke)
338
339### Worked Example
340
341```markdown
342## Test Cases to Cover
343
344### AC-1: Discount returns the correct percentage based on the total
345- [unit] discount returns 0% when total = 0 [EP partition: below threshold]
346- [unit] discount returns 0% when total = 99 [BVA: B-1 at boundary 100]
347- [unit] discount returns 5% when total = 100 [BVA: B at boundary 100]
348- [unit] discount returns 5% when total = 101 [BVA: B+1 at boundary 100]
349
350### AC-2: Discount fails when total is invalid
351- [unit] discount throws when total = -1 [EP partition: invalid]
352
353### AC-3: /orders saves the order to the database
354- [integration] POST /orders persists order to Postgres and returns 201 with order id
355
356### AC-4: /orders rejects duplicate idempotency key
357- [integration] POST /orders rejects duplicate idempotency key with 409
358
359### AC-5: /orders/:id returns order by id
360- [contract] GET /orders/:id returns schema matching mobile-app pact
361```
362
363---
364
365## Sources & Further Reading
366
367These 14 sources back every gate and rule above. When in doubt, consult the source linked at that gate.
368
3691. **Test Pyramid** — Mike Cohn (2009, *Succeeding with Agile*) + Ham Vocke, [The Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html), martinfowler.com.
3702. **Testing Trophy** — Kent C. Dodds (2018), [The Testing Trophy and Testing Classifications](https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications) and [Write Tests](https://kentcdodds.com/blog/write-tests).
3713. **Google Test Sizes** — Mike Bland (2011), [Small / Medium / Large](https://mike-bland.com/2011/11/01/small-medium-large.html); [Software Engineering at Google Ch.11](https://abseil.io/resources/swe-book/html/ch11.html); [Test Sizes (Google Testing Blog)](https://testing.googleblog.com/2010/12/test-sizes.html).
3724. **Google Testing on the Toilet** — [What Makes a Good End-to-End Test](https://testing.googleblog.com/2016/09/testing-on-toilet-what-makes-good-end.html), [Testing UI Logic - Follow the User](https://testing.googleblog.com/2020/10/testing-on-toilet-testing-ui-logic.html), [Origins (Mike Bland)](https://mike-bland.com/2011/10/25/testing-on-the-toilet.html).
3735. **ISTQB Foundation Level** — Black-box techniques: [Boundary Value Analysis white paper](https://istqb.org/wp-content/uploads/2025/10/Boundary-Value-Analysis-white-paper.pdf); [ASTQB Black-Box Techniques](https://astqb.org/4-2-black-box-test-techniques/).
3746. **ISO/IEC/IEEE 29119** — Risk-based test process standard. [Wikipedia overview](https://en.wikipedia.org/wiki/ISO/IEC_29119).
3757. **Kent Beck — *Test Driven Development: By Example*** (Addison-Wesley, 2002). [Publisher page](https://www.oreilly.com/library/view/test-driven-development/0321146530/). ISBN 978-0321146533.
3768. **The Pragmatic Programmer (20th Anniversary Edition)** — Hunt & Thomas (2019). [pragprog.com](https://pragprog.com/titles/tpp20/the-pragmatic-programmer-20th-anniversary-edition/).
3779. **AAA pattern** — Bill Wake (2001), [3A — Arrange, Act, Assert](https://xp123.com/articles/3a-arrange-act-assert/). **Given-When-Then** — Dan North, [Introducing BDD](https://dannorth.net/introducing-bdd/).
37810. **Property-based testing** — [Hypothesis: What is property-based testing?](https://hypothesis.works/articles/what-is-property-based-testing/); QuickCheck (Haskell), fast-check (TS).
37911. **Contract testing / Consumer-Driven Contracts** — [Pact docs](https://docs.pact.io/); [Pactflow CDC explainer](https://pactflow.io/what-is-consumer-driven-contract-testing/).
38012. **Testcontainers** — [testcontainers.com](https://testcontainers.com/).
38113. **Table-driven tests** — Dave Cheney, [Prefer table-driven tests](https://dave.cheney.net/2019/05/07/prefer-table-driven-tests); [Go wiki: TableDrivenTests](https://go.dev/wiki/TableDrivenTests).
38214. **Risk-based testing** — [Risk Management During Test Planning (softwaretestinghelp.com)](https://www.softwaretestinghelp.com/risk-management-during-test-planning-risk-based-testing/).
383
384---
385
386## Worked Examples
387
388Each example shows:
389- a. the artifact and acceptance criteria
390- b. gate-by-gate walkthrough
391- c. `test_strategy` YAML following the schema
392- d. `Test Cases to Cover` list
393- e. commentary on rejected types
394
395---
396
397### Example A — Pure Helper Function: `formatCurrency(amount: number, code: string): string`
398
399**Artifact**
400
401```ts
402function formatCurrency(amount: number, code: string): string;
403// e.g. formatCurrency(1234.5, "USD") -> "$1,234.50"
404// formatCurrency(1234.5, "EUR") -> "€1.234,50"
405```
406
407**Acceptance criteria**:
408
409- AC-1: USD output uses `$` prefix, comma thousands, period decimal, two decimal places.
410- AC-2: EUR output uses `€` prefix, period thousands, comma decimal, two decimal places.
411- AC-3: Throws `Error("Unknown currency code")` for unsupported codes.
412- AC-4: `amount = 0` formats as `"$0.00"` / `"€0,00"`.
413
414**Criticality**: `LOW` (helper used in display only, no money movement here).
415
416**Gate Walkthrough**
417
418| Gate | Decision | Reason |
419|------|----------|--------|
420| 0 Skip | OFF | Has logic |
421| 1 Unit | **ON** | Pure logic with branches per currency code — [Test Pyramid base](https://martinfowler.com/articles/practical-test-pyramid.html) |
422| 2 Integration | OFF | No I/O, no boundary — [Skip Heuristic: no integration for pure functions](https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications) |
423| 3 Component/E2E | OFF | No UI surface |
424| 4 Contract | OFF | Not a public API |
425| 5 Smoke | OFF | Not deployable |
426| 6 Property-Based | **ON** (partial) | Numeric input is unbounded, but invariants exist (round-trip via parse, monotonicity in amount) — [Hypothesis](https://hypothesis.works/articles/what-is-property-based-testing/). Promote at MEDIUM-HIGH; here LOW criticality means we apply it sparingly (1-2 properties) |
427
428**`test_strategy` YAML**
429
430```yaml
431test_strategy:
432 artifact: "src/util/formatCurrency.ts"
433 rationale: "Pure helper function used in display only; no money movement here."
434 criticality: "LOW"
435
436 selected_types:
437 - rationale: "Pure logic with currency-specific branches and number formatting; EP+BVA on amount, decision table on currency code"
438 type: "unit"
439 size: "small"
440 framework: "vitest"
441 dependencies: []
442 gate: "Gate 1"
443 - rationale: "Amount domain is unbounded floats; invariant 'parseCurrency(formatCurrency(x, c)) ~= x' is stable; sparingly applied (1-2 properties) at LOW criticality"
444 type: "property-based"
445 size: "small"
446 framework: "fast-check"
447 dependencies: []
448 gate: "Gate 6"
449
450 rejected_types:
451 - reason: "No I/O, no boundary, no collaborators - Gate 2 OFF"
452 type: "integration"
453 - reason: "No UI surface - Gate 3 OFF"
454 type: "component"
455 - reason: "No UI surface - Gate 3 OFF"
456 type: "e2e"
457 - reason: "Internal helper, not consumed across deploys - Gate 4 OFF"
458 type: "contract"
459 - reason: "Library helper, no deploy pipeline target - Gate 5 OFF"
460 type: "smoke"
461
462 deliberately_skipped:
463 - why: "Locale list is finite (USD, EUR); exhaustive enumeration via decision table is sufficient and more maintainable than i18n property tests"
464 what: "Property-based fuzzing of currency code beyond known list"
465```
466
467**Test Cases to Cover**
468
469```markdown
470### AC-1: USD output uses `$` prefix, comma thousands, period decimal, two decimal places.
471- [unit] formatCurrency(1234.5, "USD") returns "$1,234.50" [EP: typical USD]
472- [unit] formatCurrency(0.01, "USD") returns "$0.01" [BVA: B+1 smallest non-zero]
473- [unit] formatCurrency(-0.01, "USD") returns "-$0.01" [BVA: B-1 negative side]
474
475### AC-2: EUR output uses `€` prefix, period thousands, comma decimal, two decimal places.
476- [unit] formatCurrency(1234.5, "EUR") returns "€1.234,50" [EP: typical EUR]
477- [property-based] for any non-NaN finite x in [-1e9, 1e9] and code in {USD, EUR}: parseCurrency(formatCurrency(x, code)) is within 0.005 of x [round-trip invariant]
478
479### AC-3: Throws `Error("Unknown currency code")` for unsupported codes.
480- [unit] formatCurrency(1, "XYZ") throws Error("Unknown currency code") [Decision table: unknown code]
481
482### AC-4: `amount = 0` formats as `"$0.00"` / `"€0,00"`.
483- [unit] formatCurrency(0, "USD") returns "$0.00" [BVA: B at amount=0]
484- [unit] formatCurrency(0, "EUR") returns "€0,00" [BVA: B at amount=0 for EUR]
485
486```
487
488**Why types were rejected**: Helper has no boundaries (no integration), no UI (no component/e2e), is internal and library-style (no contract/smoke), and at LOW criticality the cost of additional test types far exceeds the benefit.
489
490---
491
492### Example B — HTTP POST Endpoint with DB and Multi-Consumer: `POST /users`
493
494**Artifact**
495
496A user-registration endpoint that:
497
4981. Validates request body (email format, password complexity, age >= 13).
4992. Checks email uniqueness against Postgres.
5003. Inserts user record (transactional).
5014. Emits `user.created` event to Kafka.
5025. Returns `201` with `{id, email, createdAt}`.
5036. Returns `400` for invalid input, `409` for duplicate email.
504
505**Consumed by**: mobile app (iOS/Android) and web app on independent deploy cadences.
506
507**Acceptance criteria**:
508
509- AC-1: Valid request returns `201` and persists user.
510- AC-2: Invalid email format returns `400` with field-level error.
511- AC-3: Password not meeting policy returns `400`.
512- AC-4: Duplicate email returns `409`.
513- AC-5: Successful registration emits exactly one `user.created` event.
514- AC-6: Response schema is stable for mobile + web consumers.
515
516**Criticality**: `MEDIUM-HIGH` (auth surface, identity domain, multi-consumer public API).
517
518**Gate Walkthrough**
519
520| Gate | Decision | Reason |
521|------|----------|--------|
522| 0 Skip | OFF | Has substantial logic |
523| 1 Unit | **ON** | Validators (email, password, age) are pure logic — [Test Pyramid base](https://martinfowler.com/articles/practical-test-pyramid.html) |
524| 2 Integration | **ON** | Boundary crossing: HTTP, Postgres, Kafka — [Testing Trophy](https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications) ROI sweet spot |
525| 3 Component/E2E | OFF (here) | No UI in this artifact; UI lives in mobile + web repos and tests itself |
526| 4 Contract | **ON** | Two distinct consumers (mobile + web) on independent deploy cadences — [Pact CDC](https://pactflow.io/what-is-consumer-driven-contract-testing/) |
527| 5 Smoke | **ON** | Deployable HTTP service; post-deploy probe of `/users` registration is meaningful — [Google e2e](https://testing.googleblog.com/2016/09/testing-on-toilet-what-makes-good-end.html) |
528| 6 Property-Based | OFF | Input domain (email, password, age) is constrained and well-covered by EP+BVA at unit; criticality is MEDIUM-HIGH but Gate 6 OFF on bounded inputs — [Skip Heuristic](https://hypothesis.works/articles/what-is-property-based-testing/) |
529
530**`test_strategy` YAML**
531
532```yaml
533test_strategy:
534 artifact: "POST /users (user registration endpoint)"
535 rationale: "User registration is a critical user-facing path; can be used by web and mobile apps independently of each other."
536 criticality: "MEDIUM-HIGH"
537
538 selected_types:
539 - rationale: "Validators (email, password, age) are pure logic; EP+BVA on each field; one test per partition"
540 type: "unit"
541 size: "small"
542 framework: "vitest"
543 dependencies: ["in-memory user repository fake (for service-level unit if needed)"]
544 gate: "Gate 1"
545 - rationale: "Endpoint writes to Postgres and emits to Kafka; mocking these distorts transactional and ordering behavior - Testcontainers gives real boundary fidelity"
546 type: "integration"
547 size: "medium"
548 framework: "vitest + supertest + Testcontainers"
549 dependencies: ["Postgres 15 via Testcontainers", "Kafka via Testcontainers"]
550 gate: "Gate 2"
551 - rationale: "Public API consumed by mobile + web on independent deploy cadences; contract testing prevents schema drift breaking either consumer"
552 type: "contract"
553 size: "medium"
554 framework: "Pact (provider verification)"
555 dependencies: ["Pact broker", "consumer-published pacts from mobile and web"]
556 gate: "Gate 4"
557 - rationale: "Deployable HTTP service with a post-deploy pipeline; one minimal smoke verifies /users responds 201 in the deployed environment"
558 type: "smoke"
559 size: "large"
560 framework: "Playwright (1 critical path)"
561 dependencies: ["deployed environment URL", "test account seeding"]
562 gate: "Gate 5"
563
564 rejected_types:
565 - reason: "No UI surface in this artifact - Gate 3 OFF; mobile and web repos own their own component tests"
566 type: "component"
567 - reason: "No UI surface - Gate 3 OFF; consumer e2e lives in mobile/web repos"
568 type: "e2e"
569 - reason: "Input domain is bounded and EP+BVA at unit level covers it; property-based on this glue endpoint adds infra without finding more bugs - Gate 6 OFF"
570 type: "property-based"
571
572 deliberately_skipped:
573 - why: "Performance/load testing is out of scope here; tracked in dedicated performance backlog"
574 what: "Load test verifying p99 < 200ms at 1000 RPS"
575 - why: "Cross-region failover is owned by infrastructure team, not this endpoint"
576 what: "Multi-region availability test"
577```
578
579**Test Cases to Cover**
580
581```markdown
582### AC-1: Valid request returns `201` and persists user.
583- [unit] validateEmail accepts "alice@example.com" [EP: well-formed]
584- [integration] POST /users with valid body returns 201 and persists row in Postgres
585- [smoke] POST /users in deployed environment returns 201 for a synthetic test account
586
587### AC-2: Invalid email format returns `400` with field-level error.
588- [unit] validateEmail rejects "alice@" [EP: missing domain]
589- [unit] validateEmail rejects "" [BVA: empty boundary]
590- [integration] POST /users with invalid email returns 400 and does NOT persist
591
592### AC-3: Password not meeting policy returns `400`.
593- [unit] validatePassword rejects 7-char password [BVA: B-1 at min length 8]
594- [unit] validatePassword accepts 8-char password meeting policy [BVA: B at min length]
595- [unit] validatePassword accepts 9-char password [BVA: B+1]
596- [unit] validateAge rejects 12 [BVA: B-1 at boundary 13]
597- [unit] validateAge accepts 13 [BVA: B at boundary 13]
598
599### AC-4: Duplicate email returns `409`.
600- [integration] POST /users with duplicate email returns 409 and does NOT emit event
601
602### AC-5: Successful registration emits exactly one `user.created` event.
603- [integration] POST /users emits exactly one user.created event to Kafka on success
604- [integration] POST /users transaction rolls back when Kafka publish fails [State Transition: failure path]
605
606### AC-6: Response schema is stable for mobile + web consumers.
607- [contract] Provider satisfies mobile pact: POST /users response shape matches mobile contract
608- [contract] Provider satisfies web pact: POST /users response shape matches web contract
609```
610
611**Why types were rejected**: No UI surface (component/e2e belong to consumer apps), bounded input space (property-based ROI low), out-of-scope concerns (load, multi-region) deliberately skipped with rationale.
612
613---
614
615### Example C — UI Form Component: `<RegistrationForm />` (web)
616
617**Artifact**
618
619A React form component:
620
6211. Fields: email, password, confirmPassword, age.
6222. Client-side validation: email format, password >= 8 chars with mixed case + digit, passwords match, age >= 13.
6233. Submits to `POST /users`.
6244. Shows inline field errors and submit-level errors (network, 409 duplicate).
6255. Disables submit button while pending; re-enables on response.
6266. WCAG 2.1 AA: labels bound to inputs, errors announced via `aria-live`, focus moves to first error on validation failure.
627
628**Acceptance criteria**:
629
630- AC-1: User can submit a valid form and is navigated to `/welcome`.
631- AC-2: Invalid email shows inline `"Enter a valid email"`.
632- AC-3: Mismatched passwords show inline `"Passwords must match"`.
633- AC-4: Submit is disabled while request is in flight.
634- AC-5: 409 response from server shows `"This email is already registered"` at form level.
635- AC-6: Form is keyboard navigable; focus moves to first error on validation failure.
636- AC-7: All inputs have programmatic labels; errors are announced via `aria-live="polite"`.
637
638**Criticality**: `MEDIUM-HIGH` (registration is a critical user-facing path; accessibility is regulated in many jurisdictions).
639
640**Gate Walkthrough**
641
642| Gate | Decision | Reason |
643|------|----------|--------|
644| 0 Skip | OFF | Behavior + accessibility logic |
645| 1 Unit | **ON** | Validation helpers (`validateEmail`, `passwordsMatch`, `parseAge`) are pure logic |
646| 2 Integration | OFF (here) | The component itself does not cross a real boundary; network is mocked at fetch level. Network integration is owned by `POST /users` (Example B) |
647| 3 Component/E2E | **ON** (component) + **ON** (e2e for the registration path) | UI surface, criticality MEDIUM-HIGH, user-facing critical path — [Test Pyramid top](https://martinfowler.com/articles/practical-test-pyramid.html) + [Follow the User](https://testing.googleblog.com/2020/10/testing-on-toilet-testing-ui-logic.html) |
648| 4 Contract | OFF | UI consumes API; provider-side contract tests live in Example B |
649| 5 Smoke | **ON** | Web app is deployed; smoke for "registration page renders and submits" is meaningful |
650| 6 Property-Based | OFF | Bounded form inputs; EP+BVA covers them |
651
652**`test_strategy` YAML**
653
654```yaml
655test_strategy:
656 artifact: "src/components/RegistrationForm.tsx"
657 rationale: "React form component used in web app; registration is a business-critical user-facing path."
658 criticality: "MEDIUM
659
660…(truncated)