Coverage — Interview¶
A set of progressive interview questions on Go's coverage toolchain. Use them for self-review or as a rubric for screening. Each question lists the kind of role it usually appears in.
Junior (1–8)¶
1. What command produces a coverage percentage for a Go package? go test -cover ./.... The trailing percentage on each package line is the proportion of statements executed.
2. How do you produce a coverage profile file? go test -coverprofile=cover.out. The flag implies -cover.
3. How do you view a coverage profile in a browser? go tool cover -html=cover.out. It launches the default browser with source colored by coverage state.
4. How do you produce a per-function summary? go tool cover -func=cover.out prints function-by-function statement coverage.
5. What is the default -covermode? set — each statement is recorded as covered (1) or not (0), without counting executions.
6. What does the percentage in go test -cover measure? Statements (more precisely, statements inside executed blocks), not branches and not functions.
7. If a test panics, does the profile still write? -coverprofile writes only when tests pass. A panic in a test causes failure, so no profile. Run individual passing tests or use -failfast strategically.
8. Does adding -cover change observable program behavior? It rewrites source to add counter increments, so timing changes (slower). For set mode the increment is unconditional; for count/atomic it modifies a shared counter.
Middle (9–16)¶
9. What does -covermode=count add over set? It stores the actual number of executions per block, not just 0/1. Useful for finding hot or cold paths.
10. When must you use -covermode=atomic? When tests run in parallel and instrument the same code from multiple goroutines. Without atomic, counter writes race. -race automatically forces atomic.
11. What does -coverpkg do that -cover alone does not? -cover instruments only the package under test. -coverpkg=./... instruments all packages matching the pattern, so cross-package tests count toward dependency coverage.
12. Why might -coverpkg=./... lower a percentage you previously saw without it? Because previously only the directly tested package was in the denominator. Adding more packages adds more uncovered code unless those packages have their own tests too.
13. What is in a coverage profile line pkg/foo.go:10.13,12.2 3 0? A block from line 10 col 13 to line 12 col 2, containing 3 statements, executed 0 times.
14. Why does the compiler increment counters at blocks rather than statements? Because a basic block has a single entry and exit, so one counter at the entry is sufficient — incrementing per statement would be redundant.
15. Does Go measure branch coverage? No. Only statement (block) coverage. You cannot tell from a profile whether both branches of a short-circuit || were taken; only whether the bodies of if/else ran.
16. How do you exclude generated code from a coverage report? There is no first-class exclusion flag. Common approaches: post-process the profile by filtering files matching a pattern (e.g. _gen.go or .pb.go); or compute a custom percentage excluding those files when running go tool cover -func.
Senior (17–22)¶
17. Describe the Go 1.20 integration coverage workflow. Build with go build -cover -o app ./cmd/app. Run with GOCOVERDIR=cov ./app …. Convert binary data to text profile with go tool covdata textfmt -i=cov -o=cover.out, merge with unit-test profiles, render with go tool cover.
18. How do you merge coverage profiles from multiple test invocations? For text profiles, parse with golang.org/x/tools/cover.ParseProfiles and merge blocks summing counts. For GOCOVERDIR directories, use go tool covdata merge -i=a,b -o=merged.
19. How would you parse a cover.out programmatically to enforce a per-file minimum? Use golang.org/x/tools/cover.ParseProfiles. Walk profiles; for each profile compute coveredStatements / totalStatements. Fail CI if any file under a target directory is below threshold.
20. What are the runtime costs of set vs count vs atomic? set is a simple write; very cheap. count is a non-atomic increment. atomic is sync/atomic.AddUint32 and substantially slower under contention. On benchmark-heavy code, atomic mode can dominate.
21. Why is 100% coverage a misleading goal? Statement coverage is a weak property. You can have 100% statement coverage with zero assertions, no boundary tests, untested concurrent interleavings, and unverified error paths. Coverage is a necessary-but-not-sufficient signal.
22. How does go test -cover interact with -race? -race forces -covermode=atomic. This is intentional: instrumentation counters would otherwise be a benign data race detected by the race detector.
Staff (23–28)¶
23. You inherit a service with 30% coverage. What is your prioritization strategy? Do not chase a number. Identify the critical paths: request handlers, persistence, billing, auth. Use go tool cover -func plus business risk weighting to find high-risk-low-coverage zones. Add focused tests there. Track coverage as a delta to avoid Goodhart's law (rewarded for the number itself).
24. A team mandates 80% line coverage. What pathologies result? Engineers write tests that exercise getters and constructors. Critical error paths stay untested. PR reviewers reject good refactors because they reduce a percentage. Brittle tests proliferate. The metric improves while the system gets worse.
25. Describe a real integration coverage pipeline using Go 1.20. Build all server binaries with -cover. Deploy to a staging environment. Run a recorded production-traffic replay. Collect GOCOVERDIR from each pod. Merge centrally with covdata merge. Convert with textfmt and combine with unit-test profile to feed Codecov.
26. How would you use coverage data to detect dead code? Build with -cover -coverpkg=./..., run the full integration suite, parse the resulting profile, and report any function whose every block has count 0. False positives include init code, error branches, and platform-specific files — so the report is a candidate list, not a hard delete list.
27. Compare go test -cover to JaCoCo or Istanbul. Go's tool is source-rewriting at build time, statement-only, single-binary. JaCoCo (JVM) is bytecode-instrumentation, branch-coverage capable. Istanbul (JS) is source-transformation, function/branch/line/statement multi-metric. Go is intentionally minimal; richer metrics require third-party tooling such as gocover-cobertura and post-processing.
28. When would you intentionally disable coverage in CI? For PR pipelines where wall-clock latency matters and the unit tests run on every push: a -cover build can double runtime. Some teams compute coverage on a nightly job instead. Coverage of benchmarks (go test -bench) is rarely useful and almost always disabled.
Deep technical (29–40)¶
29. Describe what the cover rewriter does to a function body. It walks the AST, identifies basic blocks (maximal straight-line runs of statements with single entry/exit), and inserts a counter-increment statement at the start of each block. The counter is per-block, stored in a package-scoped array generated by the rewriter. At process exit, the array is serialized to the profile file.
30. Why does the cover rewriter operate at the block level, not the statement level? A basic block has a single entry, so one counter at the entry suffices to know whether every statement in the block ran. Per-statement counters would be redundant and would multiply the instrumentation cost.
31. What is the relationship between numStatements and counter values in a profile line? numStatements is the static count of statements in the block (set at compile time, never changes). The counter is the dynamic execution count (in count/atomic) or 0/1 (in set). The percentage formula is sum(numStatements where counter > 0) / sum(numStatements).
32. How does coverage interact with panic and recover? The counter at block entry is set as the block begins. If a statement within the block panics, the counter for that block stays 1 (it was set before the panic). Subsequent blocks the function would have executed are not entered, so their counters remain 0. A recover in a deferred function catches the panic but does not change earlier counter states.
33. Why might a refactor reduce coverage even without changing behavior? Several reasons: code moved between packages (different scope in -coverpkg), helper functions extracted into new packages with no direct tests, dead code removed (lowering both numerator and denominator but the ratio may drop), or new defensive code added (e.g., error wrappers) that the existing tests do not exercise.
34. Why does set mode not need atomics even under concurrent execution? Because all goroutines write the same constant value (1) to the same counter. The race is benign — the final value is always 1 regardless of which goroutine wrote last. The race detector still flags the access pattern, which is why -race forces atomic mode.
35. How does the -coverpkg flag affect compile time? Each matched package is compiled twice: once normally for dependencies of non-test code, and once instrumented for use under the test binary. On a 50-package module, -coverpkg=./... roughly triples cold compile time (instrument all, but cache reuses on warm runs).
36. What problem does Go 1.20 binary coverage solve that go test -cover did not? Coverage of code reachable only from main (CLI flag parsing, server bootstrap, signal handlers) and coverage from integration tests that run the compiled binary, not its test binary. Before Go 1.20, main could only be tested via fragile harness code and integration coverage was not measurable at all.
37. Walk through the lifecycle of GOCOVERDIR data in a single binary run. On startup, the runtime checks GOCOVERDIR. If set, it writes a metadata file (covmeta.HASH) describing instrumented packages, if not already present. Each block execution increments an in-memory counter (atomic if compiled atomic-mode, plain otherwise). At graceful exit (return from main, os.Exit, runtime/debug.Goexit), a runtime hook writes the counter array to covcounters.HASH.PID.NANOS. A kill -9 skips this hook and loses the data.
38. How would you measure code reachable only by an HTTP error handler that returns 500 on database failure? Inject a fault: write a test that uses a database driver that returns errors, or use a build tag for a "fault-injecting" mock. Then run the integration test; the 500 handler's block counter rises. Without fault injection, the path is structurally unreachable from tests.
39. Compare coverage's view of Public.Method() versus (*internal).private(). Both are instrumented identically. Public methods are recorded just like private; the access modifier is irrelevant to coverage. The difference is that external test packages (pkg_test) cannot call private methods, so private methods are only covered via the public methods that delegate to them. If you want explicit coverage of private logic, write internal (same-package) tests.
40. What does it mean for a profile line to have count=0 but numStatements=5? A block of 5 statements was never executed during the test run. The 5 statements account for 5 in the denominator and 0 in the numerator. The block could be an error handler, a rare branch, or unreachable code.
Practical (41–48)¶
41. You inherit a profile from a CI artifact. How do you quickly see the worst-covered files? go tool cover -func=cover.out | sort -k 3 -n (sort by the third column, the percentage). The first few lines are the worst.
42. Your coverage dropped 5% in a refactor PR. What three checks do you run? First: look at per-file deltas (use Codecov or a custom parser) to see which files lost coverage. Second: check whether -coverpkg semantics changed (e.g., a package moved out of the matched pattern). Third: walk through the diff and identify newly added uncovered code (often error handling).
43. You want to identify all functions that have 0% coverage across both unit and integration tests. How? Run both, merge their profiles, then write a small program that parses the merged profile, walks the AST of every Go file, maps each function to its blocks, and reports functions where every block has count 0. Output a candidate list for manual review.
44. Your team wants 80% coverage on every PR. What pathologies should you anticipate? Fake coverage (tests that exercise code without asserting), trivial test files (testing getters and setters), generated code padding the denominator, brittle tests that break on legitimate refactors, time spent chasing the number instead of fixing real bugs.
45. How do you configure Codecov to ignore generated files? In codecov.yml, add an ignore list under the root: ignore: ["**/*.pb.go", "**/mock_*.go", "**/*_gen.go"]. Codecov drops matching files from both numerator and denominator.
46. You see mode: atomic in a profile produced by go test -covermode=count. What happened? The test was also run with -race, which forced -covermode=atomic regardless of the explicit flag. The promotion is documented and intentional.
47. Your service has main.go showing 0% coverage forever. What's the best fix? Build the service with go build -cover -o app ./cmd/svc and run integration tests against the resulting binary with GOCOVERDIR=cov ./app .... Convert with go tool covdata textfmt -i=cov -o=integration.out and merge with unit-test coverage. main will now show coverage from the integration runs.
48. A new engineer says "coverage is fake — let's not measure it". How do you respond? Acknowledge the legitimate critique: a coverage target is fake. But coverage as a diagnostic is genuinely useful — it focuses attention on untested code. Propose a middle ground: keep measuring coverage, report deltas on PRs, but do not block merges on absolute numbers. Couple with assertion-quality review and (when ready) mutation testing.
Architecture (49–52)¶
49. Design a coverage pipeline for a Go monorepo with 50 packages owned by 5 teams. Per-team CI runs go test -coverprofile=team-X.out ./teams/X/... and uploads with a team-X flag. Shared internal/core is covered cross-team via -coverpkg=./internal/core/... ./.... A nightly job merges all team profiles for a global view. Per-directory floors are owned by each team and enforced by Codecov status checks. New engineers see per-team coverage trends in their team dashboard.
50. How would you integrate coverage with a deployment pipeline that requires zero downtime? Coverage data does not directly affect deployments. But you might want a gate: deployments to production require that the candidate commit's coverage is not lower than the current production's. Implement as a check in the deploy job: download the production coverage from the artifact store, compare with the candidate's, fail if regression exceeds threshold.
51. What is the right way to gate a PR on coverage? A combination: (1) per-file delta — coverage of touched files must not regress, (2) per-directory floor — critical packages must stay above a threshold, (3) overall trend — the module-wide total is reported but not blocked. Block on (1) and (2), warn on (3).
52. Describe a coverage strategy that resists Goodhart's law. Track coverage as a delta signal on PRs, not an absolute KPI. Pair with mutation testing (or assertion-density linters) to catch fake coverage. Couple with bug-escape metrics; if coverage rises but bugs do not fall, the metric is decoupled from reality. Make coverage a team concern, not an individual one. Periodically audit fake coverage in code review and push back culturally.
Concept (53–55)¶
53. Is coverage a leading or lagging indicator of code quality? Leading: coverage trends predict bug-escape rates. Teams with rising coverage usually have improving quality. But the correlation is weak; coverage is one of many signals.
54. Is "100% coverage" achievable in practice? Often yes for libraries, rarely for services. Defensive code (panic guards, init blocks, platform-specific files) resists testing. Most teams plateau at 85-90% for services. Pursuing the last 10% costs more than it returns.
55. If coverage is so flawed, why does the Go toolchain include it? Because it is a cheap, language-native, ubiquitous tool. The cost of providing it is one compiler pass and one CLI tool; the benefit is widespread test discipline. Even a flawed metric is better than no metric, as long as it is wielded with judgment. Go's coverage is intentionally minimal — the team avoided richer metrics (branch coverage, MC/DC) on the grounds that they add complexity without proportional value. The minimality is itself a design statement.
Followups (56–60)¶
56. Walk through what happens when go test -race -cover is run. The test tool compiles the test binary with both race-detector instrumentation and coverage instrumentation. Coverage mode is forced to atomic (because the race detector would flag non-atomic counter writes). The binary runs; each block-entry executes an atomic.AddUint32 on the corresponding counter. The race detector tracks memory accesses on all other data. On exit, the coverage profile and the race report are produced.
57. How do you tell if a profile is from a parallel test run? The profile itself does not encode this directly. The mode line will say atomic if -race was on or if the user explicitly set atomic. If the mode is count and you see oddly-low counter values for a block that should have run hundreds of times, lost increments are a likely cause.
58. How would you implement "all packages must have ≥1% coverage" gate? Parse the profile with cover.ParseProfiles. For each profile (one per file), compute the percentage. Group by package directory. For each directory, take the maximum (or any non-zero) file percentage. If any directory has zero across all its files, fail. This catches packages with no tests at all without setting an artificially high floor.
59. What is the difference between coverage of cmd/foo/main.go before and after Go 1.20? Before Go 1.20: 0%, because go test -cover does not exercise main. After Go 1.20: can be measured via go build -cover + GOCOVERDIR; the integration runs of the binary contribute coverage to main and its helpers.
60. Describe how you would build a coverage-aware refactoring tool. Parse the profile. Identify blocks with count=0. Map blocks back to AST nodes. For each uncovered block in a function whose other blocks are covered, propose refactoring patterns that might make the block reachable (e.g., extract dependency to argument, introduce interface). This is essentially what mutation testing tools and tools like dead provide as candidate lists. Coverage is the input; the refactoring suggestions are the output.
Bonus (61–62)¶
61. Coverage of a function that calls panic unconditionally? The function entry block (containing the panic call) is covered if the test reaches it. The block after the panic does not exist in the AST (panic is terminal), so no further blocks count. If the panic is wrapped in a guard (if cond { panic(...) }), the guard's body is its own block, covered only when the condition fires.
62. Coverage of a function with if false { ... }? The unreachable body block has numStatements > 0 but count is always 0. It pulls down the function's coverage percentage permanently. Linters often flag if false as dead code; coverage tooling does not — it just reports the gap.
More scenarios (63–70)¶
63. How would you handle the coverage of an HTTP client retry loop? The retry loop has three branches typically: success, retryable error, fatal error. To cover all three, mock the HTTP transport. Inject a RoundTripper that returns: (a) success on first try, (b) error then success, (c) error then error then error. Three tests, full coverage.
64. A package's coverage went from 90% to 50% overnight. What happened? Possibilities: someone added a large generated file (e.g., *.pb.go) that is uncovered; someone refactored test files and broke them; someone added a build tag that excluded test code; the -coverpkg config was changed to include more packages. Inspect the per-function output and the file list to diagnose.
65. How do you cover a select statement's timeout case? The timeout case typically uses time.After. To cover it: either inject a controllable clock (replace time.After with a function-typed field that defaults to it but can be stubbed), or use a very short timeout (1ms) and accept some test flakiness. The clock injection is cleaner.
66. Walk me through what happens if a test runs an os.Exit(0). os.Exit bypasses deferred functions, including the runtime hook that writes the coverage profile. The profile may not be written. To avoid this, return from main instead of calling os.Exit, or call runtime/debug.SetTraceback and use Goexit in extreme cases. The Go testing framework forbids tests from calling os.Exit precisely because it interferes with cleanup including coverage.
67. How is coverage computed for a package containing only init functions and no other Go code? init functions are instrumented like any function. If the package is loaded by any test (which loads its dependencies), the init runs and its blocks are covered. The percentage reflects what fraction of init blocks ran.
68. You see a profile with the line pkg/foo.go:0.0,0.0 0 0. What does this mean? Likely a parsing artifact: a block with no real source position. May indicate a tool bug or a corrupted profile. Inspect the surrounding lines; if the profile parses cleanly otherwise, the line can be ignored.
69. How would you set up coverage for a Go program that runs as a long-lived daemon? Build with go build -cover. Run with GOCOVERDIR=cov. Ensure graceful shutdown: handle SIGTERM, finish in-flight work, return from main. The runtime writes coverage data on graceful exit. For a daemon that runs for hours, this captures all the code paths exercised during the run.
70. What is go tool covdata pkglist useful for? Listing all instrumented packages in a coverage data set. Useful for sanity-checking -coverpkg patterns ("did my pattern actually match the packages I expected?") and for filtering reports by package set.
More design (71–75)¶
71. How would you implement a coverage-aware test ranker? Run each test individually with -coverprofile. Compute the marginal coverage added by each test. Rank tests by their unique contribution. Use the ranking to identify high-value tests (cover many unique blocks) and redundant tests (cover blocks already covered by others). This is a useful tool for slim-fitting a test suite.
72. Coverage in continuous deployment (CD): should it gate releases? Generally, no. Coverage should be measured but not block releases. Blocking releases on coverage creates incentive misalignment: when an emergency hotfix must ship, coverage data is the wrong reason to delay it. Use coverage as a release-quality signal in dashboards, not as a deployment gate.
73. Describe a scenario where you would deliberately accept a coverage drop. Several: deleting dead code (legitimate drop in numerator and denominator with ratio dropping); adding defensive code that is intentionally hard to reach (panic guards, init fallbacks); migrating to a new framework where temporary uncovered code exists during transition; deprecating a feature that is being removed in the next release.
74. How do you balance coverage with test runtime? Identify your CI's wall-clock budget. Allocate roughly 20-30% of it to coverage overhead. Beyond that, optimize: narrow -coverpkg, use set mode by default, skip coverage on benchmarks. If you cannot fit coverage in the PR pipeline, move it to nightly and use the PR pipeline for fast no-cover feedback.
75. What is the single most common coverage mistake junior engineers make? Writing tests to lift coverage without assertions. The pattern is: "here's a function with 0% coverage. Let me call it and the percentage goes up." The function then runs in the test but its behavior is not verified. Coverage tools cannot detect this; only code review can. Teach junior engineers to ask "what does this test verify?" before "what does it cover?".