Testing Strategy
This is the concrete “how much and where” companion to the testing philosophy in 8.5. That subsection sets the why (pyramid vs trophy, when TDD is scoped in); this subsection sets the scope, tooling, and numbers.
13.1 Scope by layer
Matching the test pyramid from 8.5:
- Unit tests (Vitest, see 2.2): the majority of test coverage. Domain logic, pure functions, and isolated components, run with no real database, no real network calls, and no real external providers.
- Integration tests: fewer than unit tests, covering a handler or a domain service exercised through its actual layers (see 6.1), with external dependencies mocked at the boundary (13.2 below) rather than every internal collaborator mocked individually.
- End-to-end tests (Playwright, see 2.2): the fewest, covering critical user-facing flows end to end against a running application. Reserved for paths where a real browser and a real (or realistic) backend matter, not used as a substitute for unit or integration coverage.
Rule: if a behavior can be verified with a unit test, it’s covered by a unit test, not pushed up to an integration or end-to-end test “to be safe.” Testing the same behavior redundantly at a higher, slower layer doesn’t add confidence proportional to its cost.
13.2 Mocking convention
External dependencies are mocked at the network boundary using MSW (Mock Service Worker), not by mocking the SDK client or the wrapper module directly.
- CI never hits a real third-party API. Every external call in a test environment is intercepted by MSW and given a controlled response, including for the typed provider wrappers in 7.4.
- Mocking at the network boundary rather than mocking the wrapper module means tests still exercise the wrapper’s own request-building, validation, and error-handling logic, only the actual network call is faked. Mocking the wrapper itself would skip testing that logic entirely.
- The database is not mocked in integration tests; use a real (test) database instance so tests catch real query and schema issues, not just logic issues against a fake in-memory stand-in.
Rule: a test that reaches out to a real third-party API, even “just once, to check,” does not run in CI. If a dependency isn’t yet wrapped with an MSW-mockable boundary, that’s a gap in the typed wrapper from 7.4, fix that, don’t work around it in the test.
13.3 Smoke tests
A required, fixed set of smoke tests runs against critical paths after every deploy, distinct from the pre-merge test suite:
- Smoke tests run against the deployed environment itself (not a local or CI-only environment), verifying that the critical paths, sign-in, the primary user flow the product depends on, and any path a prior incident has occurred on, actually work in the environment users are about to use.
- Smoke tests are intentionally small in number and fast to run. Their job is to catch a deploy that broke something despite passing CI, not to re-run the full test suite against production.
Rule: every critical path has exactly one smoke test covering it, named clearly enough that a failure tells whoever’s on call what broke without needing to read the test body first.
13.4 Coverage expectation
Stated expectation: 80% line coverage on new and changed code per PR, measured by the CI coverage tool, not on the codebase as a whole. This is a stated number, not an aspiration to grow into eventually, it is enforced at PR time.
- Coverage is measured on the diff, not on total repository coverage, so a PR touching a small, well-tested area isn’t penalized for pre-existing gaps elsewhere, and a PR adding meaningful new code can’t hide behind a high total percentage.
- Coverage is a floor, not a target to write tests toward. Meeting 80% with tests that don’t assert meaningful behavior (calling a function without checking its result) satisfies the number without satisfying the point of the number; that’s a code review problem, not a tooling gap.
Rule: a PR that falls under the coverage threshold on its own changed lines does not merge without an explicit, reviewed justification (e.g. the uncovered lines are unreachable defensive code, or coverage tooling can’t instrument a specific pattern).
13.5 Deferred: visual regression and mutation testing
Both are explicitly out of scope for now, considered and deliberately deferred rather than omitted without discussion:
- Visual regression testing (screenshot-diffing UI changes) is a phase-2 addition, once the component library (Storybook/Chromatic, see 2.2) has enough coverage of shared components for visual regression to catch real issues rather than flagging noise from unrelated layout shifts.
- Mutation testing (verifying tests actually fail when the code they cover is deliberately broken) is a phase-2 addition, once the coverage expectation in 13.4 is consistently met and the team is ready to invest in verifying test quality, not just test presence.
Rule: don’t introduce either ad hoc on a single feature as a one-off experiment. When the team is ready to adopt one of these, it comes in as a standard applied consistently, following the same evaluation process as any new tool (see 2.5), not as a per-project decision.
13.6 Contract testing between services
This is about verifying that one service’s expectations of another’s API contract don’t silently drift apart, distinct from the unit/integration/e2e layers in 13.1, which test a service’s own correctness, not its agreement with a different service’s contract.
- An internal RPC-style API (tRPC, see 2.2) already gets this for free from shared types: because client and server share the same type system (per 7.2), a contract drift between them is a compile-time type error, not a runtime surprise a dedicated contract test would need to catch separately. A dedicated contract-testing tool is not needed for service-to-service calls that go through tRPC.
- A REST boundary, or a call to an external/partner-facing API, doesn’t get that guarantee. REST endpoints intended for external/partner consumption (see 7.2) have no shared type system with their consumers, this is where an actual contract test earns its cost: verifying the response an endpoint produces still matches what its documented schema (see 6.3) promises, run as part of the same test suite that already exercises that endpoint (13.1’s integration layer), rather than adopting a separate, dedicated consumer-driven contract testing tool ahead of an actual need for one.
- A webhook payload’s shape is validated the same way: against its own Zod schema (see 9.3), as part of the test for the handler that produces or consumes it, not left unverified because “it’s just JSON.”
Rule: a REST or webhook contract change that isn’t caught by an existing test until a consumer reports a break in production is a testing gap, the same class of gap this document treats a missing test for any other behavior change as. Adopt a dedicated contract-testing tool (per the evaluation process in 2.5) once the number of REST/external contracts and consumers makes schema-level testing alone insufficient to catch drift, not before.
13.7 Test data and fixtures
Every engineer needs a consistent, working starting dataset to develop and test against, without hand-crafting it locally every time or, worse, pointing local development at production data.
- A seed script populates a local database with realistic, synthetic data, covering the range of states a feature needs to exercise (an empty state, a typical record, an edge case like the maximum-length field or a record in an unusual status), run once to set up a fresh local environment and re-runnable to reset it.
- Seed data is synthetic, never a copy of production data. This is the same discipline already required for a pentest environment in 11.16: real user data, especially anything classified as PII (11.13), never ends up in a local or test environment, synthetic data that exercises the same shapes and edge cases serves the same purpose without the exposure risk.
- A test’s fixtures are defined near the test that uses them, scoped to what that specific test needs, rather than every test reaching into one large, shared global fixture set where a change for one test’s needs risks silently affecting another, unrelated test. A fixture genuinely shared across many tests (a standard “logged-in user” fixture, a standard tenant) is fine to centralize, the same promotion criteria as 3.4 applies: promote it once more than one real consumer needs the exact same shape, don’t default to one shared fixture file for everything.
- Integration tests reset their data between runs, per the real test database already required in 13.2, a test that depends on state left behind by a previous test run is flaky by construction, not just occasionally unlucky.
Rule: a new feature requiring a non-trivial local setup to exercise (specific records, a particular account state) updates the seed script to cover that setup, rather than leaving it as tribal knowledge the next engineer has to reconstruct by asking around or reading the feature’s code to figure out what data it expects to exist.
13.8 Security test scope
Security-relevant behavior is tested as its own explicit category, not assumed to be incidentally covered by ordinary feature tests that happen to exercise the same endpoints.
- Access control tests are mandatory, not optional coverage. The IDOR test already required in 11.14, proving a different authenticated user cannot access a resource by ID alone, is the concrete example of this category: a test proving what a user cannot do, not just tests proving what they can.
- Authentication and authorization boundary tests cover the negative case explicitly: an unauthenticated request to a protected endpoint is rejected, an authenticated-but-unauthorized request is rejected, a request with an expired or revoked token (11.6) is rejected. These are as much a part of a feature’s required coverage (4.2) as its happy-path test, a feature whose tests only prove the authorized case works is not fully tested.
- Multi-tenant isolation tests, where multi-tenancy applies (see 7.6), are their own required suite, per that subsection’s own requirement, not folded into ordinary feature tests where a tenant-scoping gap could slip through unnoticed.
- Input validation boundary tests cover a deliberately malformed or malicious input (an oversized payload, an unexpected type, an injection attempt) reaching the server boundary and being rejected per 11.3, not just the well-formed input a happy-path test naturally exercises.
Rule: a PR touching an authenticated or authorized endpoint is not considered fully tested by a happy-path test alone; per 4.2, it needs the negative-case coverage above as part of what “tests are updated” means for that kind of change, the same way a UI change isn’t considered done without its error state.