The CRAP Metric: Complexity and Coverage in One Number
CRAP stands for Change Risk Anti-Patterns. It multiplies how tangled a method is by how untested it is, and the result answers a question no single metric can: which code is dangerous to change?
A Deliberately Blunt Instrument
Alberto Savoia and Bob Evans introduced CRAP in 2007 alongside Crap4j, a Java tool that scored every method in a build. The premise was that neither complexity nor coverage means much alone. A gnarly method with thorough tests is workable. A trivial method with no tests is fine. What actually hurts is complexity nobody has covered, because that is the code where a small edit produces a surprise nobody catches. CRAP puts both terms in one expression so the dangerous combination scores badly and the harmless combinations do not.
The name is doing deliberate work. A metric called Change Risk Anti-Patterns gets discussed once; a metric that tells you a method is crap gets fixed.
The Formula, and Why the Exponents Are Uneven
Complexity is squared. The uncovered fraction is cubed. That asymmetry is the entire design.
- • CC is the cyclomatic complexity of the method: the number of independent paths through it
- • cov is the coverage fraction for that method, from 0 to 1, so (1 − cov) is the untested part
- • Cubing the untested fraction makes the first term collapse fast as coverage rises, so testing an ugly method pays off immediately
- • At full coverage the first term is zero and CRAP equals CC, the residual risk the tests cannot remove
- • The trailing + CC is what stops the metric from ever pretending complexity is free
What the Numbers Actually Demand
Using the conventional threshold of 30, here is how much coverage each complexity level needs before a method falls below the line.
| CC | 0% cov | 50% cov | 100% cov | To clear 30 |
|---|---|---|---|---|
| 5 | 30.0 | 8.1 | 5 | None. Simple code passes untested. |
| 10 | 110.0 | 22.5 | 10 | About 42% |
| 15 | 240.0 | 43.1 | 15 | About 60% |
| 20 | 410.0 | 70.0 | 20 | About 71% |
| 25 | 650.0 | 103.1 | 25 | Exactly 80% |
| 30 | 930.0 | 142.5 | 30 | 100%, and it lands exactly on the line |
| 31+ | 961.0 | 155.2 | 31 | Unreachable. Tests cannot fix this one. |
What the Curve Is Telling You
Past complexity 30, coverage stops helping
With a threshold of 30, a method at complexity 31 is over the line even at 100% coverage. That is not a flaw in the formula, it is the message: the only move left is to split the method.
At full coverage, CRAP is just complexity
Tests never forgive complexity, they only stop it being amplified. A well-covered complex method still carries its complexity as acknowledged, managed risk rather than hidden risk.
Simple code is left alone on purpose
A complexity 5 method sits exactly at 30 with no tests at all. The metric routes attention towards tangled code instead of generating busywork on getters and mappers.
Coverage is the weak leg
cov measures execution, not assertion. A suite of assertion-free tests raises coverage and lowers CRAP while changing nothing about real risk. That specific gap is what mutation testing exists to close.
Working With It
The formula is two lines of code, which is most of why it keeps being reimplemented in new ecosystems.
The metric itself
jsAny coverage report plus any complexity tool gives you everything the formula needs.
// crap.js — coverage is a fraction, 0..1
export function crap(complexity, coverage) {
const untested = 1 - coverage;
return complexity ** 2 * untested ** 3 + complexity;
}
crap(15, 0); // 240
crap(15, 0.5); // 43.125
crap(15, 1); // 15 Gate the change, not the codebase
shA ratchet on changed methods keeps new risk out without opening a cleanup project nobody funded. Legacy scores stay visible on a dashboard instead of blocking every build.
# CI: score only the methods this branch touched
git diff --name-only origin/main... -- '*.js' \
| xargs node ./tools/crap-report.mjs --threshold 30 --changed-only
# exit non-zero when a touched method crosses the line;
# print the untouched offenders as a report, not a failure The refactor the score is asking for
jsWhen the score comes from complexity rather than coverage, testing harder is the wrong response. Pull each branch out into something with complexity 1 and the driver stays flat no matter how many rules arrive.
// Before: complexity climbs with every rule anyone adds.
function validate(order) {
if (!order.id) return 'missing id';
if (order.items.length === 0) return 'no items';
if (order.total < 0) return 'negative total';
if (order.currency !== 'USD' && order.currency !== 'EUR') return 'bad currency';
if (order.customer && !order.customer.email) return 'customer without email';
return null;
}
// After: each rule is trivially testable, the driver stays at 2.
const RULES = [
[(o) => !o.id, 'missing id'],
[(o) => o.items.length === 0, 'no items'],
[(o) => o.total < 0, 'negative total'],
[(o) => !['USD', 'EUR'].includes(o.currency), 'bad currency'],
[(o) => Boolean(o.customer) && !o.customer.email, 'customer without email']
];
function validate(order) {
for (const [fails, message] of RULES) if (fails(order)) return message;
return null;
} How to Use It Without Annoying Everyone
Read the two levers separately
- - A high score driven by complexity is a refactoring task, not a testing task
- - A high score driven by missing coverage is a testing task, and a cheap one
- - Always show CC and coverage next to the score, because the number alone does not say which
Weight by churn
- - Change risk only matters where change happens
- - A score of 200 in a file untouched for four years is less urgent than 60 in a file edited weekly
- - Sort by score multiplied by commit frequency and work down the top of that list
Make exclusions explicit
- - Generated code, adapters, and exhaustive switch statements inflate complexity without inflating real risk
- - Exclude them in configuration, with a comment saying why, rather than quietly raising the threshold
- - Revisit the exclusion list when the code it protects stops being generated
Common Misreadings
Treating it as a quality score
CRAP estimates the risk of changing code. It says nothing about whether the code is correct, well named, or well designed. A clean, well-covered method with genuine domain complexity scores the same as an unpleasant one.
Gaming it with coverage
Snapshot-everything tests and assertion-free walkthroughs move the number without moving the risk. If CRAP is a gate, something has to keep the tests honest, whether that is review, mutation testing, or both.
Launching a cleanup epic
A repository-wide CRAP report on legacy code produces a number so large it gets ignored. Ratchet on changed code instead, and let the dangerous parts get fixed by the people who were already going to touch them.
Expecting a maintained tool
The original Crap4j has been dormant for years. NDepend carries the metric on .NET, and there are community implementations for Rust, .NET, and Groovy, but on most stacks you compute it yourself from data you already collect.
One Number, One Question
CRAP is not a quality score and was never meant to be one. It answers a narrower and more useful question: if someone edits this method next week, how likely is it that something breaks quietly? Complexity says how many ways there are to get it wrong, coverage says how many of them anyone is watching, and the formula weights the second more heavily than the first.
That is enough to be worth wiring into a build. Gate new and changed code at a threshold, sort the rest by score against churn, and read the two inputs separately so the number resolves to an action: split this method, or test it. Just keep in mind that the coverage half of the formula only means what your assertions make it mean.
Related Engineering Articles
The coverage half of this formula is exactly what mutation testing interrogates, and it has already been given its own role in agent workflows.
Mutation Tests Are Negative. TDD Tests Are Positive.
Mutation runs can only report what a suite fails to notice, while TDD tests state what the system must do. How to use each accordingly.
SwarmForge: What Uncle Bob's Agent Swarm Gets Right
A review of SwarmForge's role decomposition, worktree isolation, and handoff protocol, and which parts transfer to any team.
FAQ
What counts as a bad CRAP score?
Thirty is the conventional default threshold, inherited from Crap4j and reused by most later implementations. Lower it for greenfield code where the cost of holding the line is small, and keep it while ratcheting on legacy code rather than raising it to make a report look better.
Why is complexity squared but the untested fraction cubed?
So that coverage moves the score faster than complexity does. The cubed term collapses towards zero as coverage approaches full, which rewards testing complex code immediately, while the squared term plus the trailing complexity keeps a floor under the score no amount of testing can remove.
Is the CRAP metric still relevant?
The original Java tool is long dormant, but the formula keeps reappearing in new ecosystems because it is trivial to implement and answers a question single-metric dashboards cannot. Anywhere you already collect coverage and complexity, you are two lines of code away from it.
Does a low CRAP score mean the code is safe to change?
It means the complexity is covered, which is not the same as tested well. Coverage counts execution rather than assertion, so a low score built on weak tests is a comfortable number over an uncomfortable reality. Mutation testing is the standard way to check whether the coverage is real.
Should CRAP fail the build?
As a ratchet on new and changed methods, yes, because that is enforceable and nobody has to schedule it. As a repository-wide gate on an existing codebase, no. That produces a backlog rather than a behaviour change.