Testing Strategy
What to test at each level, contract tests between services, deterministic fixtures, real databases over mocks, and a zero-tolerance policy for flaky tests.
Tests exist so you can change code with confidence. A test suite that's slow, flaky, or only checks that mocks were called gives you the cost of tests without the confidence.
The Shape
The classic test pyramid says most tests should be unit tests, because they're fast. For service code, that optimizes the wrong thing. Most production bugs in a service aren't in a pure function; they're in the wiring: a validation rule that never runs because the route isn't registered, a query that violates a constraint, an error mapped to the wrong status. A thousand unit tests over mocks pass straight through all of those.
So I weight toward integration tests, the shape Kent C. Dodds calls the testing trophy. They're slower than unit tests, but containers have made "a real database in a test" cost seconds rather than minutes, and the confidence per minute of CI is far higher:
| Level | What it covers | Share |
|---|---|---|
| Static | Types, linting, formatting | Everything, free |
| Unit | Pure logic: money math, state transitions, parsers | Many |
| Integration | One service end to end through its API, with a real database | The most |
| Contract | The agreement between a service and its consumers | Every boundary |
| End to end | A few critical journeys across the deployed system | A handful |
What to Test Where
- Unit test the logic that's easy to get wrong. Rounding and remainder allocation from Money & Amounts, every allowed and rejected transition in Payment States, signature verification in Webhooks.
- Integration test through the HTTP interface. Send a request, assert on the response and the resulting state. This tests routing, validation, error format, and the database together, which is where real bugs live.
- Test behavior, not implementation. A test that breaks when you rename a private function is a test of the wrong thing.
- Test the failure paths. Timeouts, duplicate requests, a replayed idempotency key, a webhook delivered twice. Payments bugs live in the unhappy paths.
Real Dependencies Over Mocks
A mock encodes your belief about how a dependency behaves. When the belief is wrong, the test is wrong in exactly the same way as the code, and both pass together.
- Test against real Postgres, in a container (Testcontainers, or CI service containers), with migrations applied. A mocked repository happily accepts a duplicate key that the real unique constraint rejects.
- Isolate per test with a transaction rolled back at the end, or a fresh schema per worker for parallel runs.
- Mock only what you don't own, at the network edge: processors, email providers, third-party APIs. Use recorded responses or the vendor's sandbox, not hand-written guesses.
Contract Tests
When one service calls another, each side's tests can pass while the integration breaks. Contract tests catch that.
- Consumer-driven: the consumer records what it relies on (endpoints, fields, status codes), and the provider's CI verifies every consumer's contract. Pact is the common tool.
- Schema-driven: for public APIs, the OpenAPI document is the contract. CI fails on breaking changes to it, per Versioning & Deprecation.
- Events too. Event schemas between producers and consumers are contracts. See Events & Async Messaging.
Deterministic Fixtures
Tests produce the same result every run, on every machine.
- Stable IDs. Derive fixture IDs with UUID v5 from readable names (
uuid5(ns, "merchant-with-expired-card")), as described in Identifier Conventions. Failures are reproducible and IDs are recognizable in output. - Controlled time. Inject a clock. Never assert against "now". Tests for authorization expiry or idempotency key retention set the clock explicitly.
- Seeded randomness. If a test uses random data, it logs the seed so a failure can be replayed.
- Builders, not shared fixtures. Each test builds the data it needs, with defaults for everything else, so one test's setup can't break another.
Flaky Tests
A flaky test is a failing test. The damage isn't the one false alarm; it's what the team learns from it. Once "just rerun it" is a habit, a red build stops meaning anything, and the next real failure gets rerun until it happens to pass. Flakes are also often real bugs: a race condition that shows up in CI one time in fifty shows up in production too.
- Quarantine within a day. Move it out of the required suite, and open a ticket with an owner. A day is long enough to look, and short enough that nobody learns to ignore red.
- Fix or delete within a week. A quarantined test protects nothing, so it can't live there indefinitely. If nobody fixes it, delete it and write down the gap it leaves.
- Never retry in CI to hide it. Automatic retries turn a flaky test into a slow, silent one, and hide the race condition behind it.
In CI
- The full suite runs on every pull request and blocks the merge. See Commits, PRs & Code Review.
- Keep it under about 10 minutes. Parallelize before you start skipping.
- Coverage is a signal for finding untested code, not a target to hit. Make it a target and people write tests that execute lines without asserting anything; 100% coverage of the wrong assertions proves nothing.
This Site
syntaqx.com runs its suite with Vitest on every pull request. The API's proxy tests go through the real request handling (headers, rate limits, QUERY routing) rather than calling functions in isolation, and the rate limiter's integration tests run against a real Redis: compose.yml locally, CI service containers in the pipeline. One of those tests checks the key inside Redis itself, because an earlier version passed while silently falling back to the in-memory store. The docs have tests too: lib/docs.test.ts fails the build on a broken link or a missing References section.
References
Specifications
- RFC 9562: UUIDs, name-based UUID v5 for stable fixtures
Reference
- Pact Docs, consumer-driven contract testing
- Testcontainers, real Postgres in tests
In practice
- Kent C. Dodds: The Testing Trophy, weighting toward integration tests
- Ham Vocke: The Practical Test Pyramid, what to test at each level
- Martin Fowler: Eradicating Non-Determinism in Tests, quarantining and fixing flaky tests