Skip to content
gohacks.dev
Testing

Test Doubles for Time Randomness and IDs in Go Code

Make Go tests deterministic by injecting the clock, ID generator and random source through one Env struct, with a fixed seed and the same output on every run.

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

A test that depends on the current time, on a random number or on a generated ID is a test of a different program each time it runs. Most of the time the difference does not matter, and then once in a thousand runs it does, and the failure cannot be reproduced. Determinism is the fix: every source of variation becomes an input, and the test chooses the values.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This guide shows the three standard sources, time, randomness and identifiers, and the small design that replaces each with a double, on Go 1.25.6. The related articles go deeper on single cases: time, seeds, and flaky time based tests. Here the aim is the shape that holds them together.

What counts as nondeterminism

Code can read the world in more ways than it appears.

SourceTypical callWhat goes wrong in a test
Wall clocktime.Now(), time.SinceExpectations tied to the date, midnight rollovers, DST
Timerstime.Sleep, time.After, time.NewTickerSlow tests and races with real elapsed time
Random numbersrand.Intn, rand.Float64A different path each run
Identifiersuuid.New(), ulid.Make()Cannot assert on a generated value
Map iterationfor k := range mOrder differs between runs, by design
Environmentos.Getenv, os.Hostname, the working directoryDiffers per machine
Concurrencygoroutine schedulingInterleaving differs

The first four are replaced by injection. Map order is fixed by sorting. The environment is passed in or set with t.Setenv. Scheduling is handled by synchronising on events and by testing/synctest, and no double can make it deterministic from outside.

One struct for the sources

order.goGo
package main

import (
	"fmt"
	"math/rand/v2"
	"time"
)

// Clock, IDs and randomness all come in through one struct, so a test can replace each.
type Env struct {
	Now   func() time.Time
	NewID func() string
	Rand  *rand.Rand
}

type Order struct {
	ID      string
	Created time.Time
	Coupon  string
}

func (e Env) NewOrder() Order {
	return Order{ID: e.NewID(), Created: e.Now(), Coupon: fmt.Sprintf("SAVE%02d", e.Rand.IntN(100))}
}

func Production() Env {
	n := 0
	return Env{
		Now:   time.Now,
		NewID: func() string { n++; return fmt.Sprintf("ord-%d-%d", time.Now().UnixNano()%1000, n) },
		Rand:  rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())),
	}
}

func main() {}

Env bundles three function values: Now, NewID and a *rand.Rand. NewOrder reads all three through the struct and nothing else. Production() wires the real ones in one place. That is the design: the code under test never calls time.Now or the global random functions directly, so there is nothing to patch.

Why a struct and not three parameters? Because the list grows. A fourth source, say the host name, becomes a fourth field and every function that already takes an Env gets it for free. A function that needs only the clock can take func() time.Time and nothing more; the struct is for the components that use several. Avoid a global var Now = time.Now that tests overwrite. It works until two tests run in parallel, and then they overwrite each other.

An interface is the alternative to function values. type Clock interface { Now() time.Time } is more ceremony for one method, and worth it when the double has behaviour, such as a fake clock that records timers. For a single call, a function is lighter.

Doubles for each source

order_test.goGo
package main

import (
	"fmt"
	"math/rand/v2"
	"testing"
	"time"
	_ "time/tzdata" // zone data is embedded, so the test does not depend on the machine
)

func testEnv() Env {
	n := 0
	return Env{
		Now:   func() time.Time { return time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) },
		NewID: func() string { n++; return fmt.Sprintf("ord-%03d", n) },
		Rand:  rand.New(rand.NewPCG(1, 2)), // fixed seed: the same coupons every run
	}
}

func TestDeterministicOrders(t *testing.T) {
	e := testEnv()
	for range 3 {
		o := e.NewOrder()
		t.Logf("%s %s %s", o.ID, o.Created.Format(time.RFC3339), o.Coupon)
	}
	// A second environment built the same way gives the same sequence.
	e2 := testEnv()
	t.Logf("again: %s", e2.NewOrder().Coupon)
}

func TestProductionVaries(t *testing.T) {
	a, b := Production().NewOrder(), Production().NewOrder()
	t.Logf("two production orders differ in coupon or id: %v", a.ID != b.ID || a.Coupon != b.Coupon)
}

func TestAcrossDaylightSaving(t *testing.T) {
	ny, err := time.LoadLocation("America/New_York")
	if err != nil {
		t.Fatal(err)
	}
	start := time.Date(2026, 3, 7, 12, 0, 0, 0, ny) // the day before clocks go forward
	t.Logf("start:          %s", start.Format("Mon 15:04 MST"))
	t.Logf("AddDate(0,0,1): %s", start.AddDate(0, 0, 1).Format("Mon 15:04 MST"))
	t.Logf("Add(24h):       %s", start.Add(24*time.Hour).Format("Mon 15:04 MST"))
}
Shell
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]+: #    #"
Text
    ord-001 2026-03-01T12:00:00Z SAVE76
    ord-002 2026-03-01T12:00:00Z SAVE61
    ord-003 2026-03-01T12:00:00Z SAVE78
    again: SAVE76
--- PASS: TestDeterministicOrders
    two production orders differ in coupon or id: true
--- PASS: TestProductionVaries
    start:          Sat 12:00 EST
    AddDate(0,0,1): Sun 12:00 EDT
    Add(24h):       Sun 13:00 EDT
--- PASS: TestAcrossDaylightSaving

Time. The test clock returns a fixed instant, 12:00 UTC on 2026-03-01. Every order is stamped with it, so an assertion on Created is a literal. If the code under test needs time to pass, give the double an Advance method as in the clock article, or use testing/synctest for code built on timers.

IDs. The generator is a counter closure that formats ord-001, ord-002, ord-003. In production it would be a UUID or ULID call; the code under test only needs "a unique string". A counter is better than a fixed value because uniqueness still holds, and a test that creates two orders can tell them apart. Pad the number so the order sorts the way it was created.

Randomness. rand.New(rand.NewPCG(1, 2)) is a generator whose output is a function of its two seed words. The log shows SAVE76, SAVE61, SAVE78, and a second environment built the same way starts at SAVE76 again. The generator is from math/rand/v2, and its PCG algorithm is documented, so the sequence does not change when the Go version does; the old math/rand seeded source makes a weaker promise, and the global functions are randomly seeded since Go 1.20. Note that the numbers SAVE76, SAVE61 and SAVE78 are specific to this seed and this version of the library, so assert on the property when the value is not the point of the test (the coupon matches a pattern), and on exact values only when it is.

Proving the doubles are doing something

The last test builds two production environments and asserts that their orders differ. It guards the premise. A test double that replaces a source that was constant anyway proves nothing, and a refactor can quietly route a call around the injected function. When code under test reads time.Now() directly, the failure is that the test output matches the clock of the machine, which a single run cannot show. Two defences work. First, a search: grep -rn "time.Now()" --include=*.go outside the wiring file should return nothing in code that has tests. Second, a vet-style linter rule, for example a forbidigo pattern that matches the call to the clock outside main and the wiring package, so new direct calls are rejected in review.

Time zones and daylight saving are inputs too

A fixed instant removes the date from the test and leaves the zone. Code that adds a day, formats a local time or computes a billing period behaves differently across a daylight saving change, and a test that runs in UTC will never see it. Put the zone in the test explicitly and choose dates around the change.

The last test of the lab does that. It starts at noon on 7 March 2026 in America/New_York, the day before clocks go forward. AddDate(0, 0, 1) adds one calendar day and keeps the wall clock, so it lands at 12:00 the next day, 23 hours later. Add(24 * time.Hour) adds exactly 24 hours of elapsed time and lands at 13:00. Both are correct for different questions, and code that picked the wrong one for "same time tomorrow" has a bug twice a year that no test in UTC could find. The blank import of time/tzdata embeds the zone database in the test binary, so the test gives the same answer on a minimal container with no system zone files. Without it, LoadLocation depends on the machine.

Three habits help. Create times with time.Date and an explicit *time.Location, never time.Now() converted afterwards. Test the boundary days of each zone you serve, including the repeated hour in autumn. And keep persisted timestamps in UTC, converting at the edges, so the part of the system that needs zone rules is small and easy to test.

Fake clocks that move

A constant clock is enough for stamping. Code that waits, expires or schedules needs one that moves, and it needs the moves to be driven by the test. The design has three parts: a Now that returns the fake's current value, an Advance(d) that moves it, and timers created through the fake that fire when Advance passes their deadline. The last part is where hand-written fakes get complicated, which is why testing/synctest is attractive for timer-heavy code. For simple expiry checks, as in a cache, Now and Advance are all you need and a mutex around the value.

The most common bug in fake clocks is forgetting the lock. A fake read from a goroutine started by the code under test and advanced from the test goroutine is a data race, and the race detector will say so. Another is advancing past a deadline without letting the goroutine waiting on it run, so an assertion that follows Advance sees stale state. Wait for the effect on a channel or with synctest.Wait, not with a sleep.

Deterministic but not trivial

A test double should be as boring as possible and no more boring than that. A counter ID generator that never repeats is faithful to the contract "unique". A random source with a fixed seed is faithful to "arbitrary". A clock stuck at one instant is faithful to "now" only for code that never compares two readings. When the real source has a property the code relies on, such as IDs sorting by creation time or the clock never going backwards, the double must keep it, and a test of the double itself is worth a line.

Where to draw the boundary

Inject at the edges of the logic you want to test, not everywhere. A web handler needs the clock only if its response depends on it. A repository that stamps created_at could instead let the database do it with DEFAULT now(), and then the test asserts on a range, or the application passes the time in as a parameter and the database stores it. Decide which layer owns the instant and inject there.

For IDs, one more option avoids the double entirely: let the database generate them, and have tests read back the returned ID. That is right when the database is real. When the ID is generated in Go and used before the insert, as here, inject.

For randomness used for security, such as tokens and nonces, do not seed a deterministic generator in production. The injection point stays, and the production value is crypto/rand; only tests use the fixed seed. Make sure the production constructor is the only place that can build an Env without doubles, and a test cannot pass a seeded generator to the real token issuer by accident.

What to do with an existing codebase

Most code that needs this arrives with direct calls already in it, so the practical question is order of work. Do not refactor everything. Start where a flaky test or a time-dependent test already exists, since that is where the cost is paid, and follow this sequence.

  1. Introduce the field with a default. Add the now function to the struct and set it to time.Now in the constructor, so no caller changes. The behaviour is identical and the diff is small.
  2. Replace the direct calls inside that type only. time.Now() becomes s.now(). Run the existing tests; they should pass unchanged.
  3. Change the one test that suffered, to set now to a fixed value. It no longer depends on the machine's date.
  4. Repeat for IDs and randomness in the same type if it uses them.
  5. Add the lint rule once the first package is clean, scoped to that package, and widen it as others are converted.

The sequence leaves production behaviour untouched at every step and makes each step reviewable. The refactor that tries to inject everything at once is the one that gets abandoned.

A word on global state

The shortcut is a package level variable, var now = time.Now, that tests reassign. It is quick, it works for one test, and it breaks in three ways. Parallel tests overwrite each other's value. A test that forgets to restore it leaks into the next one, which is a flake generator. And it hides the dependency: nothing in the function signature says that the result depends on the clock. If you must use one as an interim step, restore it with t.Cleanup and never use t.Parallel in the package, then replace it with a field as soon as you can.

A short checklist

  1. Find every read of the world: time.Now, rand., uuid., os.Getenv, os.Hostname.
  2. Move each behind a field or parameter, with production wiring in one constructor.
  3. Write the test double: a fixed instant, a counter, a seeded generator.
  4. Assert exact values where the value is the point, and properties where it is not.
  5. Add one test that the production wiring still varies.
  6. Reject new direct calls with a linter.

Limits

Determinism makes tests repeatable, not correct. A fixed clock hides bugs that need time to move, such as a cache that never expires or a token that stays valid forever, so test those with an advancing clock. A seeded generator exercises one path per seed, and a property test explores many; see property based testing. And doubles can drift from reality: the production ID generator may produce longer strings than the counter, and a column sized for the test IDs fails in production. Check the format assumptions once, with a test that uses the real generator.

The math/rand/v2 package documents the generators, including the seeding behaviour, in the package reference, and the standard time functions are in the time package. For the broader testing practice that this supports, see the Go testing guide.

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.