Software testing strategies are usually described in terms of coverage, and coverage is the least informative number in the whole discipline. A codebase at ninety per cent can ship a bug on its most-used path, because coverage measures which lines executed during a test run, not whether anything meaningful was asserted about them.

The teams that trust their suite are not the ones with the highest percentage. They are the ones whose tests fail when something is genuinely broken and stay quiet otherwise, which turns out to be a much harder property to buy.

The question worth asking about any test: if this fails, will I know what to do? A test that fails when behaviour changes tells you something. A test that fails because an implementation detail moved tells you only that somebody refactored, and after enough of those the team stops reading failures and starts re-running the pipeline until it goes green.


Why Coverage Misleads

Coverage answers “did this line run”, which is not the same as “is this line correct”. A test that calls a function and asserts nothing produces identical coverage to one that checks every branch of the result.

That gap has practical consequences. Targets set at a fixed percentage reliably produce tests written to hit the number: exhaustive tests on trivial getters, nothing on the payment path where the branches are hard to set up. The number goes up, the risk does not move.

Coverage is useful in one direction only. Low coverage in a critical area is a real signal worth acting on. High coverage overall is not evidence of anything, and treating it as a target rather than a diagnostic is how teams end up with thousands of tests and no confidence.

Software Testing Strategies: Which Tests Earn Their Cost

Every test is a liability as well as an asset. It has to be maintained, it slows the suite, and it will occasionally be wrong. The useful question is which ones repay that.

Unit tests repay when the logic is genuinely complex and independent of infrastructure: pricing rules, date handling, permission checks, parsers. Fast, precise, and they survive refactoring because the behaviour they describe is real.

Integration tests repay more than most teams expect, because the majority of production bugs live at boundaries rather than inside functions. The query that works against a mock and fails against the real database. The API whose optional field is absent in practice. These are slower and worth it.

End-to-end tests repay only for a handful of journeys, and the number should be small enough to name out loud. Sign up, buy, the one thing your business does. They are slow, brittle and expensive, and a suite of two hundred of them is a team’s main source of misery.

The shape most codebases converge on is many unit tests, a solid layer of integration tests, and a handful of end-to-end journeys, which is the testing pyramid Martin Fowler describes. Where teams go wrong is usually the middle: they have unit tests and end-to-end tests and almost nothing checking that the pieces fit together.

Flaky Tests Are a Trust Problem

A test that fails one run in twenty is worse than no test, and the reason is behavioural rather than technical.

Once a suite has a few flakes, the team learns that red does not necessarily mean broken. Re-running becomes routine. Then a real failure gets re-run too, and once it passes on the third attempt, somebody merges it. The suite has stopped functioning as a signal while continuing to consume time.

Treat flakiness as a defect with the same priority as a production bug. Quarantine the test immediately so the pipeline goes green honestly, then fix or delete it. The usual causes are shared state between tests, real timing dependencies, and reliance on ordering that the runner does not guarantee.

Deleting a flaky test is a legitimate outcome. A test nobody trusts is providing no protection, and removing it at least stops it consuming attention.

Test Behaviour, Not Implementation

The most common cause of expensive test suites is tests coupled to how the code works rather than what it does.

Mocking every dependency and asserting that a specific method was called with specific arguments produces a test that fails on any refactor, whether or not behaviour changed. That is precisely backwards: refactoring is when you most want the suite to tell you nothing broke, and instead it produces fifty failures you have to work through by hand.

The alternative is to assert on outcomes. Given this input, the system produces this output or reaches this state. Such tests survive rewrites of the internals, which means they keep protecting you during exactly the changes that carry the most risk.

Mocks earn their place at genuine boundaries: a payment provider, an email service, anything slow or with side effects you cannot have in a test run. Inside your own code they usually cost more than they return.

Making It Run in CI

A suite nobody waits for is a suite that gets skipped. If the full run takes forty minutes, people push and move on, and the feedback arrives after they have started something else.

Split it. Fast unit and integration tests on every push, giving an answer in a couple of minutes. The slow end-to-end journeys on merge or on a schedule. This is the same reasoning as the deployment discipline in our CI/CD pipeline best practices guide.

Make failures readable. A failure that says an assertion was false, with no indication of what was being checked, costs ten minutes of archaeology every time. Naming tests after the behaviour they protect turns the failure list into a description of what broke.

And keep the suite deterministic. No real network calls, no dependence on today’s date without controlling it, no ordering assumptions. Every non-deterministic test is a future flake.

Where to Start on an Untested Codebase

Do not attempt full coverage retroactively; the effort is enormous and most of it protects code nobody changes.

Start with the paths where a bug costs money, and write integration tests around them first, because those catch the most per test written. Then add a test with every bug fix, reproducing the failure before you repair it. That way coverage grows exactly where defects actually occur, which is the best available signal about where the risk lives.

Mecanik reviews and builds test strategy as part of our software development work, usually starting with the question of which failures would actually hurt. If your suite is large and your team still deploys nervously, the problem is rarely the number of tests.


Related reading: Technical Documentation That Gets Read , Developer Onboarding That Ships in Week One , Postmortems That Actually Change Something and API Versioning: When to Break and How Not To .


Frequently Asked Questions

Is high test coverage a good goal? Not on its own. Coverage measures which lines executed during a test run, not whether anything meaningful was asserted about them, so a test that calls a function and asserts nothing scores identically to one checking every branch. Low coverage on a critical path is a useful signal; a high overall percentage is not evidence of much.

What is the right mix of unit, integration and end-to-end tests? Many unit tests for genuinely complex logic, a substantial layer of integration tests because most production bugs live at boundaries, and a small number of end-to-end journeys you can name out loud. Most teams get the middle layer wrong, having unit and end-to-end tests but little checking that the pieces fit together.

How should I handle flaky tests? Treat them as defects with production-bug priority. Quarantine the test immediately so the pipeline is honest, then fix or delete it. Once a suite has a few flakes the team learns that red does not mean broken, starts re-running by reflex, and eventually merges a genuine failure. Deleting a flaky test is a legitimate outcome.

Should I mock dependencies in tests? At genuine boundaries, yes: payment providers, email services, anything slow or with side effects. Inside your own code, mocks usually cost more than they return, because asserting that a specific method was called with specific arguments makes the test fail on any refactor whether or not behaviour changed.

How do I add tests to a codebase that has none? Do not attempt full coverage retroactively. Write integration tests around the paths where a bug costs money, since those catch the most per test written. Then add a test with every bug fix, reproducing the failure before repairing it, so coverage grows exactly where defects actually occur.