I have never seen a metric sit as stubbornly still as our test coverage did. Twenty-five percent. For two years. It wasn't drifting up, it wasn't drifting down, it just sat there like furniture nobody wanted to move. And the thing that eventually fixed it wasn't a rewrite or a hero week - it was a small, almost boring idea called a test coverage ratchet, which I'll get to. First I want to tell you why this bugged me for so long, because the psychology turned out to matter more than the config.
Why this bugged me for years
I joined a team maintaining a ~400,000-line JavaScript and TypeScript monolith. Coverage was 25% and everyone knew it. It's not that people didn't care - I watched them care, out loud, in retros. It's that every attempt to fix it failed the exact same way. Someone would pitch a "refactoring sprint," leadership would grudgingly hand over two weeks, the team would rewrite one module, a production incident would eat half the time, and the whole thing would quietly evaporate. Coverage: still 25%.
What really got under my skin was watching genuinely excellent engineers make a one-line change to a function they clearly understood, and then refuse to clean up the obvious mess sitting right next to it. When I asked why, the answers were always the same flavor: "it's always worked this way," "better not touch it," "honestly it'd be easier to rewrite than to understand." That's not laziness. Once I stopped reading it as laziness, everything reframed.
The thing I finally understood: it's fear, not incompetence
Teams don't rot from a lack of knowledge. They rot from fear. When a codebase is big and fragile, every change feels dangerous, so people start programming defensively - the smallest possible edit, a workaround instead of a fix. And it feeds on itself: the worse the code gets, the less anyone wants to touch it, which makes it worse. Psychologists have a name for it, learned helplessness, that state where you stop trying to change a situation even when you actually could. Michael Feathers puts the technical half of it bluntly in Working Effectively with Legacy Code - "legacy code is simply code without tests" - which is exactly why the fear is rational. With no tests, every edit really is a gamble.
And here's the thing that clicked for me: you cannot fix learned helplessness with a two-week sprint. A sprint says "all or nothing," and since "all" is impossible, the brain quietly hears "nothing." The way out is the opposite of a sprint - tiny, achievable, basically-guaranteed-to-succeed steps. That's the whole spirit of incremental constraints, and it lines up perfectly with Robert C. Martin's Boy Scout Rule: leave every file you touch a little better than you found it. Not perfect. A little better. Rename one variable, split one bloated function, delete one bit of duplication.
The rule we actually wrote down
We put one sentence on the team wiki: every change should leave the code in a better state than before the change. Then we made it concrete. New files had to meet a real bar - tests for public methods, no exceptions. Modified files couldn't lose coverage, and ideally gained a few points. Critical bug fixes shipped with a regression test that reproduced the bug. Refactors shipped with a test proving behavior hadn't changed.
The magic is entirely in the asymmetry. We never asked anyone to go improve the 300,000 lines of legacy code. We asked only that whatever you newly wrote or happened to touch that day met the bar. Legacy code you never open never blocks you. That one boundary is what made the whole thing feel possible instead of doomed.
Making it real: the ratchet lives in CI
A rule nobody enforces is just a nice feeling on a wiki. So we encoded the ratchet in three layers.
The first is a two-tier jest coverage threshold. The trick is a per-path override - jest's coverageThreshold takes both a global block and path/glob-specific blocks, and a glob's files are held to their own bar independently of the global one. We pinned the global numbers at today's levels so they could never slide backward, while new feature directories answered to 80%:
// jest.config.js
module.exports = {
collectCoverageFrom: ['src/**/*.{js,ts,tsx}'],
coverageThreshold: {
global: {
branches: 25, // current level - never decrease
functions: 30,
lines: 35,
statements: 35
},
// Everything created after we flipped the switch
'src/features/**/*.ts': {
branches: 70,
functions: 80,
lines: 80,
statements: 80
}
}
}
Every few weeks, once the global numbers had drifted upward on their own, we'd bump the global floor up to meet them. That's the whole idea of a ratchet - it only ever clicks in one direction, and it can't click back.
The second layer is a quality gate that judges only the diff. This pattern has a name too: diff coverage, sometimes called patch coverage. You hold a high bar only to the lines a pull request adds or changes, and you cheerfully ignore the legacy sea around them. Codecov ships this as a first-class check - its codecov/patch status "only measures lines adjusted in the pull request" - so you can demand 80% on new lines without touching the rest of the repo. Ready-made ratchet tools exist as well; jest-coverage-ratchet reads your coverage summary and nudges each threshold up to the current level so it can only ever go higher. All of these are lovely. But a tool with no culture behind it just becomes another gate people learn to game, so we wrote a thin wrapper of our own to keep the numbers legible to the team:
# .github/workflows/quality-gate.yml
name: Quality Gate
on: [pull_request]
jobs:
quality-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0 # needed for diff analysis
- name: Check coverage for changed files
run: |
CHANGED_FILES=$(git diff --name-only origin/main...HEAD | grep -E '\.(js|ts|tsx)$')
if [ ! -z "$CHANGED_FILES" ]; then
npm run test:coverage
fi
We rolled it out with a phase I'd call "measurement without judgment" - run coverage, ESLint, and duplication analysis purely to see where we stood, with zero blame attached to any number. That framing mattered enormously. Nobody feels attacked by a dashboard they helped set up.
The third layer is ESLint with the same asymmetry - strict complexity limits on new directories, warnings-only on the legacy ones, so src/features/** answered to a complexity ceiling of 8 and 30-line functions, while src/legacy/** got gentle warnings at 15 and 100. Same shape, different tool.
The trick that unlocked the genuinely scary files
Some functions were terrifying - a 200-line calculateDiscount nobody fully understood. You can't refactor what you can't describe, and this is where Feathers' characterization test earns its keep: it "characterizes the actual behavior of a piece of code". You don't test what the code should do. You test what it currently does, whatever that is, warts and all, and pin it in place before you dare touch anything:
describe('calculateDiscount - current behavior', () => {
it('VIP user with promo XYZ123', () => {
const result = calculateDiscount({ type: 'VIP' }, [{ price: 100 }], 'XYZ123');
// We don't know WHY it's 0.25, we're just pinning current behavior
expect(result.discount).toBe(0.25);
});
});
The emotional shift here is real. Once a tangled function is wrapped in characterization tests, it just stops being scary. You've got a net that screams the instant behavior changes, so you can finally carve it into small, testable pieces with your shoulders down.
How it feels now
Eleven months later, global line coverage had climbed from 25% to 61%, and the code we shipped that quarter was sitting around 84% - up from maybe 10% before. We reverted far fewer PRs for regressions, and "safe-ing" a scary legacy function went from a thing we simply avoided to something you could knock out in an afternoon. Most tellingly, the number of engineers willing to touch calculateDiscount went from exactly one to most of the team.
But the number I care about most is the one we never scheduled: zero dedicated refactoring sprints. Coverage climbed because ordinary feature work now dragged quality up with it, one touched file at a time, and nobody had to be a hero.
We hit a few walls worth naming. Don't set the new-code bar at 100% - we tried 90% and people gamed it with trivial assertions; 80% forced real tests without inviting malicious compliance. Bump the global floor by hand, in its own PR, on purpose - we automated it once and a hot-fix that happened to touch a well-covered file caused flaky failures. And treat "measurement without judgment" as load-bearing: the first time a manager used the coverage dashboard to single someone out in a review, trust cratered for a month. Kill that instinct early and loudly.
If you want the long version - the full psychology, every config, the pre-commit hooks I didn't have room for - someone wrote up the whole incremental-constraints playbook here, and it's worth a read before you pitch your next doomed sprint.
What stays with me isn't the graph. It's that "better not touch it" has basically vanished from our standups. We didn't make anyone braver by asking them to be brave. We just made the next small step safe enough that bravery stopped being the requirement - and it turns out an entire team quietly leaving files a little better than they found them will outrun any refactoring sprint you could ever schedule.
Sources & further reading
- Configuring Jest —
coverageThresholdwith global and per-glob overrides - Robert C. Martin — The Boy Scout Rule (InformIT)
- Michael Feathers, Working Effectively with Legacy Code — characterization tests, summarized
- Codecov — Status Checks and the
codecov/patch(diff) coverage gate - jest-coverage-ratchet — a ready-made one-directional coverage ratchet (GitHub)
- A full write-up of one team's rollout, with the configs and the culture change laid out
Top comments (0)