Testing Async Code with synctest

Updated

September 13, 2026

Testing Async Code with synctest

The last chapter used a Clock interface when you own time. Often the code under test already calls time.Sleep, time.After, or context.WithTimeout. Do not rewrite production APIs just to make a test finish. Prefer testing/synctest: run the test in a bubble with a fake clock so five seconds of deadline logic finishes in a blink. Stable. Fast. No GOEXPERIMENT on Go 1.25+ (this book is on 1.27).

Mental model

  • synctest.Test(t, f) runs f inside a new bubble. Goroutines started in f stay in that bubble.
  • Inside a bubble, time uses a fake clock. It always starts at midnight UTC 2000-01-01.
  • Time advances only when every goroutine in the bubble is durably blocked (waiting on something only another bubble goroutine can unblock): time.Sleep, channel send/receive on channels created in the bubble, WaitGroup.Wait tied to the bubble, and a few related cases.
  • synctest.Wait() blocks until every other goroutine in the bubble is durably blocked. Use it after you expect background work (or a timer callback) to have run.
  • t.Context() inside the bubble is tied to the bubble. Prefer it over context.Background() in these tests.
  • Real network sockets are not durably blocking. Prefer net.Pipe, httptest, or fakes — or use httptest.NewTestServer (Go 1.27) when you need HTTP inside a bubble.
  • Go 1.27 adds synctest.Sleep(d): time.Sleep(d) plus Wait in one call. Use it when you only need “advance and quiesce.”

A Clock interface is still the right boring default when your package decides what “now” means. Reach for synctest when the standard library’s timers are already on the path.

Worked examples

Case 1: A desk hold that times out

HoldTicket blocks until the context is done. The test proves it still holds just before the deadline and returns context.DeadlineExceeded after.

Empty directory:

go mod init desk

Save as hold.go:

// hold.go
package desk

import (
    "context"
)

// HoldTicket blocks until ctx is done, then returns ctx.Err().
func HoldTicket(ctx context.Context) error {
    <-ctx.Done()
    return ctx.Err()
}

Save as hold_test.go:

// hold_test.go
package desk

import (
    "context"
    "testing"
    "testing/synctest"
    "time"
)

func TestHoldTicketTimesOut(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        const timeout = 5 * time.Second
        ctx, cancel := context.WithTimeout(t.Context(), timeout)
        defer cancel()

        errCh := make(chan error, 1)
        go func() {
            errCh <- HoldTicket(ctx)
        }()

        // Just before the deadline: still holding.
        time.Sleep(timeout - time.Nanosecond)
        synctest.Wait()
        select {
        case err := <-errCh:
            t.Fatalf("ticket released early: %v", err)
        default:
        }

        // Cross the deadline (fake clock — runs instantly).
        time.Sleep(time.Nanosecond)
        synctest.Wait()
        if err := <-errCh; err != context.DeadlineExceeded {
            t.Fatalf("got %v, want DeadlineExceeded", err)
        }
    })
}

Run:

go test

Output (the duration varies):

PASS
ok      desk    0.004s

Five seconds of timeout logic, milliseconds of wall clock. The Wait after each Sleep lets the context package’s timer goroutines finish before you assert.

Case 2: Wait before you assert

Without Wait, a test can race the background goroutine. After cancel, wait for quiescence, then check the flag.

Save as hold_wait_test.go (same module as Case 1):

// hold_wait_test.go
package desk

import (
    "testing"
    "testing/synctest"
)

func TestHoldTicketReleasesOnCancel(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        released := false
        ctx, cancel := context.WithCancel(t.Context())

        go func() {
            _ = HoldTicket(ctx)
            released = true
        }()

        synctest.Wait()
        if released {
            t.Fatal("released before cancel")
        }

        cancel()
        synctest.Wait()
        if !released {
            t.Fatal("not released after cancel")
        }
    })
}

Run:

go test

Output (the duration varies):

PASS
ok      desk    0.003s

Wait is not a sleep. It means “every other goroutine in this bubble is stuck waiting on something inside the bubble.”

Case 3: synctest.Sleep (Go 1.27)

When the pattern is always “sleep, then wait for the bubble to settle,” use the helper.

Save as hold_sleep_test.go:

// hold_sleep_test.go
package desk

import (
    "testing"
    "testing/synctest"
    "time"
)

func TestSynctestSleepAdvancesClock(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        start := time.Now() // midnight UTC 2000-01-01 in the bubble
        done := false
        go func() {
            time.Sleep(2 * time.Second)
            done = true
        }()

        synctest.Sleep(2 * time.Second) // Sleep + Wait
        if !done {
            t.Fatal("background sleep not finished")
        }
        if got := time.Since(start); got != 2*time.Second {
            t.Fatalf("since start = %v, want 2s", got)
        }
    })
}

Run:

go test -run TestSynctestSleepAdvancesClock

Output (the duration varies):

PASS
ok      desk    0.002s

Prefer the explicit time.Sleep + synctest.Wait pair when you need an assert between advancing the clock and waiting. Prefer synctest.Sleep when you only need both.

The trap

A wall-clock sleep outside a bubble is slow and flaky. The “test” below can pass on a quiet laptop and fail under load — and it wastes two seconds every run.

Save as trap_test.go (do not keep this pattern):

// trap_test.go
package desk

import (
    "context"
    "testing"
    "time"
)

func TestHoldTicketWallClockTrap(t *testing.T) {
    t.Skip("trap demo — slow and flaky; use synctest instead")

    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    errCh := make(chan error, 1)
    go func() {
        errCh <- HoldTicket(ctx)
    }()

    time.Sleep(3 * time.Second) // real wall clock
    if err := <-errCh; err != context.DeadlineExceeded {
        t.Fatalf("got %v, want DeadlineExceeded", err)
    }
}

Run (shows the skip):

go test -run TestHoldTicketWallClockTrap -v

Output:

=== RUN   TestHoldTicketWallClockTrap
    trap_test.go:11: trap demo — slow and flaky; use synctest instead
--- SKIP: TestHoldTicketWallClockTrap (0.00s)
PASS
ok      desk    0.002s

Second trap: blocking on a real network socket inside a bubble. That wait is not durably blocked, so Wait and the fake clock can deadlock or hang. Use net.Pipe, in-memory fakes, or httptest.NewTestServer (1.27) when HTTP must live in the bubble.

The boring rule

  • Prefer sync APIs when you control the design. Prefer synctest when time / context timers are already in the path.
  • Always wrap async timer tests in synctest.Test. Use t.Context() inside the bubble.
  • After you expect background work or a timer to fire, call synctest.Wait (or synctest.Sleep) before asserting.
  • Keep a Clock interface when your package owns “now.” Do not invent a clock just to avoid learning synctest.
  • Do not use GOEXPERIMENT=synctest. That was for the 1.24 experiment. On 1.25+ the package is ordinary stdlib.
  • Do not dial the real network inside a bubble. Fake the wire.

Try this

  1. Start from Case 1. Before the deadline, assert ctx.Err() == nil on the parent context (not only that errCh is empty).
  2. Replace WithTimeout with WithCancel and context.AfterFunc that sets a bool. Cancel, Wait, assert the flag — same shape as the package docs.
  3. Change Case 3 to synctest.Sleep(1 * time.Second) while the background goroutine sleeps two seconds. Predict whether done is true, run the test, then fix the sleep so it passes.
  4. Optional: move Case 1’s two-step sleep into one synctest.Sleep(timeout) and assert only after the deadline. What coverage did you lose?