Test Time Dependent Code in Go With an Injected Clock
Test expiry, TTLs and timeouts in Go without sleeping: inject a one-method clock, advance a fake by hand, or let testing/synctest fake time for real calls.

Code that depends on the current time is hard to test for one reason: the test does not control it. A session that expires after 30 minutes either needs a 30-minute test, a time.Sleep that makes the suite slow and flaky, or a way to move the clock. Go gives you two good ways, and which to use depends on whether you can change the code.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
Both appear below, run on Go 1.25.6. If the clock decides when a context deadline fires, the same fake time applies, and a data race in the fake itself is the first bug to avoid. They sit next to testing with small interfaces, which is the same dependency-injection idea applied to a different dependency.
The code that needs a clock
A session store has a time-to-live. Whether a token is valid depends on what time it is now, so Valid has to ask.
package main
import (
"sync"
"time"
)
// Clock is the only part of the time package the code needs.
type Clock interface {
Now() time.Time
}
type realClock struct{}
func (realClock) Now() time.Time { return time.Now() }
type Session struct {
Token string
Expires time.Time
}
type Sessions struct {
clock Clock
ttl time.Duration
mu sync.Mutex
items map[string]Session
}
func NewSessions(c Clock, ttl time.Duration) *Sessions {
return &Sessions{clock: c, ttl: ttl, items: map[string]Session{}}
}
func (s *Sessions) Create(token string) Session {
s.mu.Lock()
defer s.mu.Unlock()
sess := Session{Token: token, Expires: s.clock.Now().Add(s.ttl)}
s.items[token] = sess
return sess
}
// Valid reports whether the token exists and has not expired.
func (s *Sessions) Valid(token string) bool {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.items[token]
return ok && s.clock.Now().Before(sess.Expires)
}
func main() { _ = realClock{} }Clock is a one-method interface, the smallest thing the store needs from the time package. Production code passes realClock{}, which calls time.Now. The store never calls time.Now itself. That is the whole change, and it is the difference between a testable type and one that needs a sleeping test.
There is a cheaper variant for small cases: store a now func() time.Time field, defaulting to time.Now, and let the test overwrite it. It saves the interface and the extra type. I prefer the interface when the clock is passed across several components, because a named type documents the dependency and gives a place for a After or NewTimer method later.
A fake clock you advance by hand
The test double is a struct holding a time and a method to move it:
package main
import (
"sync"
"testing"
"testing/synctest"
"time"
)
type fakeClock struct {
mu sync.Mutex
now time.Time
}
func newFakeClock() *fakeClock {
return &fakeClock{now: time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC)}
}
func (f *fakeClock) Now() time.Time {
f.mu.Lock()
defer f.mu.Unlock()
return f.now
}
func (f *fakeClock) Advance(d time.Duration) {
f.mu.Lock()
defer f.mu.Unlock()
f.now = f.now.Add(d)
}
func TestSessionExpiry(t *testing.T) {
clock := newFakeClock()
s := NewSessions(clock, 30*time.Minute)
s.Create("abc")
steps := []struct {
advance time.Duration
want bool
}{
{0, true},
{29 * time.Minute, true},
{time.Minute - time.Nanosecond, true},
{time.Nanosecond, false}, // exactly at Expires: Before is false
{24 * time.Hour, false},
}
for _, st := range steps {
clock.Advance(st.advance)
if got := s.Valid("abc"); got != st.want {
t.Errorf("after +%v (now %s): Valid = %v, want %v", st.advance, clock.Now().Format("15:04:05.000000000"), got, st.want)
}
}
}
// synctest gives code that really calls time.Now and time.Sleep a fake clock.
func TestSessionExpirySynctest(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
s := NewSessions(realClock{}, 30*time.Minute)
s.Create("abc")
time.Sleep(29 * time.Minute)
if !s.Valid("abc") {
t.Fatal("expired too early")
}
time.Sleep(time.Minute)
if s.Valid("abc") {
t.Fatal("still valid at the TTL")
}
})
}
func TestSynctestIsInstant(t *testing.T) {
start := time.Now()
synctest.Test(t, func(t *testing.T) {
t0 := time.Now()
time.Sleep(48 * time.Hour)
t.Logf("fake elapsed: %v", time.Since(t0))
})
if real := time.Since(start); real > time.Second {
t.Fatalf("took %v of real time", real)
}
}Read the table in TestSessionExpiry. The clock starts at a fixed instant, so nothing depends on the day the test runs. Each step advances by a duration and asserts the answer. The steps are chosen at the edges:
0checks that a new session is valid immediately.29 * time.Minuteis comfortably inside the window.time.Minute - time.Nanosecondtakes the clock to one nanosecond before expiry.time.Nanosecondlands exactly on the expiry instant.24 * time.Houris well past it.
The fourth row is where bugs live. s.clock.Now().Before(sess.Expires) is false at the instant equal to Expires, so a token is invalid at exactly 30 minutes after creation. If a product requirement says "valid for 30 minutes", is the 30-minute mark inside or outside? The test forces you to decide, and writes the decision down. A time.Sleep(30 * time.Minute) test would have been too slow to run and too imprecise to find this.
The fake has a mutex because the code under test reads the clock from whatever goroutine calls it. In this test everything is sequential, but a store used by an HTTP server is not, and a test with -race would flag an unsynchronised field. Add the mutex from the start rather than after the first race report.
Pin the starting time to a literal like time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC). A test that starts from time.Now() passes on most days and fails when it runs near midnight, at the end of a month, or when daylight saving shifts. That kind of failure is hard to reproduce, which is what makes it expensive.
When you cannot change the code: testing/synctest
Sometimes the code already calls time.Now, time.Sleep, time.After or time.NewTicker directly, and threading a clock through it is a large change or code you do not own. Go 1.24 introduced testing/synctest as an experiment and Go 1.25 made it generally available. synctest.Test runs a function in a "bubble" where time is fake.
In the second test above the store is built with realClock{}, which calls the real time.Now. Inside the bubble that call returns the bubble's fake time. time.Sleep(29 * time.Minute) does not wait: the bubble's clock jumps forward when every goroutine in it is blocked, so the sleep returns at once. The same run shows how large the effect is, in the third test:
go test -count=1 -v . 2>&1 | grep -vE '^(=== RUN|PASS|ok)' | sed -E 's/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: # #'--- PASS: TestSessionExpiry
--- PASS: TestSessionExpirySynctest
fake elapsed: 48h0m0s
--- PASS: TestSynctestIsInstantThe test slept for 48 hours and logged fake elapsed: 48h0m0s, and the assertion that real time stayed under a second held. Nothing in the log is a timing I measured. The point is that it finished at all in a normal test run.
The bubble has rules that the documentation spells out. Time inside starts at midnight UTC on 1 January 2000. The clock advances only when every goroutine in the bubble is durably blocked, for example on a channel operation, a sync.WaitGroup.Wait or time.Sleep. A goroutine blocked on a network call or a file read outside the bubble is not durably blocked, so time will not move. synctest.Wait lets a test wait until all other goroutines in the bubble have settled before asserting. The testing/synctest package page has the full list.
Which one to use
| Situation | Reach for |
|---|---|
| You are writing new code and own the types | an injected Clock |
The code calls the time package directly | synctest |
You need to assert on specific timestamps, such as a stored Expires | an injected Clock with a fixed start |
The code uses timers, tickers, or context.WithTimeout | synctest |
| The logic runs in several packages | a shared Clock interface in one place |
The injected clock makes exact values easy to check, because you chose the starting instant. synctest makes waiting free, but the starting instant is the fixed fake epoch, which is rarely what a business rule wants to see. For the typical expiry check, the injected clock is clearer. For a retry loop with backoff, a rate limiter or a heartbeat, synctest removes the sleeps without touching the code.
A few habits keep both honest. Never assert on elapsed real time with a tight limit, because a loaded CI machine will break it. Test the boundary as its own row, as above. Keep the clock interface at the edge of your design, passed in once, rather than reaching for a package-level variable such as var now = time.Now, which makes parallel tests step on each other. And keep one or two tests with the real clock at the integration level, so you know the wiring in main is right.
The time package and testing/synctest are documented at pkg.go.dev/time and pkg.go.dev/testing/synctest, and the Go team's introduction is on go.dev/blog/testing-time.


