💾 rob's blog

🎨 Beautify your Go tests on GitHub Actions

· 3 min read

# Was this made for humans?

Although simple, Go’s default testing output leaves a lot to be desired:

go test output

This has led rise to some other wrappers for go test, solely to be a better formatter for humans. For example, gotestsum does this quite well:

gotestsum output

This is definitely an improvement, and gotestsum even has additional formatting like exporting to JUnit XML.

But, what if CI could produce a rich, interactive, summary like this:

gotestaction overview

And interactive expansion for more details:

gotestaction expansion

(Check out the example here!)

# Actions Job Summaries

One of the great features my team released recently was GitHub Actions Job Summaries. If you haven’t heard of it yet, it’s a very simple way to get Markdown content as an output for a GitHub Actions job.

It’s relatively easy to use, we provide two mechanisms to write summary content:

  1. Writing GitHub Flavored Markdown to the file at $GITHUB_STEP_SUMMARY on all GitHub Actions runners.
  2. Utilizing the @actions/core’s TypeScript helper library.

And the latter is what I used to create go-test-action.

# Introducing: go-test-action

It should be easy to get a pretty test summary.

With go-test-action, all you need is to replace one line in your Actions Workflow:

name: CI

on:
  push:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3

    - name: Set up Go
      uses: actions/setup-go@v3
      with:
        go-version: 1.18

    - name: Build
      run: go build -v ./...

    - name: Test
-     run: go test ./...
+     uses: robherley/go-test-action@v0.1.0

If your test parameters are a bit more complicated or if you want to customize the summary structure, there are a few inputs that you can use:

InputDefaultDescription
moduleDirectory.relative path to the directory containing the go.mod of the module you wish to test
testArguments./...arguments to pass to go test, -json will be prepended automatically
omitUntestedPackagesfalseomit any go packages that don’t have any tests from the summary output
omitPiefalseomit the pie chart from the summary output

So if you really hate 🥧, you can change your Workflow to omit it:

    - name: Test
      uses: robherley/go-test-action@v0.1.0
      with:
        omitPie: true

For the most up-to-date list of inputs, check out the action.yml

# But how does it work?

When executing the tests, go-test-action will prepend the arguments with -json, that will convert the output to JSON using test2json.

The test2json JSON output is structured like so:

type TestEvent struct {
	Time    time.Time
	Action  string
	Package string
	Test    string
	Elapsed float64
	Output  string
}

The actual output is a bit chunky:

{"Time":"2022-07-10T22:42:11.92576-04:00","Action":"output","Package":"github.com/robherley/go-test-example","Output":"?   \tgithub.com/robherley/go-test-example\t[no test files]\n"}
{"Time":"2022-07-10T22:42:11.926603-04:00","Action":"skip","Package":"github.com/robherley/go-test-example","Elapsed":0.001}
{"Time":"2022-07-10T22:42:11.931066-04:00","Action":"run","Package":"github.com/robherley/go-test-example/success","Test":"TestSuccess"}
{"Time":"2022-07-10T22:42:11.931141-04:00","Action":"output","Package":"github.com/robherley/go-test-example/success","Test":"TestSuccess","Output":"=== RUN   TestSuccess\n"}
{"Time":"2022-07-10T22:42:11.931166-04:00","Action":"run","Package":"github.com/robherley/go-test-example/success","Test":"TestSuccess/Subtest(1)"}
{"Time":"2022-07-10T22:42:11.931185-04:00","Action":"output","Package":"github.com/robherley/go-test-example/success","Test":"TestSuccess/Subtest(1)","Output":"=== RUN   TestSuccess/Subtest(1)\n"}
{"Time":"2022-07-10T22:42:11.931204-04:00","Action":"output","Package":"github.com/robherley/go-test-example/success","Test":"TestSuccess/Subtest(1)","Output":"    success_test.go:19: hello from subtest #1\n"}
{"Time":"2022-07-10T22:42:11.931239-04:00","Action":"run","Package":"github.com/robherley/go-test-example/success","Test":"TestSuccess/Subtest(2)"}
{"Time":"2022-07-10T22:42:11.931284-04:00","Action":"output","Package":"github.com/robherley/go-test-example/success","Test":"TestSuccess/Subtest(2)","Output":"=== RUN   TestSuccess/Subtest(2)\n"}
{"Time":"2022-07-10T22:42:11.9313-04:00","Action":"output","Package":"github.com/robherley/go-test-example/success","Test":"TestSuccess/Subtest(2)","Output":"    success_test.go:19: hello from subtest #2\n"}
{"Time":"2022-07-10T22:42:11.931315-04:00","Action":"run","Package":"github.com/robherley/go-test-example/success","Test":"TestSuccess/Subtest(3)"}
// and more!

This JSON output is parsed, grouped and aggregated like so:

  • For every package level test, group the following:
    • Count tests that have a conclusive attribute. Conclusive meaning their test2json output Action is either is pass, fail or skip.
    • Repeat above for any subtests with a test (subtests in go with T.Run are naively indicated with a / in their name)

This logic is split between the parseTestEvents function and the PackageResult class. And once it’s all parsed, the attributes are rendered to Markdown by the Renderer class.

# Contribute!

Have an idea for a feature or want to report a bug? Feel free to open an issue or submit a pull request!

Thanks for reading and happy testing! 🧪

← Prev ☕ Rewriting tiny.coffee to < 100 lines …
Next → ✂️ snips.sh retrospective: 1000+ stars …