AlgoMaster Logo

Code Coverage

Medium Priority17 min readUpdated June 6, 2026

Coverage tells you which lines of your code ran during a test, and which didn't. Go ships with first-class coverage tooling in the go command, so there's nothing to install. This chapter walks through the three coverage commands you'll actually use, the three coverage modes and when each matters, and the limits of what coverage can tell you about test quality.

What Coverage Actually Measures

When you run a test with coverage enabled, the Go toolchain instruments each statement with a counter. Every time a statement runs during the test, the counter ticks. At the end of the run, the toolchain prints what percentage of statements ran at least once.

Coverage measures statement execution, not correctness, not edge cases, and not whether your tests assert the right things. A test that calls a function and ignores the return value still counts every line it touched as "covered". Keep that in mind as we go through the tools.

The simplest form is -cover, which adds a single percentage line to the standard test output:

Run with coverage:

Every statement in Total ran at least once. The empty cart path and any future error branches would lower this number.

Writing a Coverage Profile

The -cover flag is fine for a quick check, but you can't drill into it. To see which lines ran, write a profile file with -coverprofile:

cover.out is a text file the go tool cover subcommand reads. The format is documented but you rarely need to inspect it by hand; two viewers do all the work.

The first viewer breaks coverage down by function:

That's compact and grep-friendly. CI pipelines often pipe this through awk to fail builds when total coverage drops below a threshold.

The second viewer is the one most people actually use day to day:

This opens an HTML report in your browser. Each source file is rendered with covered lines in green and uncovered lines in red. Lines the compiler optimized away or that aren't statements (closing braces, blank lines) are gray. You can click through file by file, and it's the fastest way to spot a missing branch.

To save the HTML instead of opening it (useful in CI to upload as an artifact):

The profile is the contract between the test run and the viewers. Once it's on disk, you can view it in either form, share it with teammates, or feed it into third-party tools like Codecov.

Finding Uncovered Edge Cases

Coverage is most useful when a function has branches your tests miss. Take a discount function that's slightly less trivial than Total:

Suppose the only test is the happy path:

Open the HTML report and the empty-cart branch, the negative-quantity branch, and the bulk-discount branch all show up in red. That's the signal: three missing test cases, each pointing at a real edge case that production code would eventually hit.

A second test fills the gaps:

The red lines guided the test cases. That's the workflow: write tests, run coverage, look at red, decide if each red line deserves a test or if it's an unreachable error path.

Coverage Modes: set, count, atomic

By default, Go records whether each statement ran at all. That's the set mode. It answers a yes/no question per line and uses the fewest CPU cycles to do it.

Two other modes change what's recorded:

ModeWhat it recordsWhen to use
set (default)Did this statement run? (boolean)Most projects, fastest
countHow many times did each statement run?Finding hot vs cold paths inside covered code
atomicSame as count, but safe under concurrent accessParallel tests, anything with t.Parallel() or goroutines hitting shared code

You select a mode with -covermode:

The HTML report in count mode shades each line by hit frequency: brighter green means more executions. That's useful if you want to know whether a particular branch is exercised once or thousands of times across your test suite, but it's a fairly niche use.

The mode that matters in real code is atomic. Here's why it exists:

If you ever run go test -race -cover ./... and see a race report inside the coverage instrumentation, that's the signal to switch:

For sequential tests on a single goroutine, set is fine and faster. For any package that runs parallel tests or tests goroutine-heavy code, atomic is the safe default. There's no benefit to picking count over atomic unless you specifically want hit counts; pay the atomic cost and move on.

Coverage Scope: One Package vs Many

Run go test -cover ./... and the toolchain reports a percentage per package, each in isolation. The default scope of coverage instrumentation is "the package being tested". So if package cart has a test that calls a function in package inventory, the calls from cart's tests don't count toward inventory's coverage, and they don't even show up in cart's coverage report for the inventory code.

For most projects this is what you want. Each package owns its tests, and a low number for inventory is a real signal that inventory needs more tests, not a number inflated by cross-package calls.

But sometimes you want cross-package coverage. The classic case is an integration test in package api that exercises a cart package indirectly. The api tests cover cart code, but the default scope doesn't record it. Use -coverpkg to widen the scope:

This instruments every package matching the pattern, and any test in any package contributes to the coverage of every instrumented package. Now an integration test in api that flows through cart will show coverage on the cart lines it reached.

A common pattern in CI is:

That single number, total coverage across all packages, is what most CI dashboards display. Inner-loop development sticks with plain go test -cover ./cart to keep iteration fast.

The 100% Trap

Coverage is a floor, not a ceiling. Hitting 100% says every line ran, not that the code is correct. A test that calls a function and never asserts anything still drives coverage to 100%:

That test "covers" the happy path of ApplyDiscount but verifies nothing. The CI dashboard goes green, the function is broken, nobody notices. Coverage is necessary but not sufficient.

There's also a real cost to chasing 100%. Some lines are defensive error paths that are hard or impossible to trigger from a test:

The json.Unmarshal error path is reachable: pass invalid JSON in a test file. Fine. But consider:

The panic line is unreachable by design. The only way to cover it is to violate the precondition the function documents, which means writing a test that exists solely to satisfy coverage. That test pins down the panic message and adds no real value. Leave it red.

Coverage also doesn't measure:

  • Correctness. A test that calls a function with one input proves nothing about other inputs. Coverage of 100% on a single happy-path test is still one test.
  • Edge cases inside a branch. A single integer comparison branch shows green after one test, but n == 0 vs n < 0 vs n > MaxInt - 1 are three different stories the branch doesn't tell.
  • Combinations of paths. Two independent if-statements have four combined paths. Coverage hits both branches with two tests, but only exercises two of the four combinations.
  • Concurrency bugs. A test can cover every line of a goroutine and still miss the race that fires once a week in production.
  • Performance. A line that runs in 5ms vs 5s has the same coverage. Benchmarks measure that.

The right way to read a coverage number: aim for "high enough that obvious gaps stand out", which for most production code lands somewhere in the 70-85% range. Lines that stay red after that are either unreachable or expensive to test, and the time you'd spend chasing them is usually better spent on stronger assertions, more edge-case tests, or fuzzing.

A Practical Workflow

Here's the workflow that holds up day to day:

  1. While writing tests, run go test ./cart with no coverage. Fast iteration, no profile clutter.
  2. Before pushing, run go test -cover ./cart to get a quick percentage.
  3. If the number is lower than expected, run go test -coverprofile=cover.out ./cart && go tool cover -html=cover.out and look at the red lines.
  4. Decide per red line: write a test, or leave it red because it's unreachable.
  5. In CI, run the full sweep: go test -race -covermode=atomic -coverpkg=./... -coverprofile=cover.out ./..., then publish the profile as an artifact or feed it to a coverage service.

This keeps the inner loop fast and gives you the precise view only when you need it. Don't run -coverpkg=./... on every save: the rebuild cost adds up. Don't ignore coverage entirely: red lines are the cheapest test ideas you'll ever generate.

Quiz

Code Coverage Quiz

11 quizzes