synctest makes time virtual¶
Level: 201 · anyone whose tests sleep, or wait out a timeout
One line: Inside synctest.Test, the time package reads a fake clock that moves only when every goroutine in the test's bubble is blocked — so a test that waits out a one-hour timeout passes at once, and a loop that keeps its goroutine busy takes no time at all.
The question¶
Code with a timeout is awkward to test. Here is a function that waits for a reply and gives up after a while — the select with a time.After case from A timeout is a channel:
// fetch waits for a reply, and gives up after timeout.
func fetch(replies <-chan string, timeout time.Duration) (string, error) {
select {
case reply := <-replies:
return reply, nil
case <-time.After(timeout):
return "", fmt.Errorf("no reply within %v", timeout)
}
}
With a timeout of an hour, a test of the path that gives up takes an hour. Shrinking the timeout to a few milliseconds makes the test fast, and turns it into a bet on how soon the machine gets round to the goroutine that replies.
testing/synctest ↗, generally available since Go 1.25 ↗, keeps the hour. synctest.Test(t, f) runs f in a bubble: every goroutine started inside it belongs to it, and inside it the time package reads a clock of the bubble's own. The script below writes three tests into a throwaway module and runs them:
Verified output of synctest_makes_time_virtual_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ go mod init example
$ go test -v -count=1
=== RUN TestSilentServerTimesOut
fetch_test.go:23: the bubble's clock starts at 2000-01-01 00:00:00 +0000 UTC
fetch_test.go:30: fetch returned "no reply within 1h0m0s" after 1h0m0s
--- PASS: TestSilentServerTimesOut
=== RUN TestSlowReplyBeatsTimeout
fetch_test.go:46: fetch returned "order 1042 shipped" after 30m0s
--- PASS: TestSlowReplyBeatsTimeout
=== RUN TestBusyWorkTakesNoTime
fetch_test.go:57: adding up 100,000,000 numbers (total 4999999950000000) took 0s
--- PASS: TestBusyWorkTakesNoTime
PASS
ok example
exit status 0
the test binary ran for under 10 s of wall time: true
Reading the output¶
the bubble's clock starts at 2000-01-01 00:00:00 +0000 UTC. Whatever the date on the machine, the package documentation ↗ starts every bubble's clock at midnight UTC on 1 January 2000. The test prints it with .UTC() so that the line does not depend on the machine's time zone.
fetch returned "no reply within 1h0m0s" after 1h0m0s. Nobody sends on replies, so the test's only goroutine blocked in select. With every goroutine in the bubble blocked, the clock jumped to the next moment at which one of them would wake — time.After's timer, an hour on — and time.Since(start) reports exactly 1h0m0s.
fetch returned "order 1042 shipped" after 30m0s. Two goroutines this time, and two timers: the sender's 30-minute time.Sleep and the one-hour timeout inside fetch. Once both goroutines were blocked, the clock went to the earlier timer, the sender woke and sent, and fetch returned the reply. The one-hour timer never fired.
adding up 100,000,000 numbers (total 4999999950000000) took 0s. A hundred million additions and no time at all. The goroutine never blocked, so the clock never moved: bubble time counts waiting, not work.
the test binary ran for under 10 s of wall time: true. Outside the bubble the clock is real. go test prints how long the test binary ran after ok example; the script removes that number from the transcript, because it changes from run to run, and holds it to a threshold instead. An hour and a half of timeouts fits under ten seconds with room to spare.
synctest_makes_time_virtual_sh.sh
synctest_makes_time_virtual_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.
#!/usr/bin/env bash
# testing/synctest: inside a bubble, the time package reads a fake clock that
# moves only when every goroutine in the bubble is blocked. A test that waits
# out a one-hour timeout passes at once. go test prints how long each test and
# the whole run took, which varies, so this script removes those durations and
# prints a threshold instead.
set -u
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1
export GOTOOLCHAIN=local
cat >fetch_test.go <<'GO'
package example
import (
"fmt"
"testing"
"testing/synctest"
"time"
)
// fetch waits for a reply, and gives up after timeout.
func fetch(replies <-chan string, timeout time.Duration) (string, error) {
select {
case reply := <-replies:
return reply, nil
case <-time.After(timeout):
return "", fmt.Errorf("no reply within %v", timeout)
}
}
func TestSilentServerTimesOut(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()
t.Log("the bubble's clock starts at", start.UTC())
replies := make(chan string) // nobody ever replies
_, err := fetch(replies, time.Hour)
if err == nil {
t.Fatal("fetch returned without an error")
}
t.Logf("fetch returned %q after %v", err, time.Since(start))
})
}
func TestSlowReplyBeatsTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()
replies := make(chan string)
go func() {
time.Sleep(30 * time.Minute)
replies <- "order 1042 shipped"
}()
reply, err := fetch(replies, time.Hour)
if err != nil {
t.Fatal(err)
}
t.Logf("fetch returned %q after %v", reply, time.Since(start))
})
}
func TestBusyWorkTakesNoTime(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()
total := 0
for n := range 100_000_000 {
total += n
}
t.Logf("adding up 100,000,000 numbers (total %d) took %v", total, time.Since(start))
})
}
GO
say() { printf '$ %s\n' "$*"; }
say 'go mod init example'
go mod init example 2>/dev/null || exit 1
say 'go test -v -count=1'
go test -v -count=1 >test.txt 2>&1
status=$?
# Drop "(0.03s)" after each test's name and the time at the end of "ok example".
sed -E 's/ \([0-9.]+s\)$//; s/^(ok|FAIL)([[:space:]]+example)[[:space:]]+[0-9.]+s$/\1\2/' test.txt
echo "exit status $status"
# The time go test printed after "ok example" is how long the test binary ran.
seconds=$(awk '$1 == "ok" { sub(/s$/, "", $3); print $3 }' test.txt)
awk -v s="$seconds" 'BEGIN { print "the test binary ran for under 10 s of wall time: " (s != "" && s + 0 < 10 ? "true" : "false") }'
What moves the clock¶
The package documentation states the rule in terms of goroutines that are durably blocked: blocked in a way that only another goroutine in the same bubble can end. When every goroutine in a bubble is durably blocked:
- a pending
synctest.Waitreturns — that is the next lesson; - otherwise the clock advances to the next time that will unblock a goroutine, if there is one;
- otherwise the bubble is deadlocked, and the test fails.
Durably blocking: a send or receive on a channel created inside the bubble, a select whose cases are all such channels, sync.Cond.Wait, sync.WaitGroup.Wait when Add was called inside the bubble, and time.Sleep. Not durably blocking, because something outside the bubble could end them: locking a sync.Mutex or sync.RWMutex, I/O such as reading from a network socket, and system calls. While a goroutine waits on one of those, not every goroutine is durably blocked, so the clock stays where it is.
Three consequences, all from the same documentation:
- What the test waits on belongs inside the bubble. A channel,
time.Timerortime.Tickercreated in a bubble is tied to it, and using one from outside panics. The documentation's HTTP example connects client and server withnet.Piperather than a loopback connection, because goroutines blocked on network I/O keep a bubble from ever becoming idle. synctest.Testwaits for every goroutine it started. It returns only after all goroutines in the bubble have exited.- A bubble runs one test. Inside
f, the*testing.Tmust not callRun,ParallelorDeadline.
Go 1.24 shipped the package as an experiment behind GOEXPERIMENT=synctest, with a function named Run where Go 1.25 has Test. The Go 1.25 release notes ↗ say the old API remains only under that setting, and will be removed in Go 1.26.
What to do¶
- Test timeouts, deadlines, retries and tickers at their real durations, inside
synctest.Test, instead of shrinking them to keep the suite fast. The deadline version of this page'sfetchis A deadline is a cancel with a clock. - Assert on elapsed time with
==. In a bubble,time.Sincereturns exactly how long the code waited:1h0m0s,30m0sand0sabove. - Give the goroutines channels and pipes created in the bubble, not network connections, so that waiting on them counts as blocked.
- When the test must see that a goroutine has reacted, add
synctest.Wait— the next lesson.
In other languages¶
- Concurrency library — chapter 09, Testing and tools (ThreadSanitizer, Go's race detector, stress tests, deterministic schedulers), is planned; it has no page on fake clocks yet.
- Rust — the Rust library's Where a test goes ↗ shows the standard harness running tests in parallel; a bubble is the opposite arrangement, since
t.Parallelis forbidden inside one. The nearest counterpart to a bubble belongs to an async runtime rather than the standard library: Tokio'stime::pause↗ freezes Tokio's clock and, when the runtime has no work to do, moves it straight to the next pending timer — the same rule, applied to tasks on a single-threaded runtime and to Tokio'sInstantonly, while the standard library'sInstantkeeps running. (Not machine-checked here.)
Sources¶
testing/synctest↗ — bubbles, the starting time, the durably blocking operations,TestandWait.- Go 1.25 release notes: New testing/synctest package ↗, and Go 1.24 release notes: testing/synctest ↗, where it was an experiment.
- Testing Time (and other asynchronicities) ↗ — Damien Neil, the Go blog, 26 August 2025: the package as it shipped in Go 1.25.
- Testing concurrent code with testing/synctest ↗ — Damien Neil, the Go blog, 19 February 2025: the Go 1.24 experiment, with
Runwhere Go 1.25 hasTest.