Skip to content
gohacks.dev
Testing

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.

7 min read
Summarize in AI
Flat illustration of a test report with one failing test.

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.

session.goGo
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:

session_test.goGo
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:

  • 0 checks that a new session is valid immediately.
  • 29 * time.Minute is comfortably inside the window.
  • time.Minute - time.Nanosecond takes the clock to one nanosecond before expiry.
  • time.Nanosecond lands exactly on the expiry instant.
  • 24 * time.Hour is 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:

Shell
go test -count=1 -v . 2>&1 | grep -vE '^(=== RUN|PASS|ok)' | sed -E 's/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: #    #'
Text
--- PASS: TestSessionExpiry
--- PASS: TestSessionExpirySynctest
    fake elapsed: 48h0m0s
--- PASS: TestSynctestIsInstant

The 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

SituationReach for
You are writing new code and own the typesan injected Clock
The code calls the time package directlysynctest
You need to assert on specific timestamps, such as a stored Expiresan injected Clock with a fixed start
The code uses timers, tickers, or context.WithTimeoutsynctest
The logic runs in several packagesa 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.

Also inBasics
Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.