Shape the suite like a pyramid
Keep most tests fast, isolated unit tests, with proportionally fewer integration tests and only a handful of end-to-end tests for critical journeys. This pushes each defect to the cheapest layer that can catch it, keeping feedback fast and the suite reliable; the inverted "ice-cream cone" of mostly end-to-end tests produces slow, flaky suites teams stop trusting.
- Do: test a pricing rule as a unit test, the order-repository SQL as an integration test, and only checkout end-to-end.
- Don't: verify a validation-message edge case by driving the whole UI in a Selenium test.
Assert behaviour, not implementation
Write tests against the observable contract of the system under test - its outputs and side effects - rather than its internal calls and structure. Behaviour-focused tests survive refactoring and document intent, whereas tests that assert "method X was called" break on every internal change and can pass even when the code is wrong.
- Do: assert that
transfermoves the balance and rejects an overdraft. - Don't: assert that
transfercalledrepository.saveexactly twice in a specific order.
Make every test independent and deterministic
Each test should run alone, in any order, and in parallel with the same result, with no shared mutable state or ordering assumptions. Determinism and isolation are what make a suite trustworthy and parallelisable; their absence is the leading cause of flaky, order-dependent failures.
- Do: create or reset the fixture per test and inject a fixed clock for time-dependent logic.
- Don't: let one test leave rows in a shared database that a later test silently relies on.
Control time, randomness, and other non-determinism
Never let the code under test read the wall clock, generate random values, or depend on real network timing directly. Inject these as dependencies (a Clock, a seeded random source, a fake HTTP client) so tests can pin them to known values and assert exact behaviour at boundaries.
- Do: inject
Clock.fixed(...)and assert token expiry at the exact second. - Don't: call
Instant.now()inside the method and add asleepto "wait for" the boundary.
Use real infrastructure for integration tests
When verifying persistence, messaging, or serialization, run against the real technology in a disposable container (for example via Testcontainers) rather than an in-memory substitute that behaves differently. Mocked or H2-style substitutes hide dialect, mapping, and transaction defects that only the real engine reveals.
- Do: start a real PostgreSQL container and run the repository test against it.
- Don't: test Postgres-specific SQL against an in-memory H2 database and assume it transfers.
Test the unhappy paths, not just the happy path
For every happy-path test, add negative and boundary cases: invalid input, missing data, timeouts, and error responses. Most production defects and security issues live in error handling and edge conditions, so a suite that only exercises the default scenario gives false confidence.
- Do: test that the API returns 400 on malformed input and 409 on a conflicting write.
- Don't: test only the well-formed request and assume the error branches work.
Turn every bug fix into a regression test
Before fixing a defect, write a test that reproduces it and fails; the fix is done when that test passes. This guarantees the specific bug cannot silently return and steadily grows a regression suite focused on real failures rather than imagined ones.
- Do: add a failing test capturing the exact input that triggered the production incident, then fix.
- Don't: patch the code and move on, leaving nothing to stop the same regression next month.
Avoid over-mocking
Mock only the boundaries you cannot easily run for real - external services, slow or non-deterministic dependencies - and prefer real objects or fakes elsewhere. Excessive mocking couples tests to internal call structure, makes refactoring painful, and produces tests that pass even when the integrated behaviour is broken.
- Do: use a real domain object and an in-memory fake repository in a service test.
- Don't: mock every collaborator so the test merely re-describes the implementation.
Keep tests readable with a clear structure
Structure each test as Arrange-Act-Assert (or Given-When-Then) with one logical behaviour per test and a descriptive name that states the scenario and expected outcome. Readable tests act as living documentation and make failures self-explanatory; a test that needs comments to explain what it checks is usually doing too much.
- Do: name a test
rejects_withdrawal_when_balance_insufficientwith three clear phases. - Don't: cram five unrelated scenarios into one
test1with a tangle of asserts.
Measure test quality beyond line coverage
Treat coverage as a tool to find untested code, not as a target, and supplement it with mutation testing on critical or complex modules to confirm the tests actually catch faults. High coverage with weak assertions gives a false sense of safety; a surviving mutant reveals behaviour no test truly verifies.
- Do: run mutation testing on the billing module and add tests for surviving mutants.
- Don't: celebrate 95% line coverage on code whose tests assert nothing meaningful.
Run the right tests at the right stage in CI
Gate every commit on fast unit (and lightweight integration) tests so feedback stays quick, and schedule slow, broad suites - full end-to-end, soak, and performance - on a less frequent cadence. Keeping the fast gate fast preserves developer flow, while the slower suites still catch what only they can.
- Do: run unit + integration on each push and the full E2E and load suite nightly.
- Don't: put a 40-minute end-to-end suite on the path of every single commit.
Evaluate AI and LLM features with a fixed golden set
Build a representative evaluation dataset with reference answers or properties, score outputs with automated metrics (faithfulness, relevancy, context recall) plus targeted human review, and gate releases on those scores in CI. Keep the golden set fixed and vary the system, so you measure the change rather than editing the target - and never use the same model as both generator and judge without cross-checks, which inflates scores.
- Do: run the eval set on every prompt/model change and block merges below a faithfulness floor.
- Don't: tweak the reference answers until the current model "passes".
Test in production safely
Complement pre-release testing with controlled production validation - canary releases, feature flags, synthetic monitoring, and A/B experiments - to catch what only real traffic and data expose. Production is the ultimate environment, so validating there with a limited blast radius and fast rollback closes the gap that staging environments cannot.
- Do: roll a new version to 5% of traffic behind a flag and watch error rate and p99 latency.
- Don't: deploy to 100% at once with no canary, no flag, and no rehearsed rollback.