Coverage-Driven TDD: Why 100% Is a Lie and What Actually Matters
Coverage percentage is a proxy metric. Like most proxy metrics, it becomes counterproductive the moment teams start optimizing for it directly.
The problem isn't coverage tools. The problem is treating a measurement of test quantity as a measurement of test quality. You can achieve 100% line coverage with tests that assert nothing meaningful, while critical business logic remains effectively untested because the assertions are wrong.
This guide is about using coverage the right way: as a signal, not a target.
Why 100% Coverage Is Often a Lie
Consider this function:
function calculateDiscount(user, cart) {
if (user.isPremium) {
return cart.total * 0.15;
}
return cart.total * 0.05;
}A test that achieves 100% coverage:
test('calculates discount', () => {
const premiumDiscount = calculateDiscount({ isPremium: true }, { total: 100 });
const regularDiscount = calculateDiscount({ isPremium: false }, { total: 100 });
expect(premiumDiscount).toBeDefined();
expect(regularDiscount).toBeDefined();
});Both lines execute. Both branches execute. Coverage tools report 100%. The assertions check that a number is defined — not what that number is. If the discount rates are swapped (premium gets 5%, regular gets 15%), the tests still pass.
This is coverage theater. The tests exist. The metric is green. The bug ships.
Mutation Testing: The Only Honest Coverage Metric
Mutation testing asks: if I introduce a bug into this code, do the tests catch it?
Tools like Stryker (JavaScript/TypeScript), PITest (Java), and mutmut (Python) automatically create hundreds of mutants — modified versions of your code with small changes: flipped conditionals, changed operators, removed return statements. They then run your test suite against each mutant. A mutant that survives (tests still pass) means your tests wouldn't catch that class of bug.
Install Stryker for a Node.js project:
npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runnerConfigure it in stryker.config.js:
module.exports = {
testRunner: 'jest',
reporters: ['html', 'clear-text', 'progress'],
coverageAnalysis: 'perTest',
mutate: [
'src/**/*.js',
'!src/**/*.test.js',
'!src/**/__mocks__/**',
],
};Run it:
npx stryker runThe output shows a mutation score: the percentage of mutants that were killed by tests. A test suite with 100% code coverage but 40% mutation score tells you something important — your tests are executing code but not verifying its behavior.
For Java with PITest in Maven:
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.15.0</version>
<dependencies>
<dependency>
<groupId>org.pitest</groupId>
<artifactId>pitest-junit5-plugin</artifactId>
<version>1.2.1</version>
</dependency>
</dependencies>
<configuration>
<targetClasses>
<param>com.example.domain.*</param>
<param>com.example.service.*</param>
</targetClasses>
<targetTests>
<param>com.example.*Test</param>
</targetTests>
<mutationThreshold>75</mutationThreshold>
</configuration>
</plugin>What Good Coverage Actually Looks Like
Good coverage has three characteristics that a percentage doesn't capture:
1. It tests outcomes, not execution.
Bad test (tests execution):
test('processOrder runs without error', () => {
expect(() => processOrder(mockOrder)).not.toThrow();
});Good test (tests outcome):
test('processOrder marks order as processing and sends confirmation', async () => {
const order = createOrder({ userId: 'user-1', items: [item('SKU-A', 2)] });
await processOrder(order);
expect(order.status).toBe('processing');
expect(emailService.send).toHaveBeenCalledWith({
to: 'user@example.com',
template: 'order-confirmation',
data: { orderId: order.id, items: order.items },
});
expect(inventoryService.reserve).toHaveBeenCalledWith('SKU-A', 2);
});The first test achieves coverage. The second test verifies behavior. Only the second one would catch a bug where order.status is set incorrectly or the email is sent with wrong data.
2. It covers the branches that matter.
Not all branches are equal. An error message for a misconfigured admin panel deserves less coverage effort than a conditional in payment processing. Good coverage strategy identifies the high-value paths and ensures they're thoroughly tested.
Prioritize:
- Financial calculations and state transitions
- Authentication and authorization checks
- Data validation that affects downstream systems
- Error handling that determines user experience
Deprioritize (but don't ignore):
- Logging and telemetry
- Configuration parsing
- Development-only debug paths
3. It catches regressions.
The true test of a test suite is whether it catches bugs introduced by changes. If you can modify a function's logic and no test fails, that function's coverage is providing false confidence.
Run your test suite with deliberate bugs introduced to validate it. This is what mutation testing automates. Manually, you can do it periodically: change a >= to >, swap a return value, remove a condition. If the tests catch it immediately, they're doing their job.
The Ratchet Strategy
Instead of setting a coverage floor (say, "must be above 80%"), set a ratchet: coverage can never go down.
This approach changes the psychology. Teams aren't racing to hit an arbitrary number — they're ensuring each PR maintains or improves the coverage baseline. New code without tests fails the check. Existing covered code remains covered.
Implement this in Jest:
// jest.config.js
const currentCoverage = require('./coverage/coverage-summary.json');
// Calculate current threshold dynamically
const lineCoverage = Math.floor(
currentCoverage.total.lines.pct
);
module.exports = {
coverageThreshold: {
global: {
lines: lineCoverage, // Never go below what we have
},
},
};A simpler version: commit the coverage numbers and check them in CI:
# In CI, after running tests:
CURRENT=$(jq '.total.lines.pct' coverage/coverage-summary.json)
BASELINE=$(cat .coverage-baseline)
if (( $(echo "$CURRENT < $BASELINE" | bc -l) )); then
echo "Coverage dropped from $BASELINE% to $CURRENT%"
exit 1
fi
echo $CURRENT > .coverage-baselineWriting Tests That Increase Quality, Not Just Numbers
The coverage-quality gap comes from test design. Here's the difference between tests that inflate coverage versus tests that improve quality.
Coverage inflation:
test('all branches', () => {
validate({ age: 25 }); // valid path
validate({ age: -1 }); // invalid age path
validate({}); // missing field path
});No assertions, just execution. Covers branches.
Quality improvement:
describe('validate', () => {
test('accepts valid user data', () => {
const result = validate({ name: 'Alice', age: 25, email: 'alice@example.com' });
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
test('rejects negative age with descriptive error', () => {
const result = validate({ name: 'Alice', age: -1, email: 'alice@example.com' });
expect(result.valid).toBe(false);
expect(result.errors).toContain('age must be a positive number');
});
test('rejects missing required fields', () => {
const result = validate({});
expect(result.valid).toBe(false);
expect(result.errors).toEqual(
expect.arrayContaining(['name is required', 'age is required', 'email is required'])
);
});
});Same coverage. Completely different quality. The second set would catch a bug where error messages are wrong, where valid is true when it should be false, or where the errors array contains the wrong fields.
The 80/20 of Coverage Strategy
Coverage effort should match risk. The 80/20 distribution:
80% of your coverage effort should go to:
- Domain logic and business rules
- State machines and workflows
- Data transformations that feed downstream systems
- Authorization logic
20% of your coverage effort (but still required) for:
- Infrastructure adapters (DB, HTTP clients)
- Configuration and startup code
- Error formatting and logging
Never test:
- Third-party library internals
- Compiler-generated code
- Infrastructure that's tested separately (database queries via integration tests don't need unit test coverage too)
Measuring What Matters
The metrics worth tracking, in order of importance:
- Mutation score — what percentage of introduced bugs do your tests catch?
- Branch coverage of critical paths — are both paths through every business logic conditional tested?
- Coverage delta per PR — is new code being tested at the same rate as existing code?
- Test execution time — slow tests signal over-testing trivial code or under-designing tests
Global line coverage percentage belongs last on this list. It's a sanity check, not a quality indicator. A codebase with 75% line coverage and 85% mutation score is better tested than one with 95% line coverage and 50% mutation score.
The goal is a test suite that catches bugs before production. Coverage tools help you find untested code. Mutation testing helps you find ineffective tests. Neither number tells you the tests are correct — that requires thinking about what your code is supposed to do and writing assertions that verify it.