gotestsum: making go test output actually useful in CI
gotestsum turns go test's wall of text into structured, CI-friendly output. Here's how to set it up well, and where its rerun feature quietly hides flaky tests.
BuildPulse Team
August 12, 2026
Listen

The problem: go test was written for your terminal, not your CI
Open any failed Go build in CI and scroll. You'll pass 4,000 lines of ok github.com/yourorg/yourrepo/internal/widgets 0.41s before you find the one FAIL line that matters, and even then the actual assertion failure is somewhere else, interleaved with log output from three other packages because tests run in parallel. Now imagine you're the engineer who got paged, it's 11pm, and you're doing this on your phone.
go test output is fine when you're running one package locally. It is actively hostile in CI, where you need three things it doesn't give you: a fast answer to "what failed," machine-readable results your tooling can ingest, and a stable record you can look back at when a test fails again next Tuesday.
This is the gap gotestsum fills. It's a small wrapper around go test -json that reformats output for humans, emits JUnit XML for machines, and adds a handful of quality-of-life features. It's become the de facto standard for running Go tests in CI, and if your Go teams aren't using it, they're spending real engineer-minutes scrolling logs that a five-minute setup change would eliminate.
It also ships one feature, --rerun-fails, that I want to talk you out of using casually. We'll get there.
What gotestsum actually does
Under the hood, gotestsum runs go test -json and consumes the event stream. Because it works from structured events rather than scraping text, it can do things plain go test can't:
- Render compact, readable summaries instead of a line per package
- Print a failure recap at the end, so the answer to "what broke" is at the bottom of the log where you'd look first
- Write JUnit XML and raw JSON files alongside the human-readable output
- Rerun failed tests (carefully, please)
- Analyze which tests are slowest across a run
Installation is one line:
go install gotest.tools/gotestsum@latest
And the basic invocation mirrors go test, with anything after -- passed straight through:
gotestsum -- -race -count=1 ./...
That passthrough design matters. You keep your existing flags, build tags, and coverage arguments. Nothing about how your tests compile or run changes; only how the results are reported.
Pick a format on purpose
The --format flag is where most people stop reading the docs, which is a shame, because the default isn't right for every context.
# Locally: one line per test, written as a sentence. Great for TDD.
gotestsum --format testdox ./...
# CI: one line per package, quiet until something fails.
gotestsum --format pkgname ./...
# GitHub Actions specifically: failures become inline annotations.
gotestsum --format github-actions ./...
testdox deserves a special mention. It renders TestUserService_Create_RejectsDuplicateEmail as User service create rejects duplicate email, which turns your test run into a readable spec of what the code does. Teams that adopt it tend to start writing better test names within a month, because bad names suddenly look bad.
For CI, pkgname or github-actions keeps logs short. Every format prints a failure summary at the end, so whatever you pick, the bottom of a failed job answers "what failed" without scrolling. That alone is worth the adoption cost, which is approximately zero.
JUnit XML: the output your tooling actually needs
Here's the part that matters beyond developer comfort. Add two flags:
gotestsum --junitfile test-results.xml --jsonfile test-output.json -- -race ./...
Now every CI run produces a structured record: which tests ran, how long each took, what failed and with what output. JUnit XML is the lingua franca of test reporting; nearly every CI system and test-analytics tool speaks it, which is why it's the standard ingestion format for platforms like BuildPulse (see the Go setup docs for the specifics).
Without structured output, your organization has no test history. You have logs, which expire, and vibes, which don't scale. With it, you can answer questions engineering leaders actually ask: which tests fail most often, is that failure new or has it been intermittently failing for six weeks, which packages are eating our CI budget. If a test failure is going to block a deploy, you want a record of that failure that outlives the log retention window. In a SOC2 shop where CI gates are part of change management, that record isn't optional; it's the evidence.
Here's a complete GitHub Actions job:
name: test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Install gotestsum
run: go install gotest.tools/gotestsum@latest
- name: Run tests
run: |
gotestsum --format github-actions \
--junitfile test-results.xml \
--jsonfile test-output.json \
-- -race -count=1 ./...
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: |
test-results.xml
test-output.json
Note the if: always() on the upload step. Test results are most valuable precisely when the run fails, which is exactly when a naive workflow skips the upload. I've seen more than one team discover this gap the week they actually needed the data.
The --jsonfile output is a bonus most teams ignore. It's the raw go test -json event stream, and gotestsum ships a tool that mines it:
gotestsum tool slowest --jsonfile test-output.json --threshold 500ms
Run that on a week of artifacts and you'll find the test that quietly grew from 200ms to 9 seconds, which nobody noticed because it was hiding in a 14-minute suite.
The flag that's both a gift and a trap: --rerun-fails
Now the opinionated part. gotestsum can rerun failed tests automatically:
gotestsum --rerun-fails=2 --rerun-fails-max-failures=10 \
--junitfile test-results.xml -- ./...
A test that fails, then passes on rerun, counts as passed. The build goes green. Everyone moves on.
I understand the appeal completely. Your suite has a few flaky tests, they're blocking merges, and this one flag makes the pain stop today. To its credit, gotestsum implements this more responsibly than most: --rerun-fails-max-failures refuses to retry when too many tests fail (so a genuine breakage doesn't get retried into a 40-minute build), and reruns are recorded in the output rather than silently swallowed.
But make no mistake about what the flag does at the organizational level: it converts a visible, annoying problem into an invisible, compounding one. A flaky test that fails 10% of the time and gets two retries passes 99.9% of the time. It has effectively vanished. Nobody triages it, because nothing looks broken. Meanwhile the underlying cause, and it always has a cause (a race, a shared fixture, an unmocked clock, a port collision), keeps sitting in your codebase. Sometimes that same race exists in production code. And your CI is now silently running some tests three times, so the flag you added to save time is inflating the compute bill for the suite you were already trying to speed up.
There's a leadership framing here too. If a test fails and an automated retry passes it, your merge gate approved a change on a signal you've decided not to trust once already. In a regulated environment, "the control failed, so we ran it again until it passed, without review" is a sentence you do not want to say to an auditor. Retries aren't inherently disqualifying, but unbounded and unmonitored retries are a control weakness wearing a green checkmark.
Retry with receipts, or don't retry at all
If you use --rerun-fails, and pragmatically most teams with a suite of any size will, do it with receipts:
- Cap it tightly.
--rerun-fails=1or2, with--rerun-fails-max-failuresset low. If a rerun budget of two isn't enough, retries were never the right tool. - Record every rerun. Keep the JUnit and JSON artifacts from all attempts, and use
--rerun-fails-report=rerun-report.txtto get an explicit list of which tests needed a retry. That report is your flaky-test backlog. If nobody reads it, you don't have a retry policy; you have a rug. - Track flakiness as a first-class metric. Which tests are retried, how often, and is the trend improving? This is exactly the job of flaky-test detection: BuildPulse ingests those JUnit files across every run and surfaces which tests are flaky, how disruptive they are, and whether your remediation is working, so retries become a monitored mitigation instead of a blindfold.
- Quarantine, then fix. A known-flaky test should be pulled out of the merge-blocking path deliberately and visibly, with an owner and a ticket, not laundered through retries. Quarantine is honest; silent reruns are not.
The difference between a healthy team and a struggling one usually isn't whether they retry. It's whether anyone can tell you, right now, which tests needed retries last week and what's being done about them.
A sane default setup
If you take one thing away: gotestsum should be your default Go test runner in CI, configured to leave evidence.
--format pkgnameor--format github-actionsin CI,testdoxlocally--junitfileand--jsonfileon every run, uploaded withif: always()-race -count=1passed through, because-count=1defeats Go's test cache in CI and the race detector catches the bugs that later become "flaky tests"- If you retry, cap it, emit
--rerun-fails-report, and feed the results into flaky-test tracking so someone owns the follow-up
The whole change is maybe twenty lines of YAML, and it upgrades your Go test pipeline from "a wall of text that occasionally says FAIL" to a structured signal you can actually manage. For a leader whose teams live in Go, that's one of the cheapest CI-reliability wins available. The expensive part was never the tooling. It's deciding, as an organization, that a green build should mean the tests passed, not that they passed eventually.
Stop guessing which tests you can trust
BuildPulse finds your flaky tests, ranks them by the engineering time they cost, and lets you quarantine the worst in one click. See results on your first build.
Free to start · No credit card required · Setup is a single CI step
Related posts