Documentation
¶
Overview ¶
Package testreport turns the record of a `go test -json` run into a verdict about whether that run actually proved anything.
THE DISEASE ¶
A Go suite that declines to run its tests exits 0. Measured on the reference project (reliant), one package, one environment variable apart:
internal/threads WITHOUT DATABASE_URL: 9 pass / 124 skip → exit 0 internal/threads WITH DATABASE_URL: 103 pass / 1 skip → exit 0
Same exit code, same green check mark, 7% of the tests. Every dashboard forge has reads the left column as success. A missing env var, an absent service, or a `testing.Short()` guard can evaporate a whole package's coverage and nothing anywhere says so — the same shape as a doctor check that answers "fine" when it holds no facts.
vacuousguard is the STATIC half of this problem: it reads source and reports skips that can never fire and loops that can pass over nothing. This package is the RUNTIME half: it reads what a run actually did. Neither subsumes the other. A skip conditioned on `os.Getenv("DATABASE_URL")` is perfectly legitimate source — vacuousguard is right to leave it alone — and it is still the thing that erased 94% of a package on the machine that ran it.
WHY NOT JUST "ANY SKIP IS BAD" ¶
Skips are legitimate. That reliant package keeps one unconditional skip even when fully configured; other packages carry documented skips for framework limitations; `-short` exists precisely so a suite can decline work. A rule that fires on every skip is a rule that gets switched off, and a check nobody reads is worse than no check at all — it trains people to skim past the real failure sitting in the same output.
So the question this package asks is not "did anything skip". It is:
Did this package skip so much that its PASS means nothing?
Two rules answer it, both measured against the real distribution (112 tested packages of the reference project, run in the broken environment):
zero-evidence Every test in the package skipped. The package contributed
no evidence at all, so its "ok" is a statement about
nothing. Unambiguous at any size — no sample-size floor.
mass-skip The package skipped at least MaxSkipRatio of its tests
(default: more than half) and has at least MinTests tests.
The floor exists because 1-of-2 is noise, not signal.
On that 112-package corpus the pair reports FIVE packages, and the gap in the data is wide: the flagged packages sit at 1.00, 0.95, 0.80, 0.60 and 0.56 skip ratio, and the next package down is 0.20. The rules are drawing a line through a real valley, not through the middle of a crowd.
AND WHEN A HIGH RATIO IS GENUINELY FINE ¶
A package that is entirely integration tests behind a docker guard skips 100% on a laptop, forever, correctly. That is what Exemption is for: a declaration, committed to forge.yaml, naming the package and REQUIRING a written reason — the same shape as `forge project disown <path> --reason`. Declared once, reviewed in a diff, silent thereafter.
What this package deliberately does NOT offer is a RECORDED baseline ("expected 124 skips here, still 124, fine"). It was considered and rejected: the skip count of a database-backed package is a property of the MACHINE, not of the code. A baseline recorded on a laptop without DATABASE_URL enshrines the broken state as normal — the precise failure being fixed — and a baseline recorded in CI is wrong on every laptop. A number that is wrong on one of the two machines every time is a number people learn to re-record without reading. A human-written exemption with a reason survives both machines because it states an intent, not a count.
THREE STATES, NOT TWO ¶
Analyze can return UNDETERMINED, and undetermined is not a pass. Output that carries no `go test -json` events, or a stream that ends mid-run so a package's totals are partial, means forge could not obtain the facts. It says so — it does not report a clean bill of health over a file it failed to read. This mirrors `forge env status`, which prints "N UNDETERMINED (not a pass — forge could not obtain the facts)" for exactly this reason.
WHAT IT COSTS ¶
Nothing re-runs. The input is the JSON the suite already emitted, read from a pipe or a file, so the signal costs one extra `-json` flag on a run that was happening anyway.
Code generated by forge. DO NOT EDIT. forge:hash=84fa0ab7ad93625e170ef121f41112851541aae731401d615b52518185468537 forge-owned: regenerated every run — do not edit (forge project disown to take ownership) Source: contract.go in this package.
To customize: edit contract.go (the interface IS the public surface) and re-run "forge generate". This file is regenerated unconditionally.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ErrNoInput = errors.New("no input: expected a `go test -json` stream")
ErrNoInput is returned by Parse when the reader held nothing at all. Callers must not treat it as an empty-but-valid run: a zero-byte stream is the absence of facts, not the fact that nothing skipped.
Functions ¶
func Render ¶
Render writes the human-readable report.
The shape is deliberate: healthy packages are NEVER listed. The report's whole claim to being read is that its body is empty when nothing is wrong, so a non-empty body means something happened. A summary line always prints, because "how much of this suite actually ran" is the number that was missing in the first place.
Types ¶
type Analysis ¶
type Analysis struct {
Policy Policy
Totals Totals
Findings []Finding
Suppressed []Suppressed
// Stale names exemptions that matched packages in this run and
// suppressed nothing. Reported so a declaration cannot outlive its
// reason and quietly become an unfalsifiable rule.
Stale []Exemption
// Undetermined holds every fact forge could not obtain.
Undetermined []Unknown
Malformed int
}
Analysis is the finished verdict over a Run.
func (Analysis) Status ¶
Status is the verdict.
Order matters. A run forge could not read at all is UNDETERMINED before anything else, because every other answer would be a claim about content nobody saw. Concrete findings then outrank a partial-fact warning: they are defects that exist regardless of what the unreadable remainder holds.
type Deps ¶
type Deps struct{}
Deps is the dependency set for the testreport Service. Empty: the package is pure computation over the stream it is handed.
type Exemption ¶
type Exemption struct {
// Package matches the Go import path. Accepted spellings: the full
// import path, a trailing path fragment ("internal/threads" matches
// "github.com/acme/app/internal/threads"), or either with a "/..."
// suffix for the subtree.
Package string `yaml:"package"`
// Reason is why this package legitimately skips. Required.
Reason string `yaml:"reason"`
// MaxSkipRatio is the share this package may skip, in [0,1]. Zero
// means "fully exempt" (1.0) — a declaration with no number is a
// blanket one, which is the common case for an integration-only
// package.
MaxSkipRatio float64 `yaml:"max_skip_ratio,omitempty"`
}
Exemption declares that a package is EXPECTED to skip heavily, so its legitimate skips stop being noise.
Reason is required and unused by the logic on purpose: it exists to be read in a code review. An exemption nobody had to justify is an exemption nobody will revisit, which is how a suppression outlives the condition it was written for. Same contract as `forge project disown <path> --reason`.
type Finding ¶
type Finding struct {
Package string
Kind Kind
Tests int
Skipped int
Ratio float64
// Examples names a few of the skipped tests, shown only when no skip
// reason could be recovered.
Examples []string
// Reasons is the dominant t.Skip message(s) behind the skips, most
// common first. This is the actionable half of the finding: "124
// skipped" is a number, `118x "DATABASE_URL not set"` is an instruction.
Reasons []ReasonCount
// Detail carries the failure text for [KindFailed].
Detail string
}
Finding is one package's verdict.
type Kind ¶
type Kind string
Kind names a finding's rule. See the package doc for why there are exactly two and why neither is "this package skipped something".
const ( // KindZeroEvidence — every test in the package skipped. KindZeroEvidence Kind = "zero-evidence" // KindMassSkip — the package skipped at least the policy's share. KindMassSkip Kind = "mass-skip" // KindFailed — the package failed or did not build. Reported so that // piping a run into forge cannot LAUNDER a red suite green: in a // shell without `set -o pipefail`, `go test -json ./... | forge ...` // reports only the last command's exit status, so a checker that // ignored failures would swallow them. KindFailed Kind = "failed" )
The finding kinds, strongest first.
type MockService ¶
type MockService struct {
contractkit.Recorder
ParseFunc func(io.Reader) (*Run, error)
AnalyzeFunc func(*Run, Policy) Analysis
RenderFunc func(io.Writer, Analysis)
}
MockService is a test mock for the Service interface.
The embedded contractkit.Recorder records every call so tests can assert call counts and captured arguments. Set XxxFunc fields to override per-method behaviour; unset methods return the canonical "MockService.<Method>Func not set" error.
func (*MockService) Analyze ¶
func (m *MockService) Analyze(run *Run, policy Policy) Analysis
Analyze records the call and delegates to AnalyzeFunc when set.
type Outcome ¶
type Outcome string
Outcome is a test's terminal result. It mirrors the `Action` field of the `go test -json` events that end a test: "pass", "fail", "skip".
type Package ¶
type Package struct {
Path string
Tests int
Passed int
Failed int
Skipped int
// Skips names the skipped leaf tests, sorted, so a finding can show
// what actually went missing instead of only a number.
Skips []string
// SkipReasons counts the t.Skip messages behind those skips.
//
// A list of test NAMES says what went missing; the reason says what to
// DO about it. "124 tests skipped" is a number to argue with;
// `118x "DATABASE_URL not set"` is an instruction. cmd/go prints the
// message as an ordinary output line just before `--- SKIP:`, so it
// costs nothing to collect — and if the format ever changes the map
// comes back empty and the report falls back to names, because a
// verdict must never depend on scraping free text.
SkipReasons map[string]int
// Result is the package-level terminal action ("pass"/"fail"/"skip"),
// or "" when the stream ended before the package finished.
Result Outcome
// NoTestFiles is a package cmd/go skipped outright: it has no test
// files. Not a finding — nothing was lost, there was nothing there.
NoTestFiles bool
// BuildFailed is a package that never produced a test binary.
BuildFailed bool
// Incomplete is a package with test events but no terminal package
// event: the stream was truncated (killed run, `head`-ed log, crash).
// Its tallies are partial, so it is UNDETERMINED rather than counted.
Incomplete bool
}
Package holds one package's tally from a run.
Tests/Passed/Failed/Skipped count LEAF tests only — a test that ran no subtests of its own. A parent is a container, not a test, and counting it alongside its children lets a package inflate its pass count with containers while every leaf beneath them skipped. Concretely: a table test whose 20 cases all skip is 20 skipped leaves (ratio 1.00), not "1 pass and 20 skips" (ratio 0.95). The stricter reading is the honest one.
type Policy ¶
type Policy struct {
// MaxSkipRatio is the share of a package's tests that may skip before
// the package is reported. See [DefaultPolicy] for the default and
// the measurement behind it.
MaxSkipRatio float64
// MinTests is the sample-size floor for [KindMassSkip]. Below it a
// ratio is arithmetic, not evidence: 1-of-2 is 50% and means nothing.
// [KindZeroEvidence] deliberately ignores this floor — "none of them
// ran" is unambiguous at any size.
MinTests int
// Allow holds the declared exemptions.
Allow []Exemption
}
Policy is the rule set Analyze applies.
func DefaultPolicy ¶
func DefaultPolicy() Policy
DefaultPolicy is the shipped rule set.
MaxSkipRatio 0.5 is a line anyone can restate — "more than half of this package's tests did not run" — and it was checked against a real distribution rather than picked for roundness. Across the 112 tested packages of the reference project, run in the environment that triggers the mass skipping, the ratios above 0.5 are 1.00, 0.95, 0.80, 0.60 and 0.56; the next package down is 0.20. There is a wide empty band around the line, so nudging it does not change who gets reported — which is the property a threshold needs if it is not going to be argued about forever.
MinTests 5 removes the tiny-sample noise (one 2-test package sitting at exactly 0.50) without touching any real finding.
type ReasonCount ¶
ReasonCount is one t.Skip message and how many tests gave it.
type Run ¶
type Run struct {
// Packages, sorted by import path.
Packages []Package
// Events is the number of parsed JSON events. Zero means forge learned
// nothing from this input, which [Analyze] reports as UNDETERMINED.
Events int
// Malformed is the number of input lines that were not JSON objects.
// A few are tolerable (a toolchain banner ahead of the stream); all of
// them, with zero events, means the input was not `go test -json`.
Malformed int
}
Run is a whole `go test -json` stream, tallied.
func Parse ¶
Parse reads a `go test -json` stream and tallies it per package.
It is deliberately forgiving about lines it does not recognise and strict about the totals it reports. Unknown actions ("run", "output", "pause") are ignored; non-JSON lines are counted, not fatal. What it never does is invent a total: a package whose stream is truncated is marked Incomplete rather than reported with the partial counts it happens to hold.
Reading is line-oriented via bufio.Reader (not Scanner) because a single `output` event can carry a very long line — a scanner's token limit would turn one fat log line into a parse failure for the whole run.
type Service ¶
type Service interface {
// Parse reads a `go test -json` stream. It returns ErrNoInput when the
// reader held nothing at all.
Parse(r io.Reader) (*Run, error)
// Analyze applies a policy to a parsed run.
Analyze(run *Run, policy Policy) Analysis
// Render writes the human-readable report for an analysis.
Render(w io.Writer, a Analysis)
}
Service is the behavioural surface of the testreport package: the read → judge → report pipeline `forge ci verify-test-run` drives.
The package-level Parse / Analyze / Render functions are the implementation; this interface is the seam a caller holds when it wants to substitute the pipeline (for example, to test a command's exit-code mapping without constructing a real `go test -json` stream). Run, Analysis, Policy and the finding types are data carriers, not behaviour to mock.
type Status ¶
type Status string
Status is the verdict of an Analysis. Four states, because collapsing them loses the distinction the whole exercise is about: a run that proved its packages ran, a run that proved they did not, a run there was nothing to judge in, and a run forge could not read.
const ( // StatusPass — every package that had tests ran enough of them for // its result to mean something. StatusPass Status = "pass" // StatusFail — at least one package skipped its way to a meaningless // pass, or the run contains failures. StatusFail Status = "fail" // StatusNotApplicable — the run contains no tests to judge (a module // of packages with no test files). Nothing is missing. StatusNotApplicable Status = "not-applicable" // StatusUndetermined — forge could not obtain the facts. NOT a pass: // unreadable input and a truncated stream both land here, and both // must be louder than silence rather than quieter. StatusUndetermined Status = "undetermined" )
The four verdicts.
type Suppressed ¶
type Suppressed struct {
Package string
Kind Kind
Tests int
Skipped int
Ratio float64
Exemption Exemption
}
Suppressed records a finding an exemption absorbed, so the report can say how much silence was bought and by which declaration.
type Totals ¶
type Totals struct {
Packages int // packages that had at least one leaf test
NoTestFiles int // packages cmd/go skipped: no test files
NoTestsRan int // packages that compiled and ran zero tests (e.g. -run filtered)
Tests int
Passed int
Failed int
Skipped int
SkipRatio float64
SuiteFailed bool
FailedPackages int
}
Totals is the run-wide roll-up.