Coherent tests for Go — a Jest-inspired assertion API and a clean test report, built as a thin wrapper around the standard go test runner.
✅ TestAdd/it adds two numbers (12ms)
✅ TestAdd/it is commutative (1.20s)
✓ All tests passed
Packages: 1 passed, 1 total
Tests: 2 passed, 2 total (100% passed)
Time: 1.232s
Ran all tests.
Go's testing tooling is excellent, but two things get awkward as a suite grows:
- Assertions are verbose. Every check is a hand-written
ifplus at.Errorfwith a format string. The intent of the test gets buried in boilerplate. - The default report is hard to scan.
go test -vproduces a flat=== RUN / --- PASSstream that's noisy and offers no at-a-glance verdict.
goherent fixes both:
- A nicer assertion API, inspired by Jest —
Expect(value).ToEqual(expected),Expect(xs).ToContainElement(3),Expect(fn).ToPanic(), and a uniformNot()for negation. Test cases also get real, multi-line descriptions (Given/When/Then), which Go normally doesn't allow. - A nice report of the results from the standard
go testoutput — colorized, with per-test durations, a pass-rate headline, and the slowest tests.
It's a wrapper, not a reinvention. goherent does not replace the Go compiler or test runner — your tests compile and execute through the exact same
go testtoolchain, so they run just as fast as plaingo test. goherent only changes how you write assertions and how results are displayed.
🚀 Starting...
📦 github.com/you/project/math
✅ TestAdd/it adds two numbers (12ms)
✅ TestAdd/it is commutative (1.20s)
✓ All tests passed
Packages: 1 passed, 1 total
Tests: 2 passed, 2 total (100% passed)
Time: 1.232s
Ran all tests.
🐢 2 slowest tests:
(1.20s) TestAdd/it is commutative
(12ms) TestAdd/it adds two numbers
Durations are dimmed, and anything ≥ 1s is highlighted so slow tests stand out.
When an assertion fails, goherent shows the source location and a readable diff, then a red verdict and the list of failing tests:
🚀 Starting...
📦 github.com/you/project/math
❌ TestDivide/it errors on divide by zero (1ms)
/you/project/math/divide_test.go:18
not equal:
expected: 6
actual : 7
Failed tests:
❌ github.com/you/project/math
● TestDivide/it errors on divide by zero
✗ Tests failed
Packages: 1 failed, 1 total
Tests: 0 passed, 1 total (0% passed)
Time: 0.004s
Ran all tests.
goherent has two parts: a runner that executes your tests and shows the report, and a library (test + expect) you import in your test files.
Requirements: Go 1.22+.
Add goherent to your module:
go get github.com/redjolr/goherentYou can then run the test runner straight from the module — no separate install step needed:
go run github.com/redjolr/goherent ./...If you'd rather have a goherent command on your PATH, install it once:
go install github.com/redjolr/goherent@latestThis puts a goherent binary in your Go bin directory (usually ~/go/bin — make sure it's on your PATH), after which you can run:
goherent ./...Write tests with the Test function and the injected Expect:
package math_test
import (
"testing"
. "github.com/redjolr/goherent/test" // dot-import so `Test` is unqualified
"github.com/redjolr/goherent/expect" // for the `expect.F` parameter type
"github.com/you/project/math"
)
func TestAdd(t *testing.T) {
Test("it adds two numbers", func(Expect expect.F) {
Expect(math.Add(2, 3)).ToEqual(5)
}, t)
// Descriptions can be multiline — great for Given/When/Then.
Test(`
Given two numbers a and b
When we add them
Then addition is commutative
`, func(Expect expect.F) {
Expect(math.Add(2, 3)).ToEqual(math.Add(3, 2))
}, t)
}Run the goherent runner instead of go test:
go run github.com/redjolr/goherent ./...That's it — it accepts the same package/flag arguments you'd give go test.
The examples below use
goherent ./...for brevity. If you haven't installed the binary, substitutego run github.com/redjolr/goherent ./...anywhere you seegoherent.
Every go test flag works — goherent forwards your arguments straight through to go test unchanged, so packages, build flags, test flags, coverage, profiling, and the rest behave exactly as they do normally:
goherent ./... # whole module
goherent ./math/... # a subtree
goherent -run TestAdd ./math # filter by name
goherent -count=1 ./... # disable test caching
goherent -race ./... # race detector
goherent -coverprofile=cover.out ./... # coverage
goherent -timeout 30s -shuffle on ./... # any other go test flagsConcurrent vs. sequential. By default go test runs packages in parallel. Force sequential execution with -p 1:
goherent -p 1 ./... # one package at a timeRerun only what failed. Every run records the tests that failed to
.goherent/last-failures. Pass --rerun-fails to run just those again — goherent
narrows the run to the affected packages and failed tests, which is ideal for a
tight edit-test loop:
goherent ./... # full run; failures get recorded
goherent --rerun-fails # rerun only the tests that just failedEach rerun re-records what still fails, so repeating --rerun-fails keeps
shrinking the set until it's empty (an all-passing run clears the file, after
which --rerun-fails reports there's nothing to rerun). --rerun-fails is
goherent's own flag — every other argument is still forwarded to go test, so you
can combine it with flags like -race or -count=1. It reruns at top-level-test
granularity (a failed sub-test reruns its whole Test… function). Add .goherent/
to your .gitignore.
Detect flaky tests. Pass --retry N to re-run failed tests up to N times.
A test that fails and then passes on a retry is reported as 🎲 flaky rather
than failed; a test that never passes is genuinely broken. When every failure
turns out to be flaky, the run is treated as passing (exit 0):
goherent --retry 2 ./...✗ Tests failed
…
🎲 Flaky tests (failed, then passed on retry):
github.com/you/project/math
● TestDivide/it sometimes races
✓ All failures passed on retry — treating the run as passed (flaky).
Retries reuse your other flags and force a fresh, uncached run (-count=1). The
genuinely-failing tests that remain are what --rerun-fails will target next.
--retry is goherent's own flag and accepts both --retry N and --retry=N.
Verbosity. You don't need -v; goherent ignores it (the report is always descriptive).
CI / non-TTY. When the CI environment variable is true, goherent prints plain, readable output that stays clean in pipeline logs:
CI=true goherent ./...Import the package with a dot-import so the helpers read naturally:
import . "github.com/redjolr/goherent/test"Defines one test case. name is any string (including multiline). body receives Expect, the assertion entrypoint. Pass the real *testing.T as the third argument.
func TestThing(t *testing.T) {
Test("it works", func(Expect expect.F) {
Expect(Thing()).ToBeTrue()
}, t)
}Each Test runs as a Go subtest (t.Run), so it's isolated and shows up individually in the report.
Same signature as Test, but the case is skipped (t.Skip()). Handy for temporarily parking a case while keeping its description.
TestSkip("it handles the edge case (TODO)", func(Expect expect.F) {
Expect(Edge()).ToEqual(want)
}, t)Every assertion starts with Expect(actual) and reads as a sentence. A failing assertion reports the file:line and a message; it does not stop the test, so multiple expectations can report in one run.
Any matcher can be inverted with Not(). There's a single, uniform negation path, so every matcher — including ones added in the future — gets its inverse for free:
Expect(2 + 2).Not().ToEqual(5)
Expect(users).Not().ToContainElement("banned")
Expect(m).Not().ToHaveKey("secret")
Expect(value).Not().ToBeNil()Not().Not() is the positive matcher again. The aliases NotToEqual, NotToBeError, and NotToBeNil exist as shorthands for the common cases.
| Matcher | Checks |
|---|---|
Expect(a).ToEqual(b) |
deep equality (handles structs, slices, maps, etc.) |
Expect(a).NotToEqual(b) |
a is not deeply equal to b (alias for Not().ToEqual(b)) |
Expect(user).ToEqual(User{Name: "Ada", Age: 36})
Expect(got).NotToEqual(unwanted)When a ToEqual on a struct, slice, array, or map fails, the diff pinpoints each
changed leaf by its field path instead of dumping the whole value, so a deep
difference is obvious at a glance:
Diff:
.Owner.Name: "Ada" → "Bob"
.Owner.Age: 30 → 31
.Items[2]: 3 → 4
(Multi-line strings still use a line-based unified diff, which reads better for text.)
| Matcher | Checks |
|---|---|
Expect(x).ToBeTrue() |
x == true |
Expect(x).ToBeFalse() |
x == false |
Expect(x).ToBeNil() |
x is nil (including typed nil pointers, slices, maps, …) |
Expect(x).NotToBeNil() |
x is not nil |
Expect(cache.Has(key)).ToBeTrue()
Expect(result).ToBeNil()| Matcher | Checks |
|---|---|
Expect(err).ToBeError() |
the value implements error (and is non-nil) |
Expect(err).NotToBeError() |
the value is nil or not an error |
Expect(err).ToWrap(target) |
err's chain contains target (errors.Is / errors.As) |
Expect(err).ToMatchError(msg) |
err's message contains the substring msg |
_, err := Parse("bad")
Expect(err).ToBeError()
_, err = Parse("ok")
Expect(err).NotToBeError()Error chains. ToWrap walks the wrapped-error chain so you can assert against
the cause, not just the outermost error. It covers both of Go's chain
primitives, choosing based on what you pass:
// errors.Is — match a sentinel error anywhere in the chain.
Expect(err).ToWrap(io.EOF)
Expect(err).Not().ToWrap(sql.ErrNoRows)
// errors.As — match by type, and extract the unwrapped error to inspect it.
var perr *fs.PathError
Expect(err).ToWrap(&perr) // on success, perr points at the matched *fs.PathError
Expect(perr.Path).ToEqual("/etc/config")ToMatchError is the quick, message-based check when you just care about the text:
Expect(err).ToMatchError("connection refused")Works across Go's numeric kinds (ints, uints, floats), plus comparable types like strings, time.Time, and []byte where ordering is defined.
| Matcher | Checks |
|---|---|
Expect(n).ToBeGreaterThan(m) |
n > m |
Expect(n).ToBeGreaterThanOrEqualTo(m) |
n >= m |
Expect(n).ToBeLessThan(m) |
n < m |
Expect(n).ToBeLessThanOrEqualTo(m) |
n <= m |
Expect(n).ToBePositive() |
n > 0 |
Expect(n).ToBeNegative() |
n < 0 |
Expect(n).ToBeCloseTo(target, tolerance) |
` |
Expect(score).ToBeGreaterThanOrEqualTo(60)
Expect(balance).ToBePositive()
Expect(3.14159).ToBeCloseTo(3.14, 0.01) // avoids float == pitfalls| Matcher | Checks |
|---|---|
Expect(s).ToBeString() |
the value is a string |
Expect(s).ToContain(sub) |
string contains substring sub |
Expect(s).ToMatch(pattern) |
string matches the regular expression pattern |
Expect(banner).ToContain("goherent")
Expect(version).ToMatch(`^v\d+\.\d+\.\d+$`)| Matcher | Checks |
|---|---|
Expect(xs).ToContain(v) |
substring (string), element (slice/array), or key (map) |
Expect(xs).ToContainElement(v) |
slice/array/map value membership (not substrings) |
Expect(m).ToHaveKey(k) |
map contains key k |
ToContain is the flexible, do-what-I-mean matcher; ToContainElement (collection values) and ToHaveKey (map keys) are the precise ones.
Expect([]int{1, 2, 3}).ToContainElement(2)
Expect("hello world").ToContain("world")
Expect(map[string]int{"a": 1}).ToHaveKey("a")
Expect(scores).Not().ToContainElement(0)Works on strings, slices, arrays, maps, and channels.
| Matcher | Checks |
|---|---|
Expect(xs).ToHaveLength(n) |
length is exactly n |
Expect(xs).ToHaveLengthGreaterThan(n) |
length > n |
Expect(xs).ToHaveLengthLessThan(n) |
length < n |
Expect(items).ToHaveLength(3)
Expect(results).ToHaveLengthGreaterThan(0)| Matcher | Checks |
|---|---|
Expect(x).ToBeOfSameTypeAs(y) |
x and y have the same dynamic type |
Expect(got).ToBeOfSameTypeAs(User{})The value under test must be a no-argument function; the assertion calls it and checks whether it panicked.
| Matcher | Checks |
|---|---|
Expect(fn).ToPanic() |
calling fn() panics |
Expect(fn).Not().ToPanic() |
calling fn() returns normally |
Expect(func() { MustParse("nope") }).ToPanic()
Expect(func() { MustParse("ok") }).Not().ToPanic()For values that settle over time — set by a goroutine, delivered on a channel, or
fetched over the network — Eventually() re-checks a matcher until it passes or a
timeout elapses. The value under test is a function that produces the current
value each poll: func() T (or func() (T, error) — while it returns a non-nil
error, the value is treated as not-yet-ready and polling continues).
// Poll counter.Load() until it reaches 3 (default: up to 1s, every 10ms).
Expect(func() int { return int(counter.Load()) }).Eventually().ToEqual(3)
// Tune the budget and cadence.
Expect(ping).Eventually().
Within(2 * time.Second).
ProbeEvery(50 * time.Millisecond).
ToBeNil()
// Negation polls until the matcher stops holding.
Expect(queue.Len).Eventually().Not().ToEqual(0)| Method | Effect |
|---|---|
Eventually() |
retry the next matcher until it holds (default 1s timeout, 10ms poll) |
Within(d) |
how long to keep polling before failing |
ProbeEvery(d) |
how long to wait between polls |
Not() |
poll until the matcher stops holding |
Every matcher above (ToEqual, ToBeTrue, ToContain, ToBeGreaterThan, …)
works after Eventually(). On timeout the failure reports the last value seen (or
the last error), so you can tell what it was stuck at.
Do I have to rewrite my existing tests?
No. Plain go test tests still run under goherent. Adopt the Test/Expect API incrementally where it helps.
Can I still use t directly inside a Test?
The body receives Expect; if you need t, capture it from the enclosing function — but most checks read better through Expect.
Does it work in CI?
Yes. Set CI=true to get clean, plain output suited to pipeline logs.