Skip to content
academia.sh

Lesson 06 / 16

Coverage Reporting

The same function tested with three separate test sets gives three separate coverage ratios — 57.1%, 85.7%, 100.0% — and the uncovered block count drops correspondingly to 3, 1, and 0. The limiting measurement shows, by running the third set — the one that gives 100% coverage — against a broken source, that it catches no real defect at all in a branch whose return value it never checks.

Published

In this lesson

  • Tests the same function with three different test sets and reads the coverage ratio and the uncovered block count from the run
  • Measures that coverage ratio counts blocks that ran, not which assertions actually ran
  • Limits that 100% coverage does not show correctness, with a fully covering test set that catches no real defect
Contents

The previous lesson split eleven pieces of information about a program into source, tool, and run classes; one of these eleven — covered line ratio — passed in that lesson only as a single number (80.0% of statements), with no detail given. This closing lesson is the topic’s wrap-up, and it opens that detail: what exactly does coverage ratio count, and what actually changes when this number rises?

Coverage ratio is a commonly used indicator in production codebases: a review, a threshold (“new code must be covered at least this much”), or a single percentage shown on a dashboard. What this indicator says and does not say is the final exam of the “who states this” question the topic’s five lessons have built — because coverage, looked at on its own, proves not that a program was tested, but only that it was run. To answer this question, the lesson returns one last time to the inventory package used throughout the topic; this time the subject is not the package itself but the three different test sets that test the same package.

Same Function, Three Test Sets, Three Ratios

One last function is added to the inventory package: Label looks at an item’s quantity and returns one of four states — a negative quantity "invalid", zero "out of stock", under three "low stock", the rest "sufficient". Each of the four states has its own return line, and the four states are mutually exclusive: a single call falls into exactly one of them.

This function was built deliberately as an example with an adjustable branch count — four distinct branches, four distinct test cases, and exactly how many separate blocks each case adds to coverage can be traced one by one. Valid (Table-Driven Tests) and ReduceStock (Faking and Interface Separation) also carried similarly branched shapes; this lesson reuses the same shape to see how the coverage tool counts this branching.

All three test sets run against the same inventoryText source — Label itself never changes, only which states get called changes. This shows that coverage ratio is not a property of a source but a property of a source-test pair: the same source’s coverage ratio can take any value between 57.1% and 100.0%, depending on which test set asks the question.

The Setup and Run routines have not changed since the topic’s first lesson; this lesson too builds a completely empty temporary module for every test set, carrying no trace of a previous run.

TM18. The same Label function is tested with three different test sets: T1 tests only the “sufficient” state, T2 tests three states (“sufficient”, “out of stock”, “low stock”), T3 calls all four.

// tools.go — setup, run, and coverage-reading routines, and the Label used throughout the topic
package main

import (
	"os"
	"os/exec"
	"path/filepath"
	"strings"
)

func Setup(files map[string]string) string {
	dir, _ := os.MkdirTemp("", "trial")
	os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module trial\n\ngo 1.24\n"), 0o644)
	for name, content := range files {
		os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)
	}
	return dir
}

func Run(dir string, extraEnv []string, args ...string) (string, bool) {
	cmd := exec.Command("go", args...)
	cmd.Dir = dir
	cmd.Env = append(append(os.Environ(), "GOTOOLCHAIN=local"), extraEnv...)
	output, err := cmd.CombinedOutput()
	return string(output), err == nil
}

func coverageRatio(output string) string {
	for _, s := range strings.Split(output, "\n") {
		if i := strings.Index(s, "coverage: "); i >= 0 {
			return strings.TrimSpace(s[i+len("coverage: "):])
		}
	}
	return "none"
}

// uncoveredBlockCount counts the blocks that never ran at all in the coverage profile.
func uncoveredBlockCount(profile []byte) int {
	n := 0
	for _, line := range strings.Split(string(profile), "\n")[1:] {
		fields := strings.Fields(line)
		if len(fields) == 3 && fields[2] == "0" {
			n++
		}
	}
	return n
}

const inventoryText = `package trial

type Item struct {
	Name     string
	Quantity int
	Price    int
}

func Label(it Item) string {
	if it.Quantity < 0 {
		return "invalid"
	}
	if it.Quantity == 0 {
		return "out of stock"
	}
	if it.Quantity < 3 {
		return "low stock"
	}
	return "sufficient"
}
`

const testTextT3 = `package trial

import "testing"

func TestLabel(t *testing.T) {
	if got := Label(Item{"pen", 5, 250}); got != "sufficient" {
		t.Errorf("got %s", got)
	}
	if got := Label(Item{"pen", 0, 250}); got != "out of stock" {
		t.Errorf("got %s", got)
	}
	if got := Label(Item{"pen", 2, 250}); got != "low stock" {
		t.Errorf("got %s", got)
	}
	Label(Item{"pen", -1, 250})
}
`
// measurement.go — three test sets, three separate coverage ratios and uncovered block counts
package main

import (
	"fmt"
	"os"
	"path/filepath"
)

const testTextT1 = `package trial

import "testing"

func TestLabelSufficient(t *testing.T) {
	if got := Label(Item{"pen", 5, 250}); got != "sufficient" {
		t.Errorf("got %s", got)
	}
}
`

const testTextT2 = `package trial

import "testing"

func TestLabel(t *testing.T) {
	if got := Label(Item{"pen", 5, 250}); got != "sufficient" {
		t.Errorf("got %s", got)
	}
	if got := Label(Item{"pen", 0, 250}); got != "out of stock" {
		t.Errorf("got %s", got)
	}
	if got := Label(Item{"pen", 2, 250}); got != "low stock" {
		t.Errorf("got %s", got)
	}
}
`

func main() {
	fmt.Println("-- run: three separate ratios with three test sets --")
	sets := []struct{ name, text string }{
		{"T1", testTextT1},
		{"T2", testTextT2},
		{"T3", testTextT3},
	}
	for _, set := range sets {
		dir := Setup(map[string]string{"inventory.go": inventoryText, "inventory_test.go": set.text})
		output, _ := Run(dir, nil, "test", "-coverprofile=coverage.out", "./...")
		profile, _ := os.ReadFile(filepath.Join(dir, "coverage.out"))
		fmt.Println(set.name, "coverage ratio:", coverageRatio(output),
			"uncovered block count:", uncoveredBlockCount(profile))
	}
}
-- run: three separate ratios with three test sets --
T1 coverage ratio: 57.1% of statements uncovered block count: 3
T2 coverage ratio: 85.7% of statements uncovered block count: 1
T3 coverage ratio: 100.0% of statements uncovered block count: 0

TM19. The case count could also be counted from the source in the previous two lessons, and it can be counted here too — T1 is one call, T2 three, T3 four. But the coverage ratio itself cannot be read from the source at all: while the same Label function stays unchanged, the ratio climbs from 57.1% to 100.0% only because which test set calls it changes. The profile file produced with the -coverprofile flag also gives which blocks this ratio comes from: uncoveredBlockCount reads each line of the profile and counts the ones whose last column is zero — this column is how many times that block ran during the run. In T1, three blocks never run (three return lines), in T3, none.

The coverage tool itself produces run information, because which block ran and which did not can only be known once the tests are actually run — anyone reading the source can see that four states exist, but cannot derive from the source alone which of these four states a given test set triggers. This is the last appearance, in this topic, of the distinction measured in Table-Driven Tests: one number (case count) sits in the source, another number (coverage ratio) only emerges by running it.

It is worth noting that all three sets are run with the same routines — Setup, Run; the only thing that changes is the content of the inventory_test.go file. Run is called this time with the -coverprofile flag, and this flag tells go test to write not just a ratio but the raw data behind that ratio to a file as well. -cover gave only the summary ratio (the form used in the previous lesson); -coverprofile records the same information block by block, and this lesson reads that detail with the uncoveredBlockCount routine. The profile file itself sits temporarily in this program’s working directory too, and its path is never printed anywhere — only the number derived from its content is read.

The Limiting Measurement: A Hundred Percent Coverage Does Not Show Correctness

T3 calls all four states, and its last call — Label(Item{"pen", -1, 250}) — never takes or checks the return value at all. In the coverage tool’s eyes, this line is a full test: Label was called, the relevant block ran, the ratio climbed to 100%. But whether this call produced a correct result was never checked anywhere.

It is worth stressing that this is not a typo — anyone reading T3’s source sees the fourth call, confirming that Label‘s fourth state was called. What is missing is that an if got := ...; got != "expected" pattern was never added to that call; this pattern was present through the third call, and was dropped for the fourth. The coverage tool does not notice this drop, because the question it asks is “did this line run,” not “was this line’s result bound to an assertion.“

This connects to the limit measured in Faking and Interface Separation: there it was shown that a fake does not carry all the real one’s assumptions; here it is shown that a test set’s own assertions do not cover all of its own branches — both from the same root: a test’s “passing” carries no guarantee about anything outside the scope of what it tested.

TM20. The measurement below makes this concrete: a broken version of Label — one that also labels a negative quantity as "out of stock", never producing the "invalid" state at all — is run with the same T3 test set.

// boundary.go — what T3, with full coverage, says about a broken source
package main

import (
	"fmt"
	"strings"
)

const brokenInventoryText = `package trial

type Item struct {
	Name     string
	Quantity int
	Price    int
}

func Label(it Item) string {
	if it.Quantity < 0 {
		return "out of stock"
	}
	if it.Quantity == 0 {
		return "out of stock"
	}
	if it.Quantity < 3 {
		return "low stock"
	}
	return "sufficient"
}
`

func main() {
	fmt.Println("-- limiting measurement: what T3, with full coverage, says about a broken source --")
	dir := Setup(map[string]string{"inventory.go": brokenInventoryText, "inventory_test.go": testTextT3})
	output, _ := Run(dir, nil, "test", "-cover", "-v", "./...")
	passed := strings.Contains(output, "--- PASS: TestLabel")
	fmt.Println("test passed:", passed, "coverage ratio:", coverageRatio(output))
}
-- limiting measurement: what T3, with full coverage, says about a broken source --
test passed: true coverage ratio: 100.0% of statements

TM21. In the broken version, a negative quantity now returns "out of stock" instead of "invalid" — a real defect, because these two states can carry different meanings elsewhere in the inventory package (an item that ran out of stock and an item whose stock count is broken are not the same thing). But T3 never catches this defect at all: the test still passes, coverage is still 100.0%. The reason is simple — T3’s last line calls Label but does nothing at all with the value it returns; the coverage tool sees that this line ran, it never asks whether the line that ran produced a correct value.

The coverage ratio of both runs (correct source, broken source) is also byte-for-byte the same: 100.0% of statements in both. The coverage tool sees no difference at all between the two sources, because the difference is not in whether a line ran, but in which value a line that did run produced. This is the same limit that applies to every piece of tool information in this topic, applied to coverage: a tool answers only the question it itself asks, and the question the coverage tool asks is always the same — did this line run — it never asks which value it produced.

This is the last form of the same limit repeated across the topic’s six lessons: in Test Package Basics a test that verified nothing still passed; here a single call that verifies nothing carries the coverage ratio of a test set whose other three assertions are sound all the way to 100%. The coverage tool counts blocks that ran, not blocks that were correctly tested — and these two numbers, as in the T3 example, can look identical while saying something different. A fully covering test set is a guarantee that every line of the source ran at least once; it is not a guarantee that every line of the source ran correctly.

This has a concrete production-side counterpart: as a codebase’s coverage ratio approaches 100%, the people adding to it tend to read that ratio as a sign of completeness. The measurement above shows this reading is incomplete — the ratio says the lines ran, it does not say the values those lines produced were verified. A codebase can misbehave even with a hundred percent coverage, because the coverage tool itself never asks whether an assertion exists at all — it only counts whether a line ran or not. This is the carryover, into coverage ratio, of the claim set up in Test Package Basics that a passing test does not say it is correct: a high coverage ratio does not state correctness either, it only states what was run.

This closing measurement shows that coverage itself is also an indicator, not a target: reaching 100% is easy, seeing which questions are still left unanswered while reaching it is hard. The coverage tool does not solve this difficulty, it only shows which lines were never run at all — the rest, whether an assertion actually checks the value that line produced, is left to the person who wrote it.

Summary

  • When the same Label function was tested with three test sets, coverage ratio took three different values (57.1%, 85.7%, 100.0%); case count could be counted from the source, coverage ratio could only be learned by running it.
  • The coverage profile also gives which block never ran at all; the uncovered block count dropped from 3 to 0 as the test set widened.
  • The limiting measurement: a test set that calls all four states but never checks its last call’s result still passed and still gave 100% coverage when run against a broken source.
  • The coverage tool counts lines that ran, not lines that were correctly tested — this distinction is the last form of the same limit repeated across the topic’s six lessons.
  • The fake dependency family, the table-driven test pattern, and coverage measurement were built in the Unit Testing and Test-Driven Development course; this lesson only measured Go’s own coverage tool.

Next Step

The same question was asked six times across this topic: a test’s verdict, a case list, an HTTP handler’s response, a fake dependency’s limit, which number of a benchmark is reproducible, and now what coverage ratio counts — all were classified with a source, tool, or run label, and each of the six lessons showed its own limiting measurement by running it: a passing test does not say it is correct, adding a case does not raise coverage, a test helper tests the handler and not the network, a fake implementation does not carry all the real one’s assumptions, allocation count cannot be compared without saying what it counts, and now coverage counts lines that ran, it does not say they were correctly tested. The next topic asks the same question of memory: a profile, too, just like coverage, sees the path that ran and states where memory was allocated — but it does not say why it was slow.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close