This article will cover how to get visibility of your test coverage in Go. If you’re not comfortable with the basics of testing in Go, please checkout my earlier article aimed at beginners.

Test coverage

Test coverage gives you a high-level overview you how much of your production code was invoked in your tests (e.g. as a percentage), but it can also give you a lower-level view of exactly which lines are invoked or missed. Before we dive deeper into the ‘how’, let’s make sure we understand why we care about test coverage. Test coverage is a terrible metric for evaluating the quality of your tests. Repeat after me:

High test coverage does not imply high-quality tests.

So what’s the purpose of test coverage then?

In my opinion, test coverage serves two purposes:

Firstly, it lets you quickly identify which lines have not been hit by tests. This could indicate that your tests aren’t comprehensive enough: “Oops, this if statement wasn’t tested, but it should have been. Let’s add another test”. However, it could also identify impossible paths in your code: “We’ve already validated this value elsewhere so this can never happen”.

Moreover, paired with a good Continuous Integration setup, you can identify if a change will remove tests prematurely, by warning you when the coverage of your pull request is lower than the base branch.

How to report test coverage in Go

The go tool supplies us with all the tools we need; no third-party solution required:

# Write a "coverprofile" to a filed called coverage.out
go test -coverprofile=coverage.out

# Invoke the 'cover' go tool, and ask it to render the coverprofile file as HTML.
# This should open your default web browser.
go tool cover -html=coverage.out

This shows you exactly how much of each production file was covered, and renders your source code in green if the particular line was covered by the tests, and red otherwise. Type definitions, comments, and other “non-runnable” code are greyed out, as it doesn’t make sense to count these lines as part of our coverage.

Screenshot of coverage rendered as HTML

You can also see a report of your coverage report on a per-function basis:

# Running coverage against github.com/kinbiko/bugsnag and requesting
# per-function output
$ go test -coverprofile=coverage.out github.com/kinbiko/bugsnag && go tool cover -func=coverage.out
ok      github.com/kinbiko/bugsnag    0.159s    coverage: 87.5% of statements
github.com/kinbiko/bugsnag/configuration.go:67:  validURL                  100.0%
github.com/kinbiko/bugsnag/configuration.go:75:  populateDefaults          100.0%
github.com/kinbiko/bugsnag/configuration.go:92:  validate                  100.0%
github.com/kinbiko/bugsnag/configuration.go:124: makeRuntimeConstants      54.5%
github.com/kinbiko/bugsnag/context.go:21:        Serialize                 60.0%
github.com/kinbiko/bugsnag/context.go:39:        Deserialize               55.6%
github.com/kinbiko/bugsnag/context.go:86:        val                       100.0%
github.com/kinbiko/bugsnag/context.go:127:       WithBreadcrumb            100.0%
github.com/kinbiko/bugsnag/context.go:143:       makeBreadcrumbs           100.0%
github.com/kinbiko/bugsnag/context.go:180:       WithUser                  100.0%
github.com/kinbiko/bugsnag/context.go:196:       WithBugsnagContext        100.0%
github.com/kinbiko/bugsnag/context.go:213:       WithMetadatum             100.0%
github.com/kinbiko/bugsnag/context.go:226:       WithMetadata              100.0%
github.com/kinbiko/bugsnag/context.go:243:       Metadata                  100.0%
github.com/kinbiko/bugsnag/context.go:251:       initializeMetadataTab     100.0%
github.com/kinbiko/bugsnag/context.go:301:       updateFromCtx             38.9%
github.com/kinbiko/bugsnag/context.go:332:       getAttachedContextData    100.0%
github.com/kinbiko/bugsnag/error.go:32:          Error                     100.0%
github.com/kinbiko/bugsnag/error.go:47:          Unwrap                    66.7%
github.com/kinbiko/bugsnag/error.go:61:          Wrap                      100.0%
github.com/kinbiko/bugsnag/error.go:70:          Wrap                      100.0%
github.com/kinbiko/bugsnag/error.go:84:          makeStacktrace            100.0%
github.com/kinbiko/bugsnag/error.go:120:         makeModulePath            66.7%
github.com/kinbiko/bugsnag/notifier.go:44:       New                       85.7%
github.com/kinbiko/bugsnag/notifier.go:72:       Close                     100.0%
github.com/kinbiko/bugsnag/notifier.go:93:       Notify                    75.0%
github.com/kinbiko/bugsnag/notifier.go:129:      loop                      100.0%
github.com/kinbiko/bugsnag/notifier.go:149:      shutdown                  70.0%
github.com/kinbiko/bugsnag/notifier.go:172:      makeReport                100.0%
github.com/kinbiko/bugsnag/notifier.go:197:      sendErrorReport           85.7%
github.com/kinbiko/bugsnag/notifier.go:221:      makeUnhandled             100.0%
github.com/kinbiko/bugsnag/notifier.go:234:      makeSeverity              83.3%
github.com/kinbiko/bugsnag/notifier.go:246:      severityReasonType        100.0%
github.com/kinbiko/bugsnag/notifier.go:265:      makeExceptions            82.4%
github.com/kinbiko/bugsnag/notifier.go:297:      makeJSONApp               100.0%
github.com/kinbiko/bugsnag/notifier.go:306:      makeJSONDevice            75.0%
github.com/kinbiko/bugsnag/notifier.go:321:      memStats                  84.6%
github.com/kinbiko/bugsnag/notifier.go:345:      osVersion                 66.7%
github.com/kinbiko/bugsnag/notifier.go:352:      makeNotifier              100.0%
github.com/kinbiko/bugsnag/notifier.go:360:      extractLowestBugsnagError 100.0%
github.com/kinbiko/bugsnag/notifier.go:374:      guard                     100.0%
github.com/kinbiko/bugsnag/session.go:30:        StartSession              100.0%
github.com/kinbiko/bugsnag/session.go:45:        flushSessions             100.0%
github.com/kinbiko/bugsnag/session.go:57:        publishSessions           82.4%
github.com/kinbiko/bugsnag/session.go:102:       makeJSONSession           100.0%
github.com/kinbiko/bugsnag/session.go:116:       makeJSONSessionReport     100.0%
github.com/kinbiko/bugsnag/session.go:135:       uuidv4                    100.0%
total:                                           (statements)              87.5%

A note on covermode and concurrent programs

go test has a parameter called covermode which tweaks how the Go tool should think about “covering a line”. Basically your options are “lines are either missed or hit” or “let’s count how many times a line is hit”. The former is used by default, and is the least expensive. You can get more of a ‘heatmap’ feature (in the rendered HTML) by setting the -covermode=count, however I recommend taking things a step further to avoid headaches. Namely, use -covermode=atomic to ensure that the lines hit are counted accurately even in concurrent programs.

$ go test -coverprofile=coverage.out -covermode=atomic

This is significantly more expensive, but for most project the additional compute resource is surely negligible.