From 09065a6a07371a425dfa96f994478ef2794bf75d Mon Sep 17 00:00:00 2001 From: Abhinav Gupta Date: Sat, 23 May 2026 15:40:29 -0700 Subject: [PATCH] feat: Add GitHub Actions failure formatter Large test suites can overload GitHub Actions logs, slowing down log browsing in the UI. Add `github-actions-fails` as a quieter formatter that keeps package progress visible while only expanding failed test diagnostics. Package-level failures still promote buffered package output so TestMain and init failures keep their error context. Naming: this is called github-actions-fails to mirror the precedent set by pkgname-and-test-fails. Resolves #543 --- README.md | 2 + cmd/main.go | 1 + cmd/testdata/gotestsum-help-text | 1 + testjson/format.go | 70 +++++++++++++++++-- testjson/format_test.go | 11 ++- .../testdata/format/github-actions-fails.out | 61 ++++++++++++++++ 6 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 testjson/testdata/format/github-actions-fails.out diff --git a/README.md b/README.md index 90e8a8a5..7cef2dda 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ Commonly used formats (see `--help` for a full list): * `pkgname` (default) - print a line for each package. * `testname` - print a line for each test and package. * `testdox` - print a sentence for each test using [gotestdox](https://github.com/bitfield/gotestdox). + * `github-actions` - print test output with GitHub Actions log groups. + * `github-actions-fails` - same as `github-actions` but only print failures. * `standard-quiet` - the standard `go test` format. * `standard-verbose` - the standard `go test -v` format. diff --git a/cmd/main.go b/cmd/main.go index f55064ed..01fa98ca 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -161,6 +161,7 @@ Formats: testname print a line for each test and package testdox print a sentence for each test using gotestdox github-actions testname format with github actions log grouping + github-actions-fails github-actions format without passing or skipped test output standard-quiet standard go test format standard-verbose standard go test -v format diff --git a/cmd/testdata/gotestsum-help-text b/cmd/testdata/gotestsum-help-text index c2283b43..e1a856e1 100644 --- a/cmd/testdata/gotestsum-help-text +++ b/cmd/testdata/gotestsum-help-text @@ -41,6 +41,7 @@ Formats: testname print a line for each test and package testdox print a sentence for each test using gotestdox github-actions testname format with github actions log grouping + github-actions-fails github-actions format without passing or skipped test output standard-quiet standard go test format standard-verbose standard go test -v format diff --git a/testjson/format.go b/testjson/format.go index 64b2f261..4faec2cf 100644 --- a/testjson/format.go +++ b/testjson/format.go @@ -449,12 +449,34 @@ func NewEventFormatter(out io.Writer, format string, formatOpts FormatOptions) E return pkgNameWithFailuresFormat(out, formatOpts) case "github-actions", "github-action": return githubActionsFormat(out) + case "github-actions-fails": + return githubActionsFailsFormat(out) default: return nil } } func githubActionsFormat(out io.Writer) EventFormatter { + return (&githubActionsConfig{}).NewFormatter(out) +} + +func githubActionsFailsFormat(out io.Writer) EventFormatter { + return (&githubActionsConfig{ + HideSuccessfulTests: true, + }).NewFormatter(out) +} + +// githubActionsConfig controls how test events are presented using +// GitHub Actions log grouping. +type githubActionsConfig struct { + // HideSuccessfulTests suppresses passed and skipped test case lines. + // + // Package result lines are still printed so CI logs retain package-level + // progress and failure context. + HideSuccessfulTests bool +} + +func (c *githubActionsConfig) NewFormatter(out io.Writer) EventFormatter { buf := bufio.NewWriter(out) type name struct { @@ -466,9 +488,17 @@ func githubActionsFormat(out io.Writer) EventFormatter { return eventFormatterFunc(func(event TestEvent, exec *Execution) error { key := name{Package: event.Package, Test: event.Test} - // test case output - if event.Test != "" && event.Action == ActionOutput { - if !isFramingLine(event.Output, event.Test) { + if event.Action == ActionOutput { + switch { + case event.Test != "": + // test case output + if !isFramingLine(event.Output, event.Test) { + output[key] = append(output[key], event.Output) + } + case event.Package != "": + // Package output is usually just the go test PASS/FAIL footer, + // but TestMain and init failures may put their only useful + // diagnostics here. output[key] = append(output[key], event.Output) } return nil @@ -476,6 +506,13 @@ func githubActionsFormat(out io.Writer) EventFormatter { // test case end event if event.Test != "" && event.Action.IsTerminal() { + defer delete(output, key) + // Failure-only mode keeps package progress visible, + // but drops successful test cases before they can create log noise. + if c.HideSuccessfulTests && event.Action != ActionFail { + return nil + } + if len(output[key]) > 0 { buf.WriteString("::group::") } else { @@ -489,14 +526,14 @@ func githubActionsFormat(out io.Writer) EventFormatter { if len(output[key]) > 0 { buf.WriteString("\n::endgroup::\n") } - delete(output, key) return buf.Flush() } // package event - if !event.Action.IsTerminal() { + if !event.PackageEvent() || !event.Action.IsTerminal() { return nil } + defer delete(output, key) result := colorEvent(event)(strings.ToUpper(string(event.Action))) pkg := exec.Package(event.Package) @@ -505,11 +542,30 @@ func githubActionsFormat(out io.Writer) EventFormatter { result = colorEvent(event)("EMPTY") } - buf.WriteString(" ") + openGroup := func() { buf.WriteString(" ") } + closeGroup := func() { buf.WriteString("\n") } + + // In failure-only mode, + // package-level failures need their output promoted. + // Otherwise failures in TestMain or init code + // can lose information. + var packageOutput []string + if c.HideSuccessfulTests && event.Action == ActionFail && pkg.TestMainFailed() { + packageOutput = output[key] + if len(packageOutput) > 0 { + openGroup = func() { buf.WriteString("::group::") } + closeGroup = func() { buf.WriteString("\n::endgroup::\n") } + } + } + + openGroup() buf.WriteString(result) buf.WriteString(" Package ") buf.WriteString(packageLine(event, exec.Package(event.Package))) - buf.WriteString("\n") + for _, item := range packageOutput { + buf.WriteString(item) + } + closeGroup() return buf.Flush() }) } diff --git a/testjson/format_test.go b/testjson/format_test.go index 6442eddc..810d52fc 100644 --- a/testjson/format_test.go +++ b/testjson/format_test.go @@ -73,7 +73,9 @@ func TestFormats_DefaultGoTestJson(t *testing.T) { run := func(t *testing.T, tc testCase) { out := new(bytes.Buffer) - shim := newFakeHandler(tc.format(out), "input/go-test-json") + formatter := tc.format(out) + assert.Assert(t, formatter != nil) + shim := newFakeHandler(formatter, "input/go-test-json") exec, err := ScanTestOutput(shim.Config(t)) assert.NilError(t, err) @@ -172,6 +174,13 @@ func TestFormats_DefaultGoTestJson(t *testing.T) { format: githubActionsFormat, expectedOut: "format/github-actions.out", }, + { + name: "github-actions-fails", + format: func(out io.Writer) EventFormatter { + return NewEventFormatter(out, "github-actions-fails", FormatOptions{}) + }, + expectedOut: "format/github-actions-fails.out", + }, } for _, tc := range testCases { diff --git a/testjson/testdata/format/github-actions-fails.out b/testjson/testdata/format/github-actions-fails.out new file mode 100644 index 00000000..972e51cb --- /dev/null +++ b/testjson/testdata/format/github-actions-fails.out @@ -0,0 +1,61 @@ +::group::FAIL Package testjson/internal/badmain (1ms) +sometimes main can exit 2 +FAIL gotest.tools/gotestsum/testjson/internal/badmain 0.001s + +::endgroup:: + EMPTY Package testjson/internal/empty (cached) + + PASS Package testjson/internal/good (cached) + +::group::FAIL testjson/internal/parallelfails.TestNestedParallelFailures/a (0.00s) + fails_test.go:50: failed sub a + --- FAIL: TestNestedParallelFailures/a (0.00s) + +::endgroup:: +::group::FAIL testjson/internal/parallelfails.TestNestedParallelFailures/d (0.00s) + fails_test.go:50: failed sub d + --- FAIL: TestNestedParallelFailures/d (0.00s) + +::endgroup:: +::group::FAIL testjson/internal/parallelfails.TestNestedParallelFailures/c (0.00s) + fails_test.go:50: failed sub c + --- FAIL: TestNestedParallelFailures/c (0.00s) + +::endgroup:: +::group::FAIL testjson/internal/parallelfails.TestNestedParallelFailures/b (0.00s) + fails_test.go:50: failed sub b + --- FAIL: TestNestedParallelFailures/b (0.00s) + +::endgroup:: + FAIL testjson/internal/parallelfails.TestNestedParallelFailures (0.00s) +::group::FAIL testjson/internal/parallelfails.TestParallelTheFirst (0.01s) + fails_test.go:29: failed the first + +::endgroup:: +::group::FAIL testjson/internal/parallelfails.TestParallelTheThird (0.00s) + fails_test.go:41: failed the third + +::endgroup:: +::group::FAIL testjson/internal/parallelfails.TestParallelTheSecond (0.01s) + fails_test.go:35: failed the second + +::endgroup:: + FAIL Package testjson/internal/parallelfails (20ms) + +::group::FAIL testjson/internal/withfails.TestFailed (0.00s) + fails_test.go:34: this failed + +::endgroup:: +::group::FAIL testjson/internal/withfails.TestFailedWithStderr (0.00s) +this is stderr + fails_test.go:43: also failed + +::endgroup:: +::group::FAIL testjson/internal/withfails.TestNestedWithFailure/c (0.00s) + fails_test.go:65: failed + --- FAIL: TestNestedWithFailure/c (0.00s) + +::endgroup:: + FAIL testjson/internal/withfails.TestNestedWithFailure (0.00s) + FAIL Package testjson/internal/withfails (20ms) +