
Product news and testing tips.
`go test -json` emits a newline-delimited stream of nine event types, not a test report — turning it into results means assembling events per test, and three things reliably go wrong: subtests double-counted with their parents, package-level failures like build errors and panics that never reach a test event, and interleaved output. gotestsum is the most direct way to get JUnit XML out of it; go-junit-report is the lighter alternative.
Key takeaways
- `go test -json` is an event stream — nine Action values, one JSON object per line — not a results document.
- Subtests emit their own events under a `Parent/child` name, so naive counting double-counts the parent.
- A build failure, TestMain failure, or panic can fail a package without any test producing a terminal event.
- gotestsum --junitfile is the shortest path from Go tests to JUnit XML in CI.
- `-shuffle` (Go 1.17+) and `-count=1` expose order-dependence and caching; `testing/synctest` went GA in Go 1.25.
Go’s testing story is unusually self-contained. The test runner is in the toolchain, the assertion style is “write an if statement”, and there is no configuration file. Right up until you need a test report in CI, at which point the ecosystem hands you a JSON stream and wishes you luck.
go test -json is genuinely well designed. It is also not a test report, and the gap between those two things is where most Go CI reporting problems live.
What does go test -json actually emit?
A newline-delimited JSON stream — one object per line, each describing one event. Run it and you get something like this:
{"Time":"2026-09-09T10:14:22.1Z","Action":"start","Package":"shop/checkout"}
{"Time":"2026-09-09T10:14:22.2Z","Action":"run","Package":"shop/checkout","Test":"TestApplyDiscount"}
{"Time":"2026-09-09T10:14:22.2Z","Action":"output","Package":"shop/checkout","Test":"TestApplyDiscount","Output":"=== RUN TestApplyDiscount\n"}
{"Time":"2026-09-09T10:14:22.4Z","Action":"pass","Package":"shop/checkout","Test":"TestApplyDiscount","Elapsed":0.21}
{"Time":"2026-09-09T10:14:22.9Z","Action":"pass","Package":"shop/checkout","Elapsed":0.78}
The Action field takes exactly nine values: start, run, pause, cont, pass, bench, fail, output, and skip. Every stream begins with a start event.
Two structural details matter more than they look:
Events with no Test field are package-level. The Go docs are explicit: events for the overall package test do not set Test. The final pass in the example above is the package passing, not a test.
Output is its own event type. A test’s log lines and failure messages arrive as a series of output events, interleaved with everything else running concurrently. Reconstructing “what did this test print” means accumulating output events keyed by package and test.
What is test2json?
test2json is the tool that does the conversion from Go’s textual test output to that JSON stream. You will rarely call it directly, because go test -json invokes it correctly for you. The official documentation is direct about this:
Note that ‘go test -json’ takes care of invoking test2json correctly, so ‘go tool test2json’ is only needed when a test binary is being run separately from ‘go test’. Use ‘go test -json’ whenever possible.
Worth knowing it exists, mostly so that when you see test2json in someone’s pipeline you know it is the same machinery.
Why the JSON stream is not a report
A report answers “what is the state of the suite”. The stream answers “what happened, in order”. Converting one to the other means holding state across events, and three specific things go wrong when you do it naively.
1. Subtests double-count
Go reports subtests as their own tests, named with a slash: TestCheckout/applies_discount. Both parent and child emit their own run and pass/fail/skip events, each with their own Elapsed.
If you count every test-level pass event as a test, a parent with four subtests counts as five tests, and its elapsed time — which spans all four children — gets added on top of theirs. Suite totals inflate and durations roughly double.
The fix is to skip a parent that has at least one child, where “child” means a test in the same package whose name begins with parent/. Whether the parent should count as a test at all is a judgement call; what is not a judgement call is counting it and its children and summing all the durations.
2. Package-level failures never reach a test event
This is the one that produces the confusing bug report: the build is red, and the test report says zero failures.
It happens whenever a package fails without any individual test reaching a terminal event:
- A compilation error — the package never ran.
- A
TestMainfailure — setup died before tests started. - A panic — it takes down the whole test binary, and tests that were mid-flight never emit
passorfail.
In each case you get a package-level fail event with no Test field, and the reason is in output events attached to the package rather than to any test. A report assembled only from test-level events cannot see any of it. The correct handling is to synthesise a case for any package that reported a failure which no individual test accounted for, carrying the package output as the error — otherwise a panic silently becomes a green report.
3. Streams get large
A verbose run over a big module produces a lot of events, and go test -json output for a large suite can run to tens of megabytes. Anything that reads the whole stream into memory before processing works fine locally and falls over on CI. Process it line by line.
Getting JUnit XML: gotestsum vs go-junit-report
Two tools dominate, and they take different approaches.
gotestsum | go-junit-report | |
|---|---|---|
| Role | Wraps and replaces go test | Reads go test output from a pipe |
| JUnit XML | --junitfile report.xml | > report.xml |
| Console output | Several formats, readable by default | Passes through what you piped |
| Latest release | v1.13.0 (Sept 2025) | v2.1.0 (Oct 2023) |
| Best for | Most CI setups | Minimal-dependency pipelines |
gotestsum is the more capable option and the one to reach for by default:
go install gotest.tools/gotestsum@latest
gotestsum --junitfile report.xml --format testname ./...
The --format flag controls console output: dots, pkgname (the default), testname, testdox, standard-quiet, and standard-verbose. In CI, testname gives you a readable log without the noise of -v. The JUnit path can also come from the GOTESTSUM_JUNITFILE environment variable, which is convenient in shared workflow templates.
go-junit-report is a filter rather than a wrapper:
go test -v 2>&1 ./... | go-junit-report -set-exit-code > report.xml
Two details people get wrong here. The 2>&1 is required — Go writes some of what the parser needs to stderr, and without the redirect you get a report missing failures. And -set-exit-code is what makes the pipeline fail the build; without it the exit status is the reporter’s, which is zero, and your CI goes green on failing tests. It can also consume the JSON stream directly with -parser gojson, which is more robust than parsing text:
go test -json 2>&1 ./... | go-junit-report -parser gojson -set-exit-code > report.xml
Its slower release cadence is not abandonment — the repository is actively pushed — but gotestsum is the more actively developed of the two.
Whichever you use, the output is JUnit XML with all the ambiguity that format carries, including no native way to express a retry.
Making Go tests reveal their non-determinism
Three toolchain features are directly useful for flakiness, and they are underused.
-shuffle, added in Go 1.17, randomises execution order:
go test -shuffle=on ./...
It accepts off, on, or an integer seed. When enabled, the seed is reported so a failure is reproducible — run again with -shuffle=1757404462 and you get the same order. This is the cheapest possible test for order-dependence, which is one of the most common sources of non-determinism in a suite that shares state.
-count=1 is the documented idiom for defeating the test cache:
go test -count=1 ./...
Without it, an unchanged package returns a cached result, and you can spend an afternoon “reproducing” a flake against output that was never re-executed. Any run whose purpose is to observe flakiness needs it.
testing/synctest addresses the hardest category: concurrent code that depends on real time. Inside a synctest “bubble”, the time package uses a fake clock that starts at midnight UTC on 2000-01-01 and only advances when every goroutine in the bubble is durably blocked. That turns a test that sleeps for two seconds into one that completes instantly and deterministically.
Get the version history right, because it changed twice:
- Go 1.24 introduced it as an experiment, with
synctest.Run, gated behindGOEXPERIMENT=synctest. - Go 1.25 graduated it to general availability with a slightly different API — the entry point is now
synctest.Test. - Go 1.26 removes the old experimental API entirely.
Any tutorial still showing synctest.Run under a GOEXPERIMENT flag is describing a version of the package that no longer exists.
None of these three make flakiness visible over time, though. They help you provoke and reproduce it. Whether a given test is actually unreliable is a question about many runs — Google’s analysis of its own corpus found almost 16% of tests showed some level of flakiness — and no single-run flag can answer it.
A working CI setup
- name: Run tests
run: |
go install gotest.tools/gotestsum@latest
gotestsum --junitfile report.xml --format testname -- -shuffle=on -count=1 ./...
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: go-test-results
path: report.xml
Note the -- separating gotestsum’s flags from the ones passed through to go test, and the if: always() on the upload — without it the artifact step is skipped exactly when tests failed. That, and the other documented limits of GitHub Actions test reporting, are worth reading before you build much on top of artifacts.
Where the ceiling is
With gotestsum and a reporter action you get a good single-run experience: readable console output, a JUnit XML file, failures annotated on the pull request. That is most of what Go’s ecosystem asks for and it works.
What it does not give you is the second run. Whether TestCheckout/handles_timeout failed last week, whether it has been slowly getting slower since a dependency bump, whether the four failures in this build are one root cause or four — none of that is in a report, because a report describes one execution.
For transparency: Qualflare is our product, and it ingests go test results — among 26 formats — and analyses them across runs for flaky-test scoring and failure clustering; the setup is documented on our Go test reporting page. It does not run your tests and does not replace gotestsum; it reads what gotestsum produces. If your problem is that a single run is hard to read, gotestsum alone solves that and is free. The history question is a different one, and worth reaching for only when you actually have it. The broader shape of that problem is covered in the complete guide to flaky tests.
Frequently asked questions
How do I generate a JUnit XML report from Go tests?
The most direct way is gotestsum, which wraps go test and writes JUnit XML with a single flag — gotestsum --junitfile report.xml ./.... The alternative is go-junit-report, which reads go test output from a pipe — go test -v 2>&1 ./... | go-junit-report -set-exit-code > report.xml. Go itself has no built-in JUnit XML output.
What does go test -json output?
A newline-delimited JSON stream where each line is one event. Every event has an Action field, which is one of nine values — start, run, pause, cont, pass, bench, fail, output, and skip. Every stream begins with a start event. It is a record of what happened during the run, not a summary of results; producing results means accumulating events per test.
What is test2json?
test2json is the Go tool that converts go test’s textual output into the machine-readable JSON stream. You rarely invoke it directly, because go test -json calls it correctly on your behalf. The Go documentation recommends using go test -json whenever possible and reserving go tool test2json for cases where a test binary is run separately from go test.
How do I detect flaky tests in Go?
Not from a single run. Use -shuffle=on to expose order-dependent tests and -count=1 to defeat the test cache so you are measuring a real execution, then compare the same test’s outcomes across many runs over time. A test that changes result on unchanged code is flaky, and that is only visible from history, not from one report.
Why does my Go test report show fewer failures than the build?
Most often because the package failed without any individual test failing — a compilation error, a panic that took down the package before tests reached a terminal event, or a TestMain failure. The package-level fail event carries the failure, but no test case does, so a report built only from test-level events shows zero failures against a red build.
Sources
- Go — cmd/test2json documentation
- Go — cmd/go testing flags
- Go 1.17 release notes — -shuffle flag
- Go 1.25 release notes — testing/synctest general availability
- Go — testing/synctest package documentation
- gotestyourself/gotestsum
- jstemmer/go-junit-report
- Google Testing Blog — Flaky Tests at Google and How We Mitigate Them (2016)


