1. Why you need go tool cover: turning percentages into a map
When you see something in the console like coverage: 42.9% of statements, your brain happily tries to conclude: “well, almost half, that’s fine!”. But that’s roughly like judging a book by its weight: there’s a number, but little meaning. go tool cover is needed to turn a percentage into a map: which functions tests never touched, which if branches are red, where you covered the “happy path” but forgot the error case. This is no longer an abstract metric, but navigation.
A coverage profile (for example, coverage.out) is data. go tool cover is a “viewer” for that data. And it has two main “lenses”: -func (a table by functions) and -html (source highlighting).
The workflow is simple:
flowchart LR
A["go test -coverprofile=coverage.out"] --> B["coverage.out (profile)"]
B --> C["go tool cover -func=coverage.out"]
B --> D["go tool cover -html=coverage.out"]
C --> E["Table: file/function/percent"]
D --> F["HTML: green/red/gray"]
2. Where coverage.out comes from and why you need it
Before interpreting the report, it’s important to understand where the data comes from. go tool cover does not run your tests by itself: it doesn’t know what inputs you used, which tests you ran, or what happened. It can read the coverage profile file.
So the first step is always the same: generate coverage.out using go test with the -coverprofile flag. In Go this is the standard path: -coverprofile automatically turns on coverage collection, so you don’t need to add -cover separately.
Let’s say we have a small training package calc (we use it as a sandbox for tests and examples):
package calc
// Abs returns the absolute value of a number.
func Abs(x int) int {
if x < 0 {
return -x
}
return x
}
And a test that covers only the “positive” scenario:
package calc
import "testing"
func TestAbs_Positive(t *testing.T) {
if Abs(5) != 5 {
t.Fatalf("Abs(5) must be 5")
}
}
Now we collect the coverage profile (example commands you run in the IDE terminal):
go test -coverprofile=coverage.out
After that you can see both the overall percentage and the fact that the run succeeded, and most importantly, the file coverage.out appears, which is then read by go tool cover.
3. go tool cover -func: an X-ray by functions
The -func mode is the fastest way to understand where to look first. It prints coverage per function and the overall total. It’s convenient to treat it as a “suspect list”: where it’s 0%, where it’s 12%, where it’s suddenly 100%.
It’s important that -func is a summary: it doesn’t explain why a line isn’t covered, and it doesn’t show which exact if branch didn’t run. But it saves a lot of time for initial diagnosis.
The command looks like this:
go tool cover -func=coverage.out
The output will look roughly like this (your numbers may differ; the format is the same):
abs.go: Abs 50.0%
total: (statements) 50.0%
Here it’s important to read the line as a contract:
- on the left is usually the file name (abs.go);
- then the function name (Abs);
- then the statement coverage percentage (statements) inside that function.
And the line total: (statements) is the overall total for the package/profile set.
Why did Abs end up at 50%? Because the function has two paths: x < 0 and “otherwise”. Your test went only through “otherwise”. The return -x branch remained untouched.
A table for quick orientation
| What you see in -func | What it means in practice |
|---|---|
| 0.0% for a function | This function never executed in tests at all (or it ran only in non-coverable sections, but that’s rare at the basic level). |
| 100.0% for a function | All statements inside the function executed at least once (this does not guarantee the tests are good). |
| An “average” percentage | Often means missed if branches, early return, and error checks. |
| total: (statements) | A summary for the whole profile: useful for “before/after”, but not as the only compass. |
4. go tool cover -html: source highlighting
When you’ve already found suspicious functions via -func, you want specifics: which lines did not execute. That’s where -html generates an HTML page with highlighted source code: covered code is usually green, uncovered is red, and sometimes you’ll see gray (non-instrumented) sections.
This method is exactly what visually shows where you lack tests: in which branches, which return, which error-handling paths.
Run it like this:
go tool cover -html=coverage.out
After running it, a browser usually opens (or you open the file manually), and you see your code colored like a traffic light.
How to read the HTML report calmly
An important point: red does not mean “the code is bad”. Red means “with the current set of tests, this code did not execute”. It can be:
- a branch you truly forgot;
- a branch that is impossible to execute by contract (and then maybe the code should be simplified);
- a branch that runs only on input/IO errors (and you didn’t test it).
In the Abs example, HTML will show the return -x line in red, and that’s a very concrete signal: “Add a test for negative numbers”.
5. How to interpret coverage like an engineer: closing risks
The most common beginner trap is to start “padding the percentages” just for the percentages. That leads to tests like “called the function — checked that it didn’t crash”, and the percentage looks pretty while bugs live happily.
The correct interpretation of a coverage report is different: we use it as a hint for where our blind spots are, especially in branching and early returns. Coverage doesn’t prove correctness, but it clearly shows what you didn’t even try to verify.
There are several typical places where red in the HTML almost always means a real hole in testing.
Branching if/switch and “the other half of the logic”
If you see that only one branch is green in an if, that’s a direct invitation: “test the alternative”. Sometimes the alternative is a negative scenario; sometimes it’s a boundary value.
Early return and “error checks”
Very often the red parts are branches like:
if err != nil {
return 0, err
}
This isn’t shameful. This is normal. It just means you never modeled an error. At our course level, we aren’t building complex mocks yet, but even in simple functions you can create test inputs that lead to an error (for example, a bad string for parsing).
“Just in case” code
Sometimes coverage marks in red a chunk of code that, in reality, should never execute (for example, a “stub” or “haven’t removed it yet” code). In this case coverage is not asking “write a test”, it’s asking “make a decision”: either you need this code and then you must test it, or you don’t need it and then you should delete it.
And yes, deleting code is also a kind of optimization—just for your brain.
Execution frequency and coverage modes
Sometimes it’s useful to understand not only “executed or not”, but also “how many times it executed”. This is especially noticeable when you collect a profile in count mode and then open HTML: it becomes almost a “thermal camera”—some code ran many times, some ran rarely.
At our level, remember the idea: -func gives a summary, -html gives visualization, and coverage modes affect what data is embedded in the profile.
But don’t confuse coverage with performance profiling: “ran often” doesn’t mean “slow”, it’s just statement execution frequency within tests.
6. Example: report → test → improved coverage
Let’s do a “before/after” example on our calc.Abs, so the “report → action” connection isn’t just theoretical.
Step 1. There was only a positive test
Function code:
package calc
func Abs(x int) int {
if x < 0 {
return -x
}
return x
}
The test was only this:
package calc
import "testing"
func TestAbs_Positive(t *testing.T) {
if Abs(5) != 5 {
t.Fatalf("Abs(5) must be 5")
}
}
Collect the profile and check the summary:
go test -coverprofile=coverage.out
go tool cover -func=coverage.out
Expected report logic: Abs will be partially covered, because the x < 0 branch never executed.
Step 2. Add a test for a negative number
Let’s add a second test:
package calc
import "testing"
func TestAbs_Negative(t *testing.T) {
if Abs(-7) != 7 {
t.Fatalf("Abs(-7) must be 7")
}
}
Now again:
go test -coverprofile=coverage.out
go tool cover -func=coverage.out
go tool cover -html=coverage.out
What you should see with your own eyes:
- in -func, the percentage for Abs will become 100% (or close to it, if the file contains something else);
- in -html, both branches will turn green.
And this is the right ritual: not a “race for percentage”, but “saw a missed branch → added a test that actually goes through it”.
7. Common mistakes when interpreting go tool cover
Mistake #1: looking only at the overall total percentage and ignoring details.
The overall percentage is convenient for comparing “before/after”, but it’s almost useless for answering “what exactly did I not test”. A good habit: first quickly check -func to find weak spots, then open -html to see the specific red lines.
Mistake #2: trying to run go tool cover without creating coverage.out.
go tool cover reads a profile; it doesn’t run tests. So if the file doesn’t exist or it’s stale, you’ll either get an error or analyze yesterday’s world. The discipline is simple: first go test -coverprofile=coverage.out, and only then go tool cover…
Mistake #3: treating red lines as a signal to “rewrite everything”.
Red means “didn’t execute in tests”, not “broken”. Sometimes it’s a signal to add one boundary-case test, and that’s it. Sometimes it’s a signal that the branch is unnecessary and should be deleted.
Mistake #4: chasing 100% coverage at any cost and writing meaningless tests.
You can write a test that simply calls functions and checks nothing, and often it will “raise the percentage”. But such a test barely catches bugs and creates a false sense of safety. Coverage is a hint for “where to look”, and test quality is your assertions (want/got) and clear scenarios.
Mistake #5: forgetting error branches and early returns.
Often it’s exactly the error checks that are uncovered. As a result, the “happy path” is green, but any failure turns the program into a surprise. If HTML shows in red if err != nil { return … }, that’s usually a good candidate for a separate test that creates an invalid input and checks that the error is handled as expected.
GO TO FULL VERSION