Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ jobs:
packages: "./... ./examples/..."
race: true

bench-smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26"
- name: Bench (examples, single iteration)
uses: ./
with:
packages: "./examples/..."
bench: "true"
flags: "-benchtime=1x"

test:
runs-on: ubuntu-latest
strategy:
Expand Down
91 changes: 91 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,40 @@ No `TestMain` is generated — both `package foo` and `package foo_test`
can define fixture-bound suites without conflict. Teardown runs via
`t.Cleanup` (reverse-wavefront: leaves first, roots last).

For suites with `Benchmark*` methods, one `Benchmark<Suite>` wrapper is
generated (standalone or fixture-bound, same shape as above), with one
`b.Run` per benchmark method and the timer fenced around each:

```go
func BenchmarkFooTestSuite(b *testing.B) {
ƒ_setupFixtures(b) // only if fixture-bound
s := &ƒƒ_GOTEST_FooTestSuite{...}
b.Cleanup(func() { s.AfterAll(lifecycleT) })
s.BeforeAll(lifecycleT)

b.Run("BenchmarkParse", func(b *testing.B) {
b.StopTimer()
s.BeforeEach(eachT) // outside timing
b.StartTimer()
b.ResetTimer()
s.BenchmarkParse(gotest.NewB(b)) // user's b.Loop() bounds measurement
b.StopTimer()
s.AfterEach(eachT) // outside timing
})
}
```

A suite must be named `*TestSuite` for its `Benchmark*` methods to be
collected at all — Pass 1 discovery matches on that suffix regardless of
whether the struct has `Test*` methods. A bench-only struct without the
suffix is invisible to the collector; its methods are silently dropped.
`ValidateContextConsistency` (Pass 4) additionally rejects a suite that
mixes `Benchmark*` methods with a returning `BeforeEach` (its context type
can't thread through `*gotest.B`) or with any stdlib `*testing.T` lifecycle
hook. The resolver rejects a fixture with `BeforeEach`/`AfterEach` bound to
a suite with `Benchmark*` methods — per-method fixture hooks aren't
supported for benchmarks.

The overlay filesystem (`-overlay=path/overlay.json`) injects generated files
without modifying source. Go's compiler reads virtual paths from the overlay.

Expand Down Expand Up @@ -425,6 +459,23 @@ The system has **four levels of parallelism**, each with distinct mechanisms:
└─────────────────────────────────────────────────────────────────────┘
```

### Bench mode: serial, non-streaming

`gotest bench` runs the same pipeline with `PipelineConfig.Bench: true`, and
that flag changes two things:

- `resolveMaxParallel` short-circuits to `maxParallel = 1` — Level 3 above
collapses to one suite subprocess at a time, `--parallel`/`-test.parallel`
are ignored for scheduling. Running benchmarks concurrently would make
their timing numbers meaningless.
- `Streaming: false` — compilation and execution are not overlapped for
bench runs; `runBatch` compiles every package first, then runs. `go test -c`
never competes with a running benchmark for CPU.

Process-per-suite isolation (Level 3's existing design) is also a
methodological benefit for benchmarks: GC pressure from one benchmark
cannot pollute another's numbers, without any extra bench-specific code.

### Streaming Execution (Compile-Execute Overlap)

`RunPipeline` with `Streaming: true` is the primary execution path. It overlaps
Expand Down Expand Up @@ -644,6 +695,46 @@ Or without test2json:
<binary> -test.run=^TestFooTestSuite$ [flags]
```

### Bench Target Construction

`BuildBenchTargets` is the bench-mode sibling of `BuildSuiteTargets` — it
walks `overlay.BenchesByPkg` (suites with `Benchmark*` methods) instead of
`overlay.SuitesByPkg`, and filters on two independent regexes matched
against the same `Benchmark<SuiteName>` function name:

```
BuildBenchTargets(compiled, benchesByPkg, dirsByPkg, runFlags, userRunFilter, userBenchFilter)
for each package:
for each bench-suite name (e.g., "FooTestSuite"):
├─ benchFuncName = "Benchmark" + "FooTestSuite" = "BenchmarkFooTestSuite"
├─ userRunFilter set (from -run) → must match benchFuncName, else skip
├─ userBenchFilter set (from -bench) → must match benchFuncName, else skip
│ (both apply — AND semantics; a -run Test<Suite> value matches nothing
│ here since the wrapper is named Benchmark<Suite>, not Test<Suite>)
└─ SuiteTarget{ ..., Bench: true }
```

A user-supplied `-bench` value is extracted out of `RunFlags` by
`ExtractBenchFilter`/`StripBenchFilter` before target construction — it must
not reach `buildSuiteCmd` as a raw `-test.bench` flag, since that would be
appended after the generated one and silently win, defeating per-suite
scoping. `buildSuiteCmd` builds the actual test binary invocation for a
bench target as:

```
<binary> -test.run=^$ -test.bench=^BenchmarkFooTestSuite$ -test.benchmem [flags]
```

`-test.run=^$` disables ordinary tests for the run; `-test.benchmem` is
appended unless already present in the forwarded flags.
`resolveMaxParallel` returns `1` whenever `PipelineConfig.Bench` is set, so
`RunSuites` dispatches these targets one at a time regardless of
`--parallel`.

---

## 8. Full Timeline (Happy Path)
Expand Down
99 changes: 98 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,73 @@ Or when running `go test` directly:
GOTEST_UPDATE_SNAPSHOTS=1 go test ./...
```

## Benchmarking

Benchmark methods live on the same suites as tests — same struct, same lifecycle, own subcommand.

### Authoring

```go
type ParserTestSuite struct {
p *Parser
}

func (s *ParserTestSuite) BeforeEach(t *gotest.T) { s.p = NewParser() }

func (s *ParserTestSuite) BenchmarkParse(b *gotest.B) {
doc := loadTestdata("large.json")
for b.Loop() {
s.p.Parse(doc)
}
}
```

`Benchmark*` methods take `*gotest.B` (or `*testing.B`) and honor the same `F_`/`X_` focus/exclude prefixes as test methods.
`BeforeEach`/`AfterEach` run once per benchmark method, outside the timing window — the generated wrapper stops the timer before `BeforeEach`, starts and resets it right before your method runs, and stops it again before `AfterEach`.
Use `b.Loop()` (Go 1.24+) rather than `b.N` — it excludes setup before the loop from timing automatically.

The suite must be named `*TestSuite`, even if it only has benchmarks — a struct without that suffix is never discovered, so its `Benchmark*` methods are silently dropped.
Benchmarks can't coexist with a returning `BeforeEach` (its context type can't thread through `*gotest.B`), and every lifecycle hook on a benchmark suite must take `*gotest.T`, not `*testing.T`.
Fixture-bound suites work — fixtures hydrate before benchmarks run — but a fixture with `BeforeEach`/`AfterEach` bound to a suite that has `Benchmark*` methods is rejected at generation time: per-method fixture hooks aren't supported for benchmarks.

### Running

```bash
gotest bench ./... # all benchmarks
gotest bench ./pkg/parser -run Parse # filter by suite name
gotest bench ./pkg/parser -bench Parse # filter by benchmark name
```

`gotest ./...` never runs benchmarks — use `gotest bench` explicitly.

Benchmark suites always run serially, one suite process at a time, regardless of `--parallel`/`-test.parallel`.
Running benchmarks concurrently makes their timing numbers meaningless — the runner disables streaming and compiles everything up front so `go test -c` never competes with a running benchmark for CPU.
A fresh process per suite is also a methodological win, not just an implementation detail: GC pressure from one benchmark can't pollute another's numbers.

`-test.benchmem` is on by default — every benchmark line reports `B/op`/`allocs/op` alongside `ns/op`.
`-benchtime` and `-count` are forwarded to `go test` unchanged.
`-coverprofile` works; `--min` (coverage gate) isn't available in bench mode.
`-run` and `-bench` both filter which suites run, both matched against each suite's `Benchmark<SuiteName>` wrapper function — `-run` the same way it scopes suites for `gotest ./...`, `-bench` by benchmark function name.
Given both, a suite must match both to run (AND semantics).
`-run Test<Suite>`-style values match nothing in bench mode — filter by the suite name itself, not the `Test` prefix.

With no matching benchmarks, `gotest bench` prints `no benchmarks found` and exits 0.

### Spec view

```bash
gotest bench --spec ./examples/notification -benchtime=10x
```

```
BenchmarkNotificationDispatchBench
✓ Dispatch 810.6 ns/op · 596 B/op · 2 allocs/op

1 suites, 1 benchmarks:
```

Each line reports `ns/op`, `B/op`, and `allocs/op` — the same numbers `go test -bench` prints, rendered as a spec.

## Configuration

Every fixture and suite runs with sensible defaults — 2-minute fixture timeout, 30-second per-test timeout.
Expand Down Expand Up @@ -742,12 +809,42 @@ Use the official action for CI pipelines with failure summaries, inline PR annot

By default (`version: gomod`), the action resolves `gotest` from your `go.mod` — no version drift between CI and local development. Set `version: latest` or a specific tag to install a standalone binary instead.

The action emits `::error` annotations that appear inline on PR diffs and writes a markdown summary to the GitHub step summary panel. See the [reference](https://mvrahden.github.io/go-test/reference/#ci-integration) for the full inputs/outputs table.
The action emits `::error` annotations that appear inline on PR diffs and writes a markdown summary to the GitHub step summary panel.

With `bench: true`, a benchmark step runs after the tests (`gotest bench --spec --json`): the spec view plus delta table land in the step summary, the versioned JSON report lands in a temp file exposed as the `bench-report` output, and a breached gate fails the step with the offending keys in `bench-breached-keys`.

### Inputs

The tables below are the canonical action surface — a drift guard test keeps them in sync with `action.yml`.

| Input | Description |
|---|---|
| `packages` | Package patterns to test (default `./...`) |
| `race` | Enable the race detector (default `false`) |
| `coverage` | Enable coverage profiling and reporting (default `false`) |
| `min-coverage` | Minimum coverage percentage (0-100, fails if below) |
| `flags` | Additional gotest flags (`--double-dash` style; also forwarded to the bench step) |
| `go-test-flags` | Additional go test flags (`-single-dash` style) |
| `bench` | Run benchmarks after tests via `gotest bench --spec --json` (default `false`) |
| `bench-baseline` | Baseline JSON file to compare benchmarks against (`--against`) |
| `bench-gate` | Fail if any benchmark regresses by more than this percent (`--gate`) |
| `bench-save` | Save the run as a JSON baseline at this path (`--save`); an explicit empty string saves to `bench.baseline` from `.gotest.yml`; default `false` saves nothing |
| `version` | `gomod` (default) resolves from go.mod; a tag (e.g. `v1.0.0`, `latest`) installs globally |

### Outputs

| Output | Description |
|---|---|
| `exit-code` | Test process exit code |
| `coverage` | Coverage percentage (empty if coverage not enabled) |
| `bench-report` | Path to the `gotest bench --json` report file (empty if bench not enabled) |
| `bench-breached-keys` | Comma-joined benchmark keys that breached the gate (empty if none or no gate) |

## Commands

```bash
gotest ./... -v -race # generate overlays and run tests (default)
gotest bench ./... # run BenchmarkX suite methods, serially
gotest spec ./... # behavioral specification view
gotest summary ./... # failure-focused summary for CI
gotest watch ./... -v # watch mode with auto-rerun
Expand Down
84 changes: 84 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ inputs:
go-test-flags:
description: "Additional go test flags (-single-dash style)"
required: false
bench:
description: "Run benchmarks after tests (gotest bench --spec)"
default: "false"
bench-baseline:
description: "Baseline JSON file to compare benchmarks against (gotest bench --against)"
required: false
bench-gate:
description: "Fail the run if any benchmark regresses by more than this percent (gotest bench --gate)"
required: false
bench-save:
description: "Save the benchmark run as a JSON baseline at this path (gotest bench --save); an explicit empty string saves to bench.baseline from .gotest.yml"
default: "false"
version:
description: "gotest version: 'gomod' (default) resolves from go.mod, or a version tag (e.g. v1.0.0, latest) to install globally."
default: "gomod"
Expand All @@ -35,6 +47,12 @@ outputs:
coverage:
description: "Coverage percentage (empty if coverage not enabled)"
value: ${{ steps.test.outputs.coverage }}
bench-report:
description: "Path to the gotest bench --json report file (empty if bench not enabled)"
value: ${{ steps.bench.outputs.bench-report }}
bench-breached-keys:
description: "Comma-joined benchmark keys that breached the gate (empty if none or no gate)"
value: ${{ steps.bench.outputs.bench-breached-keys }}

runs:
using: "composite"
Expand Down Expand Up @@ -109,3 +127,69 @@ runs:
fi

exit "$exit_code"

- name: Run benchmarks
id: bench
if: ${{ inputs.bench == 'true' }}
shell: bash
env:
INPUT_PACKAGES: ${{ inputs.packages }}
INPUT_BENCH_BASELINE: ${{ inputs.bench-baseline }}
INPUT_BENCH_GATE: ${{ inputs.bench-gate }}
INPUT_BENCH_SAVE: ${{ inputs.bench-save }}
INPUT_FLAGS: ${{ inputs.flags }}
INPUT_VERSION: ${{ inputs.version }}
run: |
if [ "$INPUT_VERSION" != "gomod" ]; then
cmd=(gotest)
else
cmd=(go run github.com/mvrahden/go-test/cmd/gotest)
fi

# gotest bench's own --spec/--github wiring writes the markdown
# delta table to $GITHUB_STEP_SUMMARY automatically (mirroring how
# `gotest summary --github` does it above). Under --json the human
# rendering moves entirely into the step summary: stdout carries
# the versioned report document, captured to a file for consumers.
report="$(mktemp -t gotest-bench-XXXXXX.json)"
args=(bench --spec --json)

if [ -n "$INPUT_BENCH_BASELINE" ]; then
args+=("--against=$INPUT_BENCH_BASELINE")
fi

if [ -n "$INPUT_BENCH_GATE" ]; then
args+=("--gate=$INPUT_BENCH_GATE")
fi

# bench-save: "false" (the default) skips saving; an explicit empty
# string becomes bare --save= (the bench.baseline config fallback);
# anything else is the baseline path.
if [ "$INPUT_BENCH_SAVE" != "false" ]; then
args+=("--save=$INPUT_BENCH_SAVE")
fi

if [ -n "$INPUT_FLAGS" ]; then
read -ra extra <<< "$INPUT_FLAGS"
args+=("${extra[@]}")
fi

read -ra pkgs <<< "$INPUT_PACKAGES"
args+=("${pkgs[@]}")

set +e
"${cmd[@]}" "${args[@]}" > "$report"
exit_code=$?
set -e

echo "bench-report=$report" >> "$GITHUB_OUTPUT"

# Comma-join gate.breachedKeys from the report without requiring
# jq on the runner (python3 ships on all hosted images).
breached=""
if [ -s "$report" ]; then
breached=$(python3 -c 'import json, sys; r = json.load(open(sys.argv[1])); g = r.get("gate") or {}; print(",".join(g.get("breachedKeys") or []), end="")' "$report" 2>/dev/null || true)
fi
echo "bench-breached-keys=$breached" >> "$GITHUB_OUTPUT"

exit "$exit_code"
1 change: 1 addition & 0 deletions cmd/gotest/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ type ExecConfig struct {
var knownSubcommands = map[string]bool{
"discover": true,
"prepare": true,
"bench": true,
"generate": true,
"scaffold": true,
"migrate": true,
Expand Down
Loading
Loading