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?
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 executedNeither number is a target
Section titled “Neither number is a target”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.
Route coverage
Section titled “Route coverage”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:
| Form | Example |
|---|---|
httputil.Handle | httputil.Handle(router, http.MethodGet, "/users/:id", h, name) |
| httprouter verbs | router.GET("/users/:id", h) |
| net/http | mux.HandleFunc("/users", h), mux.HandleFunc("GET /users", h) |
| prefix | router.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:
kis test coverage --db results.db --routes routes.json[{"method": "GET", "path": "/users/:id"}, {"method": "POST", "path": "/users"}]Matching
Section titled “Matching”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.
Requests that match nothing
Section titled “Requests that match nothing” 3 request path(s) matched no known route — the inventory may be incompleteSurfaced 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.
Statement coverage
Section titled “Statement coverage”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.
go build -cover -coverpkg=./... -o svc-instrumented .GOCOVERDIR=./covdata ./svc-instrumented &
kis test run -t tests/ --db results.db
kill -TERM $! # clean exit: see belowkis test coverage --db results.db --covdata ./covdataFour 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%. -coverpkgdecides 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 mergecombines 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:
go tool covdata textfmt -i=covdata -o=coverage.txt # on the service's hostkis test coverage --db results.db --covdata coverage.txtTracking it
Section titled “Tracking it”Both numbers are stored per run, so runs compare reports the
direction:
COVERAGE BASE NEW DELTAroutes 100.0 50.0 -50.0 worsestatements 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/filesThe 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.
Gating a build
Section titled “Gating a build”kis test coverage --db results.db --routes ../svc --covdata ./covdata \ --min-routes 80 --min-statements 60Exits 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.