Skip to content
Talk to our solutions team

Coverage

Two questions a functional suite cannot answer about itself:

  • Which of the service’s routes did it call?
  • Which of the service’s statements did it execute?
Terminal window
kis test run -t tests/ --db results.db
# …stop the service cleanly…
kis test coverage --db results.db --routes ../orders-service --covdata ./covdata
2 route(s) nothing calls
DELETE /worksheets/:worksheet_id services/v2worksheet/router.go:43
GET /worksheets/:worksheet_id/files services/v2worksheet/router.go:49
1 route(s) only ever succeeded — no failure path tested
POST /worksheets 14 hits 201
least-covered files
41.2% 7/17 .../services/v2worksheet/create.go
routes 76.5% 13 of 17 exercised
statements 68.3% 4102 of 6006 executed

A suite can execute every line of a validation function without ever asserting that it rejects anything. A high percentage proves the code ran, not that the behaviour is right.

What these are good for is finding the gaps, the route nobody calls and the error branch nobody provokes, and feeding those into tests that make claims. The claims themselves belong in a ledger that maps tests onto documented behaviour, which is what a release gates on.

Read the report top-down: it says what is missing before it says how much.

Routes are extracted from the service’s Go source by parsing it, not by matching text. A regex breaks on a line wrap, a constant, or a commented-out call, and an extractor that silently misses routes reports coverage that is too high: the one direction this number must never be wrong in.

Recognised registrations:

FormExample
httputil.Handlehttputil.Handle(router, http.MethodGet, "/users/:id", h, name)
httprouter verbsrouter.GET("/users/:id", h)
net/httpmux.HandleFunc("/users", h), mux.HandleFunc("GET /users", h)
prefixrouter.PathPrefix("/api").Handler(h) → /api/*

A path built from a variable (prefix + "/users" where prefix is not a literal) is dropped, not half-read: recording it as the literal "/users" would leave a route that can never be matched and is reported as uncovered forever. A route registered some other way needs a JSON inventory instead:

Terminal window
kis test coverage --db results.db --routes routes.json
[{"method": "GET", "path": "/users/:id"}, {"method": "POST", "path": "/users"}]

A test hits /users/42; the inventory says /users/:id. Templates use httprouter syntax (:param, *catchall) and Go 1.22’s ({param}, {param...}). Host and query string are ignored: a route is a path.

Literal segments outrank parameters. With both /users/me and /users/:id registered, a request to /users/me matches the literal one. Matching the wildcard first would report /users/me as untested while it was the endpoint being called.

3 request path(s) matched no known route — the inventory may be incomplete

Surfaced rather than dropped, because a partial inventory makes coverage look higher than it is. Usually it means a route is registered in a form other than those in the table above.

Go instruments a binary, not a test, so this works for black-box tests over HTTP: the counters live in the service, and the suite drives it from outside.

Terminal window
go build -cover -coverpkg=./... -o svc-instrumented .
GOCOVERDIR=./covdata ./svc-instrumented &
kis test run -t tests/ --db results.db
kill -TERM $! # clean exit: see below
kis test coverage --db results.db --covdata ./covdata

Four things that bite:

  • The service must exit cleanly. Counters flush at exit. A kill -9, or a harness that hard-kills between suites, writes nothing, and an empty covdata directory looks exactly like a suite that covered nothing. The report says so rather than printing 0%.
  • -coverpkg decides what is instrumented. Without it you measure only the main package, not the libraries doing the work: pass -coverpkg=./... plus the libraries you care about.
  • Not during a load run. Instrumentation costs throughput and pollutes the latency you are measuring.
  • go tool covdata merge combines directories, so unit and functional coverage can be reported as one number.

--covdata also accepts a profile file, for when the service ran somewhere without a Go toolchain to convert it:

Terminal window
go tool covdata textfmt -i=covdata -o=coverage.txt # on the service's host
kis test coverage --db results.db --covdata coverage.txt

Both numbers are stored per run, so runs compare reports the direction:

COVERAGE BASE NEW DELTA
routes 100.0 50.0 -50.0 worse
statements 89.2 83.8 -5.4 worse
2 route(s) the base run exercised and this one did not
DELETE /worksheets/:worksheet_id
GET /worksheets/:worksheet_id/files

The named routes matter more than the percentage: a coverage number can hold steady while the set underneath it changes.

Tables: route_coverage (one row per route, with happy_path_only), code_coverage (per file), coverage_summary (the headline numbers). See Outputs.

Re-measuring a run replaces its rows rather than adding to them, so a corrected inventory or a second covdata directory produces one answer.

Terminal window
kis test coverage --db results.db --routes ../svc --covdata ./covdata \
--min-routes 80 --min-statements 60

Exits non-zero below either floor. Off by default: a gate nobody set is a gate that fails a build for a reason nobody chose. Treat them as floors that ratchet, not targets to hit.