1. What is coverage, and why it is not a “happiness percentage”
Coverage is often perceived like a school grade: “I have 92%—I’m great”, “I have 37%—I’m bad”. In practice, this is a harmful habit, because coverage in Go answers a narrower and more honest question: were the statements in your code executed while running tests. Not executed means tests never went there; executed means they did. This does not say that your assertions were smart, and it certainly does not guarantee the absence of bugs.
In Go, when we enable coverage, go test instruments the code and counts coverage by statements. That is why branching (if, switch) almost always produces a typical picture: you tested the “happy path”, while the “red path” (errors, edge cases, early return) remained unexecuted—and coverage will highlight that honestly. In that sense, coverage is a great way to remind you: “Hey, did you actually test the error cases, or did you just hope the universe would be kind?”
Before choosing coverage modes, it helps to understand the measurement philosophy. Coverage in Go, by default, counts statements, i.e., individual statements. This is not quite the same as “branch coverage” in some other ecosystems. Branching affects coverage indirectly: if you have an if, then statements inside a branch are counted as covered only if you actually took that branch.
For a beginner, this is a great model because it matches intuition: “I wrote code—the test should reach it.” Coverage is especially good at highlighting the things people most often forget to test: early returns, error handling, default in switch, input validation. If you write Go in its usual style (with lots of if err != nil { return ... }), coverage very quickly shows which error return paths you never executed in tests.
2. go test -cover: a quick coverage “thermometer”
When you add the -cover flag to your test run, go test starts counting how many statements were executed during tests and prints the percentage to the console. This is the fastest way to get an initial signal: do tests actually exercise anything, or do they just compile and pretend to be busy. This run is especially useful when you have just added a new function and want to see whether you forgot to write tests entirely.
Let me show this on a small part of our tutorial app (let it be the todo package, where we parse a task status from a string). We will intentionally make the code branchy—this is convenient for demonstrating coverage.
package todo
import (
"fmt"
"strings"
)
type Status int
const (
StatusTodo Status = iota
StatusDone
)
func ParseStatus(s string) (Status, error) {
switch strings.TrimSpace(strings.ToLower(s)) {
case "todo":
return StatusTodo, nil
case "done":
return StatusDone, nil
default:
return 0, fmt.Errorf("unknown status: %q", s)
}
}
Now a test that intentionally does not cover everything (we will start with partial coverage to see the point of the percentages):
package todo
import "testing"
func TestParseStatus_Todo(t *testing.T) {
st, err := ParseStatus("todo")
if err != nil || st != StatusTodo {
t.Fatalf("expected todo, got %v, err=%v", st, err)
}
}
Run:
go test -cover
In the output, you will see a line like “coverage: N% of statements”. This is literally “N% of statements were executed”—not “N% correctness”. This percentage is a good thermometer: it shows the temperature, but it does not make a diagnosis.
3. -coverprofile: “percentage in the console” vs “data in a file”
A percentage in the console is nice, but it is almost useless if you want to understand what exactly is not covered. For that, Go can save detailed coverage data to a file—a coverage profile. This is done with the -coverprofile flag.
Key point: if you use -coverprofile=..., then go test automatically enables coverage analysis. That means you do not need to specify both -cover and -coverprofile together—-coverprofile is enough.
Command example:
go test -coverprofile=coverage.out
As a result, you get two useful effects at once: the percentage is printed to the console, and the coverage profile is written to the coverage.out file, which you can then analyze with tools (including visually). A detailed walkthrough of reading the profile is a separate topic, but it is important to build the habit now: “checked the percentage → saved the profile”.
One more practical detail from real life. When you start fixing coverage, you often do a loop like: write a test → run tests → run again. If you see that the run is “instant”, and you suspect the go test cache kicked in, you already know the remedy: -count=1. Of course, you can also force a rebuild of the coverage profile the same way if you doubt that changes were actually executed.
4. -covermode: set, count, atomic
The -covermode flag controls how exactly coverage is counted: just whether execution happened, how many times it happened, or how many times it happened with correct accounting in parallel scenarios. Go has three modes: set, count, atomic. In very short terms: set answers “was this statement executed?”, count answers “how many times was it executed?”, atomic answers “like count, but with accurate counting in concurrent programs”.
Here is a compact table (it is genuinely useful to keep this in mind):
| -covermode | What it stores | When to choose it | Cost/overhead |
|---|---|---|---|
| set (default) | Executed / not executed | Almost always as a default, especially for learning and typical unit tests | Cheap |
| count | How many times a statement executed | When you want a “heatmap”: which areas run often and which run rarely | Cheap (usually enough) |
| atomic | How many times, but correct under parallel execution | When you need precise counters under concurrent/parallel execution | More expensive: uses atomic operations and can be noticeably “heavier” |
set mode: standard and “doesn’t annoy you”
set is the default mode. It answers: “was this statement executed at least once?” For most learning and practical situations, this is more than enough. As a developer, this is what you most often want: quickly see the “red” areas—i.e., the parts tests never reached at all.
You can enable set explicitly like this (though it is not necessary, because it is the default anyway):
go test -covermode=set -coverprofile=coverage.out
This is convenient when you write scripts/commands in a uniform style and want to always specify the mode explicitly.
count mode: when you want to understand “how often”
Instead of “yes/no”, count stores how many times a statement executed. This is no longer only about “covered/not covered”, but also about “how activity is distributed”. It is important not to confuse this with performance profiling: coverage does not measure time. But as a “heatmap” of execution frequency, count can be useful: for example, you might see that some part of the code runs a thousand times in one test and realize it is a very hot area of the logic.
Command:
go test -covermode=count -coverprofile=coverage.out
And an important detail: the coverage percentage for set and count does not change, because “is the statement covered” is a separate metric from “how many times it is covered”.
atomic mode: precise counters under parallelism
atomic is the same as count, but with correct counting when the same code can execute concurrently. It uses atomic operations (sync/atomic) and therefore can be noticeably more expensive.
At this stage, it is enough to remember “why it exists” and “why you should not enable it just because it sounds cool”. It is like putting on a helmet, body armor, and carrying a fire extinguisher to go buy bread: technically safer, but the clerk will start asking questions.
The command looks like this:
go test -covermode=atomic -coverprofile=coverage.out
5. Mini scenario: how coverage helps you find a forgotten test
Now we will do what usually happens in a real project: add one more test and see how coverage changes in a meaningful way.
We had the ParseStatus function with three logical paths: "todo", "done", and the error path (default). Right now, the test covers only "todo". Let’s add a test for "done" (and we will already notice that errors are still not covered).
package todo
import "testing"
func TestParseStatus_Done(t *testing.T) {
st, err := ParseStatus("done")
if err != nil || st != StatusDone {
t.Fatalf("expected done, got %v, err=%v", st, err)
}
}
Run:
go test -cover
The percentage will go up. But what matters is this: even if the percentage looks “prettier”, you still have not tested the error path, and that is often what breaks first in production. So let’s add a test for invalid input to cover the default:
package todo
import "testing"
func TestParseStatus_Unknown(t *testing.T) {
_, err := ParseStatus("i am not a status")
if err == nil {
t.Fatalf("expected error, got nil")
}
}
Now you will see coverage approach 100% for this function. And this is the rare case where high coverage actually aligns with common sense: the function is small, there are few paths, and the tests verified all paths.
At the same time, keep the grown-up thought in mind: even if coverage is 100% for a function, the test can still be weak. For example, you can write a test that calls a function but checks nothing. Coverage will be happy—and bugs will be happy too.
6. Handy commands to keep around
Coverage is convenient to use as a repeatable ritual, not as “once a year before release”. So it helps to have a couple of standard commands.
Here is a basic set that most often covers 90% of needs:
go test -cover
go test -coverprofile=coverage.out
go test -covermode=count -coverprofile=coverage.out
The first command gives a quick signal: “is anything covered at all?” The second saves the profile. The third saves a profile with counts (“how many times”), if you need not only “red/green”, but also “how green”.
7. Typical mistakes when working with coverage
Mistake No. 1: treating coverage as proof that tests are correct.
The most common trap is to think that “80% coverage” means “the program is 80% correct”. Coverage measures whether statements executed, not the quality of assertions in tests. You can achieve high coverage with tests that check nothing and end up with perfectly “covered” garbage.
Mistake No. 2: forgetting about -coverprofile and living only with the percentage.
The percentage in the console is a useful signal, but it does not answer the main question: what exactly is not covered. Without -coverprofile, you quickly hit the situation “coverage is low, but where exactly is it low—unclear”. The profile is the way to turn coverage from an abstract number into diagnostic data.
Mistake No. 3: thinking that -coverprofile must be used together with -cover, otherwise coverage will not turn on.
It is a small detail, but it constantly comes up for beginners. In reality, -coverprofile enables coverage analysis on its own, so -cover becomes redundant.
Mistake No. 4: enabling -covermode=atomic “just in case”.
atomic is needed for accurate counting in parallel scenarios and can be expensive. If you write regular unit tests without parallel execution, count is almost always enough, and set certainly is.
Mistake No. 5: trying to “pump up the percentage” instead of covering meaningful branches.
Sometimes people start writing tests for the sake of numbers, especially when a team has a KPI like “at least 85%”. This leads to weird tests that call code without checking results, just to make lines turn green. It is far more useful to start from the logic: cover error branches, edge cases, and function invariants—and let the percentage be a consequence, not the goal.
GO TO FULL VERSION