Skip to content

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:

  1. a pending synctest.Wait returns — that is the next lesson;
  2. otherwise the clock advances to the next time that will unblock a goroutine, if there is one;
  3. 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.Timer or time.Ticker created in a bubble is tied to it, and using one from outside panics. The documentation's HTTP example connects client and server with net.Pipe rather than a loopback connection, because goroutines blocked on network I/O keep a bubble from ever becoming idle.
  • synctest.Test waits 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.T must not call Run, Parallel or Deadline.

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's fetch is A deadline is a cancel with a clock.
  • Assert on elapsed time with ==. In a bubble, time.Since returns exactly how long the code waited: 1h0m0s, 30m0s and 0s above.
  • 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.Waitthe 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.Parallel is forbidden inside one. The nearest counterpart to a bubble belongs to an async runtime rather than the standard library: Tokio's time::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's Instant only, while the standard library's Instant keeps running. (Not machine-checked here.)

Sources