testreport

package
v1.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 29, 2026 License: Apache-2.0 Imports: 8 Imported by: 0

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=9d61c214c87f26f9bfa35364e0eeaad0d2f8348ac7f1c46a0ec001ec872a804b 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

View Source
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

func Render(w io.Writer, a Analysis)

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 Analyze

func Analyze(run *Run, policy Policy) Analysis

Analyze applies a policy to a parsed run.

func (Analysis) Status

func (a Analysis) Status() 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.

func (*MockService) Parse

func (m *MockService) Parse(r io.Reader) (*Run, error)

Parse records the call and delegates to ParseFunc when set.

func (*MockService) Render

func (m *MockService) Render(w io.Writer, a Analysis)

Render records the call and delegates to RenderFunc 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".

const (
	OutcomePass Outcome = "pass"
	OutcomeFail Outcome = "fail"
	OutcomeSkip Outcome = "skip"
)

The three terminal outcomes.

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.

func (Package) SkipRatio

func (p Package) SkipRatio() float64

SkipRatio is the share of this package's leaf tests that skipped, in [0,1]. A package with no tests has ratio 0 — there is no share of nothing.

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.

func (Policy) Validate

func (p Policy) Validate() []error

Validate reports the exemptions that are not usable, by index. It is separate from Analyze so a bad declaration surfaces as a configuration error rather than as a silently-ignored line — a suppression that does not parse must never read as a suppression that applied.

type ReasonCount

type ReasonCount struct {
	Reason string
	Count  int
}

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

func Parse(r io.Reader) (*Run, error)

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.

func New

func New(_ Deps) Service

New constructs a testreport.Service.

forge:no-observe Pure compute: empty Deps; reads the reader it is given and writes the writer it is given. No I/O boundary of its own to instrument.

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.

type Unknown

type Unknown struct {
	Package string // "" when the whole run is the problem
	Reason  string
}

Unknown is one reason forge could not obtain a fact it needed.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL