Coverage does not equal confidence
- Goodhart's Law in action: Making code coverage a corporate target (e.g., demanding a mandatory 90% threshold) incentivizes low-quality tests and redundant assertions that fail to validate real-world behavior.
- The coupling trap: Overusing test doubles (mocks) to isolate code units hides critical integration bugs at system boundaries and binds your tests to internal implementation details.
- The physics of testing: Real systems break at the boundaries (network, persistence, concurrency, serialization). Designing a pragmatical strategy that balances domain unit tests with integration and contract tests is the only path to deployment safety.
In software engineering meetings, code coverage is often discussed as the gold standard of project health. Entire teams celebrate reaching 90% or 100% coverage, under the premise that a high percentage acts as an impenetrable shield against production bugs.
However, in the real world of complex systems, this metric frequently generates a false sense of security. As computer science pioneer Edsger Dijkstra famously stated: “Program testing can be used to show the presence of bugs, but never to show their absence”. Obsessing over what percentage of code lines our test suite executes, while ignoring what those tests are actually validating and how they affect system design, is one of the most significant strategic technical mistakes a senior team can make.
1. Goodhart’s Law and metric corruption
When a metric becomes a business or management target, it ceases to be a good metric. This principle, known as Goodhart’s Law, manifests clearly in software development when technology departments or team leads impose minimum coverage thresholds (such as the classic 80% or 90% target to pass continuous integration checks).
The moment developers are evaluated by coverage percentages, the system’s incentives shift:
- Tests without real assertions: Tests are written to execute entire code branches simply to make coverage tools mark them green, but robust behavioral assertions are omitted, or generic assertions that always pass are used.
- Focus on easy-to-test code: Disproportionate time is spent testing trivial logic (like getters, setters, simple mappers, or CRUD controllers) while avoiding complex asynchronous flows, race conditions, or complex network integrations because they are difficult to configure.
- Noise and maintenance overhead: The test suite grows in volume but not in fault-detection capability, slowing down the development cycle (feedback loop) and drastically increasing the technical debt of maintaining the tests themselves.
2. The danger of mocks and coupling to internal design
The orthodox unit-testing paradigm requires isolating the unit under test (usually a class or function) completely. To achieve this in modern systems, engineers tend to overuse test doubles (mocks, stubs, and spies).
While mocks are useful tools for simulating expensive side effects (such as sending an email or calling third-party payment gateways), their indiscriminate use to isolate every architectural layer (Service -> Repository -> ORM) introduces two fundamental design problems:
Coupling to implementation details
When you mock a class’s internal collaborators, the test must know how that class interacts with its dependencies (which methods it calls, in what order, and with what exact parameters).
- Consequence: If you decide to refactor the internal design of a module to improve its structure—without altering its external behavior—all your unit tests will break. Instead of facilitating refactoring (which is the primary purpose Martin Fowler advocates for in his classic book Refactoring), heavily mocked unit tests act like a concrete mold that penalizes any changes to the code’s structure.
The illusion of the green boundary
Coverage tools mark code interacting with a mock as “tested,” but the mock is an assumption you wrote.
- The risk: If the real component’s behavior changes (for example, the ORM throws a new database exception under certain circumstances, or an endpoint changes the data type it returns), your unit test will continue to pass in green because your mock still responds according to the old assumption. The real software will fail spectacularly in production despite having 100% coverage in local reports.
3. The physics of software: where systems actually break
Software rarely fails because a pure algorithmic function incorrectly adds two numbers (something unit tests are perfect at catching). The physics of modern production systems show us that crashes and critical failures occur at the boundaries and in the dynamic behavior of the system:
- Network failures and timeouts: How does your application behave when the database takes more than 500ms to respond? Do you properly handle retries with exponential backoff and circuit breakers?
- Consistency and transactions: What happens if writing to Table A succeeds but updating Table B fails? Unit tests with mocked in-memory databases cannot validate isolation levels or database transaction locks.
- Serialization and schema integrity: A subtle JSON field name change in a third-party microservice will cause parsing failures if contract or integration tests are not in the deployment pipeline to catch it.
As Steve Freeman and Nat Pryce explain in their classic book Growing Object-Oriented Software, Guided by Tests, tests should support design, helping us discover if our abstractions are correct and if the connections between them work harmoniously under real load and infrastructure conditions.
4. Designing a pragmatic testing strategy
To escape the coverage illusion and build a real safety net, teams must reorient their strategy around the following practical principles:
Evaluate coverage for diagnostic value, not control
Use coverage tools to answer a single question: “Which parts of the system are completely blind to tests?”. Coverage is useful for discovering unprotected areas of code, but it should never be used as a metric for approval or success.
Prioritize sociable tests over solitary tests
Instead of forcing every test to be strictly unit-based and solitary, allow the use of sociable tests that verify multiple components together. If you are testing business logic, do not mock your database; use tools like Testcontainers to run a real database identical to production (such as PostgreSQL) in a lightweight Docker container during test execution. This gives you 100% real feedback on constraints, data types, and N+1 queries.
Introduce contract and integration testing
To protect external boundaries, implement integration tests and consumer-driven contract tests. This ensures that communication between your services and external data providers is robust and remains consistent across schema changes and independent deployments.
Validate with acceptance tests (the true business value)
While integration and contract tests secure technical connectivity, acceptance tests (often structured with languages like Gherkin under BDD methodologies) validate that the system meets the actual business goal. An acceptance test executes the application end-to-end or over a large sociable subsystem, verifying that a real user story completes successfully. If all your unit tests pass in green but your acceptance test fails, it means the software is technically built but functionally useless.
Conclusion: toward deployment confidence
Measuring a project’s health solely by code coverage percentage is the equivalent of assessing an airplane’s quality by counting the inspected screws in the hangar, without ever conducting a test flight.
The ultimate goal of software engineering is not writing coverage tests, but gaining real deployment confidence. This is achieved by accepting that some pure modules of complex logic will require 100% detailed coverage (such as calculation engines or complex business policies), while other areas of the system are better protected by a broader network of integration and acceptance tests that validate the actual end-to-end user value flow.