Stop Flaky Time Based Tests in Go for Good With Clocks
Remove sleeps from Go tests: pass a clock function, advance a fake one by hand, or run the code in a testing/synctest bubble where a day passes instantly.

Time is the input a test is worst at controlling. A test that sleeps for a TTL to expire takes that long every run. A test that compares time.Now() with a threshold is wrong the one run in a thousand when a second rolls over in the middle. A test that sleeps a little to let a goroutine finish passes on a laptop and fails on a loaded runner. All three are the same mistake: letting real time into the test.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
There are two ways to take it out, and Go 1.25 has made the second a first-class option. This article uses a TTL cache as the example and shows both approaches on Go 1.25.6.
Make the time an input
package main
import (
"sync"
"time"
)
type entry struct {
val string
expires time.Time
}
// Cache expires entries after ttl. now is the only way it reads the time.
type Cache struct {
mu sync.Mutex
ttl time.Duration
now func() time.Time
m map[string]entry
}
func NewCache(ttl time.Duration, now func() time.Time) *Cache {
return &Cache{ttl: ttl, now: now, m: map[string]entry{}}
}
func (c *Cache) Set(k, v string) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[k] = entry{v, c.now().Add(c.ttl)}
}
func (c *Cache) Get(k string) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.m[k]
if !ok || !c.now().Before(e.expires) {
return "", false
}
return e.val, true
}
func main() {}Cache reads the time through one field, now func() time.Time. Production passes time.Now. A test passes something it controls. A function value is the lightest way to inject a clock: no interface, no dependency, one extra constructor parameter. The same idea, in more detail and with the common variations, is in the time dependent code article, and the broader family of fakes for non-determinism is in test determinism.
package main
import (
"sync"
"testing"
"testing/synctest"
"time"
)
type fakeClock struct {
mu sync.Mutex
t time.Time
}
func (f *fakeClock) Now() time.Time { f.mu.Lock(); defer f.mu.Unlock(); return f.t }
func (f *fakeClock) Advance(d time.Duration) { f.mu.Lock(); defer f.mu.Unlock(); f.t = f.t.Add(d) }
func TestExpiryWithFakeClock(t *testing.T) {
clk := &fakeClock{t: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
c := NewCache(time.Hour, clk.Now)
c.Set("a", "1")
clk.Advance(59*time.Minute + 59*time.Second)
_, ok := c.Get("a")
t.Logf("59m59s later: present=%v", ok)
clk.Advance(time.Second)
_, ok = c.Get("a")
t.Logf("exactly 1h later: present=%v", ok)
}
// The same behaviour with the real time package, inside a synctest bubble: the clock is virtual.
func TestExpiryWithSynctest(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()
c := NewCache(time.Hour, time.Now)
c.Set("a", "1")
time.Sleep(time.Hour - time.Second)
_, ok := c.Get("a")
t.Logf("after sleeping 59m59s: present=%v", ok)
time.Sleep(time.Second)
_, ok = c.Get("a")
t.Logf("after sleeping 1h: present=%v, virtual time elapsed=%v", ok, time.Since(start))
})
}
func TestBackgroundWorkInBubble(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
done := make(chan struct{})
go func() {
time.Sleep(24 * time.Hour)
close(done)
}()
synctest.Wait()
select {
case <-done:
t.Fatal("finished too early")
default:
}
time.Sleep(24 * time.Hour)
<-done
t.Log("a day of work finished in the bubble")
})
}go test -count=1 -v . 2>&1 | grep -vE '^(=== RUN|=== PAUSE|=== CONT|PASS|ok)' | sed -E 's/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: # #'
echo "wall time of the whole run:"
( time go test -count=1 . >/dev/null ) 2>&1 | grep real | awk '{print "wall clock:", $2}' 59m59s later: present=true
exactly 1h later: present=false
--- PASS: TestExpiryWithFakeClock
after sleeping 59m59s: present=true
after sleeping 1h: present=false, virtual time elapsed=1h0m0s
--- PASS: TestExpiryWithSynctest
a day of work finished in the bubble
--- PASS: TestBackgroundWorkInBubble
wall time of the whole run:
wall clock: 0m0.323sThe fake clock
fakeClock stores a time.Time behind a mutex, because the code under test may call it from several goroutines, and exposes Now and Advance. TestExpiryWithFakeClock sets a value with a one hour TTL, moves the clock forward by 59 minutes and 59 seconds, and finds the entry present. It advances one more second, to exactly one hour later, and finds it gone, because the cache treats the expiry instant as expired.
That is a boundary test, and it is the kind real time makes impossible. With a sleeping test you cannot ask "what happens exactly at the TTL" and you cannot repeat it. With a fake you can ask at any instant, in any order, and the answer never changes. The fixed start date also removes the dependence on when the test runs, so a rule such as "midnight in a timezone" is testable at midnight, whatever the actual time is.
The costs are real. Every function that needs the time must receive it, which means a parameter or a field on the types that use it. Code that calls time.Sleep, time.After or time.NewTimer directly is not covered by a now function: those still wait in real time. A fake clock for those has to implement timers and tickers as well, and the third-party packages that do so are sizeable. For code built around timers, there is a better answer.
A virtual clock for the real package
testing/synctest was experimental in Go 1.24 and became stable in Go 1.25. synctest.Test(t, func(t *testing.T) {...}) runs the function in a bubble. Inside it, goroutines started by the function belong to the bubble, and the time package uses a virtual clock. The clock starts at midnight UTC on 2000-01-01 and advances only when every goroutine in the bubble is blocked, jumping straight to the next timer.
TestExpiryWithSynctest uses time.Now unchanged. It sets a value, calls time.Sleep(time.Hour - time.Second) and finds the entry present; sleeps one more second and finds it gone. The log line shows virtual time elapsed=1h0m0s: an hour passed, measured by time.Since, and the test did not wait for it. The cache did not need a clock parameter, because the clock it reads is the virtual one.
TestBackgroundWorkInBubble shows the other half. A goroutine sleeps for 24 hours and closes a channel. synctest.Wait() blocks until every goroutine in the bubble is durably blocked, so the test can assert that the work has not finished, with no sleeping and no race. Then it sleeps 24 hours, which advances the clock to the goroutine's wake time, and receives from the channel. The log reads a day of work finished in the bubble. The whole file ran in roughly a third of a second of wall clock time, including compilation and start-up of the test binary.
Compare that with the sleep-based version of the same assertions: the first test alone would have taken an hour.
What counts as blocked
A goroutine is "durably blocked" in a bubble when it is waiting on something only another goroutine in the bubble can release: a channel operation, sync.WaitGroup.Wait, sync.Cond.Wait, time.Sleep, and a select with those. The clock jumps only when all of them are. Two kinds of blocking do not qualify, and they are where synctest stops being useful.
- I/O. A goroutine reading from a network connection or a file is not durably blocked, because something outside the bubble may wake it. A test with a real TCP connection will not advance the clock;
net.Pipeis an in-memory connection that works in a bubble. - Mutexes. Waiting for a
sync.Mutexis not counted, since the holder might be outside. Code that blocks on one is fine as long as the lock is released by something that runs.
If the bubble deadlocks with every goroutine durably blocked and no timer pending, synctest.Test panics with a deadlock message. That is useful: a missing wake-up becomes a failure instead of a hang.
Choosing between them
| Injected clock | synctest | |
|---|---|---|
| Changes production code | Yes: a now parameter | No |
Works with time.Sleep, After, Ticker | Only with a fake that implements timers | Yes |
| Real network and file I/O | Yes | No: not durably blocked |
| Controls the instant exactly | Yes, to the nanosecond | Yes, through sleeps |
| Go version | Any | 1.25 or later |
| Shows unexpected goroutines | No | Yes: a leaked goroutine blocks the bubble from exiting |
Use synctest when the code is built around timers, tickers, timeouts, retries and background goroutines, which is most concurrent code, and you are on Go 1.25 or later. Use an injected clock when the code is a pure function of the time, such as a rate limiter or a token expiry check, or when you need to run on an older Go. Both can live in one project.
Whichever you pick, ban real sleeps from tests, and when a sleep is genuinely unavoidable, for example while talking to a real database, make it a poll for a condition with a deadline. The flaky tests guide gives the method to measure how much a sleep hurts.
Limits of the approach
A fake clock tests your logic, not the system's. It will not show that the real clock jumped backwards after an NTP correction, that a monotonic reading and a wall clock reading disagree, or that a daylight saving transition shifted your timestamp. Test those with explicit values in a time zone chosen to show the problem. Virtual time also hides timing performance: a bubble that finishes a day of work in a millisecond tells you nothing about how long it takes to do the work. The Go blog explains the design in its synctest article, and the package reference is at testing/synctest.


