In your app
module Finance
class Accounts
def debit(account, amount) = …
end
end In your specs
The problem
You’ve inherited an app with patchy tests, or you’ve decided the suite needs to bite harder. Either way there’s more untested code than time, and the question is where to start.
A coverage report doesn’t answer it. It lists 140 uncovered methods, sorted by file name. A
Report#footer_text nobody depends on sits at the same weight as this:
module Finance
class Accounts
def debit(account, amount)
raise InsufficientFunds if account.balance < amount
account.balance -= amount
ledger.record(account, -amount)
end
end
end
Finance::Accounts#debit is called from checkout, refunds, payroll and four background jobs. If it’s
wrong, everything that moves money is wrong. Coverage says it’s one red line among 140.
Mutation testing has the same blind spot from the other side. Mutant is slow, so running it everywhere evenly spends most of the time on code nothing leans on.
The fix
Rank the untested methods by how much depends on them:
$ ra unit_test_candidates app/
fan-in distance method
14 ∞ Finance::Accounts#debit
9 4 Invoice#apply_discount
6 ∞ Enrollment#withdraw
1 ∞ Report#footer_text
· fan-in: distinct callers in recorded runs
· distance: fewest calls from a spec example to the method (∞: no spec reaches it)
debit has fourteen distinct callers and no spec reaches it at all, so it goes first. Ask
how_to_reach for the setup and write the spec:
RSpec.describe Finance::Accounts do
it "refuses a debit larger than the balance" do
account = Account.new(balance: Money.new(500, "GBP"))
expect { described_class.new.debit(account, Money.new(900, "GBP")) }
.to raise_error(Finance::InsufficientFunds)
end
end
Then point mutant at the top of the same list rather than the whole app.
How it works
- Fan-in. Every recorded call edge names its caller’s class and method. The number of distinct callers of a method, across your app runs and your specs, is its fan-in.
- Distance. Each call tree is stamped with the entry point that started it: a spec example or an app run. For every method, the shallowest depth at which a spec-stamped tree reaches it is its distance. A method only reached five calls deep, by a system spec, is barely tested. One no spec reaches is untested.
- Rank. High fan-in and long distance come first: the code most things rely on and the fewest tests look at directly.
It needs a recording of the code being used. Without tests, that’s your app running: in development, staging or production.
Limits
- Reached isn’t asserted. A spec that calls
debitand checks nothing still counts as reaching it. The distance tells you how close a test gets, not whether it checks the result. - Fan-in is callers, not importance. A method with one caller can still be the one that matters, like the only place you charge a card. Treat the ranking as an order to look in, not a verdict.
- Only what ran. Callers in code paths you didn’t record don’t count towards fan-in.
Related tools
how_to_reach
Reach any method. The collaborators and call path that get an object into the state a method needs, taken from a real run. Setup for tests that use real objects instead of doubles.
See more →testing_pyramid
The boundaries your pyramid misses. Seams that are only ever crossed through a double, never for real, mapped onto your testing pyramid. Record production too and they're ranked by traffic.
See more →how_far_it_reached
Is your unit test a unit test? For each spec example, how many classes its real call tree touched and how deep it went. A unit spec that reaches thirty classes is telling you something.
See more →