Skip to content
gohacks.dev
Testing

Testify Eventually and Never for Async Tests in Go

Test async Go code with testify Eventually, Never and EventuallyWithT: poll a condition with a tick and a deadline, not a sleep, and read the failure.

4 min read
Summarize in AI
Flat illustration of a test report with one failing test.

Asynchronous code needs a way to say "this will be true soon". The wrong way is time.Sleep; the right way is to poll with a deadline. Testify packages the pattern as Eventually, Never and EventuallyWithT.

Tested with Go 1.25.6, testify v1.11.1, macOS on Apple Silicon (arm64)

ev_test.goGo
package main

import (
	"sync/atomic"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
)

func TestEventuallyPasses(t *testing.T) {
	var ready atomic.Bool
	go func() { time.Sleep(120 * time.Millisecond); ready.Store(true) }()
	ok := assert.Eventually(t, ready.Load, time.Second, 20*time.Millisecond, "never ready")
	t.Logf("eventually: %v", ok)
}

func TestEventuallyFails(t *testing.T) {
	var ready atomic.Bool
	assert.Eventually(t, ready.Load, 150*time.Millisecond, 20*time.Millisecond, "service never became ready")
}

func TestNeverHolds(t *testing.T) {
	var failed atomic.Bool
	ok := assert.Never(t, failed.Load, 150*time.Millisecond, 20*time.Millisecond)
	t.Logf("never: %v", ok)
}

func TestEventuallyWithT(t *testing.T) {
	var n atomic.Int32
	go func() {
		for range 5 {
			time.Sleep(30 * time.Millisecond)
			n.Add(1)
		}
	}()
	assert.EventuallyWithT(t, func(c *assert.CollectT) {
		assert.GreaterOrEqual(c, n.Load(), int32(5))
	}, time.Second, 20*time.Millisecond)
	t.Logf("count reached %d", n.Load())
}

func main() {}
Shell
go test -count=1 -v . 2>&1 | grep -vE '^(=== RUN|=== PAUSE|=== CONT|=== NAME|PASS|ok|FAIL$|FAIL\s|exit status)' | sed -E 's/ \([0-9.]+s\)//; s#^ +[a-z_0-9]+\.go:[0-9]+: #    #; s/\x1b\[[0-9;]*m//g; s#/[^[:space:]]*/([a-z_0-9]+\.go)#\1#g; s/[[:space:]]+$//' | grep -vE '^\s*$'
Text
    eventually: true
--- PASS: TestEventuallyPasses
        	Error Trace:	ev_test.go:20
        	Error:      	Condition never satisfied
        	Test:       	TestEventuallyFails
        	Messages:   	service never became ready
--- FAIL: TestEventuallyFails
    never: true
--- PASS: TestNeverHolds
    count reached 5
--- PASS: TestEventuallyWithT

Eventually

assert.Eventually(t, condition, waitFor, tick, msgAndArgs...) runs condition every tick for up to waitFor. If it returns true, the assertion passes at once, so a fast service makes the test fast. In TestEventuallyPasses a goroutine sets a flag after 120 ms, and the assertion polls every 20 ms with a one second limit; it passes after about 120 ms.

When the condition never holds, the test fails after waitFor: Condition never satisfied, followed by your message, service never became ready. Always pass a message that names what you were waiting for.

Never

assert.Never is the mirror: it passes if the condition stays false for the whole window and fails the moment it becomes true. It proves absence only within the window, 150 ms here, so use it for conditions that would be violated promptly, as in the goroutine testing article.

EventuallyWithT

When the thing you wait for is a set of assertions, a bare boolean is awkward. EventuallyWithT passes the condition a *assert.CollectT; assertions on it do not fail the test, they make the attempt fail, and the next tick tries again. In the lab, assert.GreaterOrEqual(c, n.Load(), int32(5)) fails until the counter reaches 5, then the test passes and logs count reached 5. On timeout, the failures of the last attempt are reported.

Choosing the numbers

Two numbers matter, and both are easy to pick badly. The tick is how often the condition runs. A tick of 10 to 50 milliseconds balances responsiveness against load; a tick of 1 ms burns CPU and, for conditions that take a lock, adds contention. The wait is the longest you will wait for the thing to happen. Make it generous, ten times the normal delay or more, because it only costs time when something is already wrong. A wait of 150 ms in the lab is for brevity; in a real suite use a second or more so a loaded CI runner does not turn a pass into a failure.

A failure after the wait is the point at which a test should tell you what is wrong, so put the state in the message. assert.Eventually(t, c, d, tick, "status is %q", status.Load()) runs the formatting before the wait, so it shows the first value; to report the last, use EventuallyWithT and put the state into a failed assertion inside the condition.

Compared with the alternatives

A time.Sleep followed by an assertion is a guess that is both slow and flaky. A channel or WaitGroup is better when the code under test offers one, because it has no polling and no deadline to tune. Polling is for state without an event: a metric that updates, a file that appears, a status field in a database, a background cache refresh. synctest replaces polling for code driven by timers, because virtual time makes the wait exact. Use Eventually where none of those fit, and keep the conditions cheap and free of side effects, since the helper may call them dozens of times.

Cautions

The condition runs in a separate goroutine from the test, so shared state needs sync/atomic or a lock; the lab uses atomic.Bool and atomic.Int32, and the race detector will flag plain variables. Keep ticks coarse, 10 to 50 ms, and deadlines generous, since a deadline only matters when something is broken. Prefer a real signal, a channel or a WaitGroup, when the code offers one, and use polling for state that has no event. For code driven by timers, testing/synctest gives exact control; see stopping flaky time tests. The functions are documented in the assert package.

Taggedquick tip
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.