SyncTrix logoSyncTrix
All articles
Platform10 min read

Testing strategy: fewer, better tests that catch bugs users would have hit

Coverage percentage measures execution, not verification. Here's how to structure a suite that catches real defects without becoming a maintenance burden.

By Priya Iyer
Testing strategy: fewer, better tests that catch bugs users would have hit

Most test suites are simultaneously too large and insufficient - thousands of tests, slow to run, painful to maintain, and yet bugs reach production regularly. The problem is rarely quantity. It is that the tests verify implementation details rather than behaviour, and they are concentrated where bugs are not.

01Coverage measures the wrong thing

Line coverage tells you a line executed during the run. It says nothing about whether anything was verified. A test calling every function and asserting nothing achieves complete coverage and catches no defects, and this is more common than teams admit once a coverage target becomes a gate.

Coverage is useful in one direction only: uncovered code is definitely untested, so it is a good tool for finding gaps. As a target it produces tests written to satisfy the metric, which are precisely the tests that verify nothing and cost maintenance forever.

02Test behaviour, not structure

Tests coupled to implementation break on every refactor while catching no bugs, which is the worst possible combination - they impose the full maintenance cost of a test suite while providing none of the safety. If renaming a private method or reorganising internals breaks fifty tests, those tests were verifying structure.

Write tests against the public interface, asserting on observable outcomes. A test that says 'given this input to this module, this is the result and this is the side effect' survives any refactor that preserves behaviour, which is what you actually want to be free to do.

  • Assert on outputs and observable effects, not internal calls.
  • Mock at architectural boundaries, not between your own modules.
  • If a refactor breaks many tests, the tests were too coupled.
  • Name tests for the behaviour, not the method under test.

03The shape that works

The classic pyramid - overwhelmingly unit tests, fewer integration, minimal end-to-end - is a reasonable starting heuristic that many teams apply too literally. Heavily-mocked unit tests can pass while the system is broken, because the mocks encode assumptions that no longer match reality.

The more useful framing is to test at the level where the risk lives. Complex business logic deserves fast unit tests. Anything crossing a boundary - database, queue, external service - needs integration tests against real infrastructure, because that is exactly where mocks lie. End-to-end tests should be few and reserved for critical user journeys, since they are slow, flaky and expensive to maintain.

LevelVerifiesKeep becauseLimit because
UnitLogic in isolationFast, precise failuresMocks can diverge from reality
IntegrationReal boundary behaviourCatches what mocks hideSlower; needs real dependencies
ContractService agreements holdPrevents cross-team breakageNeeds both sides to participate
End-to-endCritical journeys workHighest confidenceSlow, flaky, expensive
Test levels and their role

04Integration tests deserve more investment

The most under-invested level is integration, because it is the least convenient. Running real Postgres, a real queue and real service dependencies in tests takes setup effort that mocking avoids. But nearly all interesting production bugs live at these boundaries - transaction semantics, constraint violations, serialisation differences, timeout behaviour.

Containerised dependencies have made this substantially easier than it was. Spinning up a real database per test run is now routine, and the tests that result catch a category of defect that no amount of unit testing will. If you invest in one level, make it this one.

05A flaky test is worse than no test

A test failing randomly one run in twenty trains the team to re-run rather than investigate. Once that habit forms, genuine failures get re-run too, and broken code ships with a green build. The suite's value depends entirely on failures being trustworthy.

Quarantine flaky tests out of the blocking path the moment they are identified, assign an owner and a deadline, and delete them if nobody fixes them. Deleting a test feels like losing coverage; keeping an unreliable one loses trust in everything, which is far more expensive.

06Let production failures direct the suite

The most reliable signal about where tests are missing is what actually broke. Every production bug is evidence of a gap, and the discipline of writing a failing test that reproduces it before fixing it does two things: it proves the fix works, and it permanently closes that gap.

Over time this steers the suite toward the parts of the system that genuinely break, which is a far better allocation than uniform coverage. Review recent incidents periodically and ask what test would have caught each one - the answers are usually more specific and more useful than any coverage report.

Topics

testing strategytest pyramidintegration testingcode coverageflaky testscontract testing

Priya Iyer

Staff Engineer · SyncTrix

Writes about the engineering decisions behind production systems - architecture, delivery and the trade-offs that only show up at scale.

Building something like this?

SyncTrix engineers AI, SaaS, platform and cloud systems for enterprises and high-growth teams. Tell us what you're shipping and we'll scope it with you.