10 KiB
The Coverage-Adaptive Safety Net
The right amount of caution before a refactor is not a fixed policy — it is gated on how well-tested the blast radius already is, not on the project's global coverage number. A codebase sitting at 90% coverage overall can still have the one function you're about to touch at 0%, and a codebase at 30% overall can have your target function fully pinned by table-driven tests. Measure the code you're actually going to change, then pick the tier below.
The Three Tiers
- HIGH coverage on the blast radius (roughly ≥80% function coverage via
go tool cover -func) — refactor aggressively with tools, and trust the green bar.- Prefer gopls Rename/Inline/Extract,
eg/gofmt -rfor bulk mechanical changes, and generatedgo/analysisfixers over hand-edits. - Run the fast net (build/vet/test) after each step, and escalate to
-race/-benchonly when the step touches concurrency or a hot path. - Larger steps are acceptable here because a well-covered blast radius means the net catches a regression within one test run — the cost of being wrong is cheap and immediate, so there is little reason to slow down.
- Prefer gopls Rename/Inline/Extract,
- MEDIUM coverage (~40-80%) — harden the blast radius before refactoring it, not after.
- First measure exactly what the change touches: gopls references and call hierarchy tell you the real call graph, not the one you remember from reading the code (see the planning gate in workflow.md).
- Add targeted table-driven or golden tests covering precisely those paths, confirm they pass against the current code, and only then refactor.
- The step that's easy to skip and shouldn't be: re-run with
-coverprofileafterward and check that the new tests actually exercise the lines you're about to change. A test that imports the right package but never reaches the branch you're editing gives false confidence — it looks like safety net from the outside and catches nothing.
- LOW/ZERO coverage (<40%, or the specific touched lines are uncovered even if the package average looks fine) — Feathers mode — write characterization (a.k.a. golden or pinning) tests first, capturing what the code actually does today, warts and all, before changing a single line.
- This is deliberately not a correctness test — you are not asserting the code is right, only recording what it currently does so a refactor can be checked against it.
- Find a seam (below) and introduce the minimum needed to make the code testable.
- Restrict yourself to the safest, tool-verified refactorings only — gopls Rename and Inline, both behavior-preserving by construction — and avoid Extract or any cross-package move until a real net exists to check them against.
- Prefer Sprout/Wrap (see catalog.md) to add new behavior in a new, tested function rather than editing untested code in place.
- Running
deadcode -testfirst is worth the two minutes — some of what looks like "untested code that needs a net" turns out to be exported API nothing actually calls, in which case the honest fix is deletion, not testing.
| Tier | Blast-radius coverage | Strategy | Allowed transforms |
|---|---|---|---|
| High | ≥80% function coverage | Refactor first, verify after each step | gopls Rename/Inline/Extract, eg/gofmt -r, generated go/analysis fixers |
| Medium | ~40-80% | Harden the touched paths, confirm green, then refactor | Same as High, once targeted tests exist and -coverprofile confirms they hit the touched lines |
| Low/Zero | <40%, or the touched lines specifically | Characterize first (Feathers mode), introduce a seam, refactor last | gopls Rename/Inline only; Sprout/Wrap for new behavior; no Extract or cross-package move until a net exists |
Diagnose: 1- go test -covermode=atomic -coverpkg=./... -coverprofile=cover.out ./... — runs the suite and produces a coverage profile scoped to the blast radius's packages 2- go tool cover -func=cover.out — ranks every function by coverage percentage; scan this for the specific functions you're about to touch, not the package-level average 3- go tool cover -html=cover.out — a visual red/green view of the exact touched lines, useful when -func's percentage for a function is ambiguous about which branches are actually green
Two caveats worth internalizing before trusting any number this produces:
- Go's coverage is statement coverage, not branch coverage — a line inside an
ifblock that ran once counts as fully covered even if theelsenever executed and even if aswitchonly ever hit onecase. A function reporting 100% can still have an untested branch; treat the percentage as a floor on how much is exercised, not proof that the logic is correct, and read the actual branches in the code you're about to touch rather than trusting the summary. go test ./...silently drops any package that has no_test.gofile from the aggregate — it isn't counted as 0%, it simply isn't in the report at all, which makes an untested package invisible instead of visibly red. Passing-coverpkg=./...(as in the Diagnose command above) forces every package in the module into the profile so a silently-untested dependency doesn't slip past the tier decision unnoticed.
Seams — What to Introduce When There's No Net Yet
- A seam, in Michael Feathers's sense, is a place in the code where you can alter behavior without editing that exact spot.
- Seams are how Feathers mode gets a fake into a test without first performing the larger refactor the test is meant to protect against.
- Two seam types matter in Go:
- An object seam is an interface, or a function-typed field or parameter, injected at the point of construction — a test substitutes a fake implementation through that injection point instead of exercising the real dependency. This is the seam type that matters most in Go, because interfaces are satisfied implicitly: introducing one at the point of use requires touching only the consumer, never the producer package, which means you can add a seam to legacy code without an invasive edit to whatever it depends on.
- A link/build-tag seam swaps an entire implementation at build time via
//go:buildconstraints; it's used far more rarely, mostly for platform- or environment-specific substitutions where an interface would be overkill.
- The enabling move for untested code with no seam yet: extract the smallest possible interface — often just one method — at the exact call site where the untested code depends on something external (a database client, the filesystem, a clock), and inject the concrete implementation through a constructor parameter instead of constructing it inline.
- This single move does two things at once: it breaks a potential import cycle between the consumer and whatever concrete type it depended on, and it opens the door for a fake in a characterization test, without requiring any change to the producer side at all.
// Before — no seam: NewReport constructs its own client, so a test
// exercising Generate has no way to substitute a fake and is stuck
// hitting a real database.
func NewReport(dsn string) *Report {
db, _ := sql.Open("postgres", dsn)
return &Report{db: db}
}
func (r *Report) Generate(ctx context.Context, id int) (Summary, error) {
row := r.db.QueryRowContext(ctx, "SELECT ... WHERE id = $1", id)
// ...
}
// After — a one-method interface extracted at the point of use;
// the concrete *sql.DB already satisfies it implicitly, so the
// producer package needs no change at all.
type rowQuerier interface {
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
func NewReport(db rowQuerier) *Report {
return &Report{db: db}
}
func (r *Report) Generate(ctx context.Context, id int) (Summary, error) {
row := r.db.QueryRowContext(ctx, "SELECT ... WHERE id = $1", id)
// ... unchanged — a characterization test can now inject a fake rowQuerier
}
→ See samber/cc-skills-golang@golang-design-patterns skill for constructor and dependency-injection patterns this move builds on, and catalog.md in this skill for the Sprout/Wrap mechanics that typically pair with a freshly introduced seam.
Verification Command Reference
This is the fast net from the Core Loop in SKILL.md, escalated only as far as the change actually requires:
go build ./... # fastest gate — compile errors
go vet ./... # correctness checks the compiler doesn't do
go test ./... # full test suite
go test -run TestName ./pkg/... # target one test while iterating
go test -race ./... # concurrency changes — see samber/cc-skills-golang@golang-testing for race-detector and testing/synctest mechanics
go test -covermode=atomic -coverpkg=./... -coverprofile=cover.out ./...
go tool cover -func=cover.out # per-function and total coverage, ranked
go tool cover -html=cover.out # visual red/green source view
go test -bench=. -benchmem -count=10 > new.txt # capture before AND after with the same command, then:
benchstat old.txt new.txt # `~` means no statistically significant difference — the desired result for a behavior-preserving refactor; anything else is a signal to stop and investigate, not noise to shrug off
Cross-References
- → See
samber/cc-skills-golang@golang-testingskill for general test-writing craft, race-detector mechanics, andtesting/synctest— this file assumes them as a baseline and only covers when a refactor's safety net needs to reach for them. - → See
samber/cc-skills-golang@golang-benchmarkskill for interpreting abenchstatdelta and the full profiling methodology. - catalog.md — the Fowler refactoring catalog mapped to Go, including the Sprout/Wrap entries referenced in the Feathers-mode and seams sections above.
- workflow.md — the planning gate that measures the blast radius this file's tiers are gated on, and the human-checkpoint rule for touching untested code.