JavaRush /Courses /Go SELF /Test stabilization: freezing time

Test stabilization: freezing time

Go SELF
Level 53, Lesson 3
Available

1. Why tests become “sometimes red”

When you start testing a CLI “like a function”, a wonderful illusion appears: since we passed a bytes.Buffer instead of os.Stdout, the tests are already fully controllable. But then reality hits: the command prints the current time (time.Now()), iterates a map in random order, mixes in absolute paths, and sometimes even adds a “random” extra space. And tests start living their own life: green today, red tomorrow, and the day after tomorrow you run git blame and blame the cat.

In this lecture we’ll do two things that turn tests from “reading tea leaves” into boring engineering routine (and boring means good). First, we’ll make time a dependency via Clock/FakeClock. Second, we’ll stabilize the output: sorting before printing will become not a “testing trick” but a normal command contract.

Determinism: same input → same output

Determinism is a simple idea: if I run the same command twice with the same arguments and the same data contents, I want the exact same text on stdout. Not “similar”, not “roughly the same”, but byte-to-byte identical. Otherwise scripts, pipelines, golden tests, and even plain manual eyeballing start breaking.

CLI is especially sensitive to this because its result is a string. In a graphical UI you can “generally” see the same table even if rows swapped places. But in tests, a string is a contract. If the contract wiggles, tests either become brittle (break for no reason) or get “smeared” with checks like Contains until they stop guaranteeing anything.

The most common sources of non-determinism in CLI:

Source How it shows up Why tests start “wiggling”
time.Now() in the command logic creation/export time is different every run golden can’t be compared, strict == checks fail
range over a map row order changes between runs output is correct, but different
local time zone in one environment +03:00, in another Z the same date gets formatted differently
“floating” spaces and \n sometimes there’s a final newline, sometimes not test diffs look like mockery

Today we’ll focus on the two “heaviest” ones: time and order.

2. Time injection: making time.Now() a dependency

When a beginner writes a CLI, the hand automatically does this:

createdAt := time.Now()

That’s fine… until the first test. Because time is the outside world. And tests like it when the outside world is turned off, like phone notifications during an exam.

The solution is simple: don’t take time directly. Instead, the command should ask “what time is it now?” from a dependency that you can swap out.

Interface Clock

Let’s start with the minimal interface. It should be able to answer the question “what time is it?”.

package clock

import "time"

type Clock interface {
	Now() time.Time
}

Go style matters here: the interface is small, to the point, without a “universal Swiss army knife”.

Production clock: RealClock

In production we need real time:

package clock

import "time"

type RealClock struct{}

func (RealClock) Now() time.Time {
	return time.Now()
}

Yes, it looks boring. And that’s great: the more boring the infrastructure, the less it breaks.

Test clock: FakeClock

In tests we want to “freeze” time. Let’s make a FakeClock that returns a pre-defined time.Time.

package clock

import "time"

type FakeClock struct {
	T time.Time
}

func (c FakeClock) Now() time.Time {
	return c.T
}

That’s already enough for most CLI tests. Sometimes you want to be able to “move” time (for example, to test --since), but even then you typically add a method like Advance(d time.Duration)—and still don’t touch time.Now() directly.

4. Wiring Clock into a todo app

Now let’s connect this to our learning CLI (let’s call it todo-cli). Suppose we have a task and we want to store CreatedAt. Important: we’re not doing “big architecture” for the sake of architecture—we’re just carefully passing a dependency to where it’s needed.

Task model

package todo

import "time"

type Task struct {
	ID        int
	Title     string
	Done      bool
	CreatedAt time.Time
}

In-memory store and the map problem

So that the next section has something to stabilize, let’s implement the store as a map[int]Task. In real life this could be a file/DB, but for this lecture we care about the effect map → random order.

package todo

type Store struct {
	nextID int
	items  map[int]Task
}

func NewStore() *Store {
	return &Store{nextID: 1, items: make(map[int]Task)}
}

Creating a task via Clock

The key point: we get time from Clock.

package todo

import "example.com/todo/clock"

func (s *Store) Add(title string, clk clock.Clock) Task {
	t := Task{ID: s.nextID, Title: title, CreatedAt: clk.Now()}
	s.items[t.ID] = t
	s.nextID++
	return t
}

This is intentionally written so you can see that Store itself doesn’t have to store a Clock. Sometimes it’s more convenient to keep the clock inside Store; sometimes it’s better to pass it as a parameter. In a learning project it’s often more convenient to keep the clock as an application-level dependency (in cli.Run) rather than threading it through every method manually. Here we did it “straight on” so the idea is crystal clear: the time source is separated.

5. Packaging CLI dependencies: Deps

When you have more than one dependency (store, clock, maybe config), you really don’t want to turn Run into an “octopus function”. So in Go it’s common to define a dependencies struct.

package cli

import (
	"example.com/todo/clock"
	"example.com/todo/todo"
)

type Deps struct {
	Store *todo.Store
	Clock clock.Clock
}

And then Run accepts Deps:

package cli

import (
	"fmt"
	"io"
)

func Run(args []string, in io.Reader, out, errOut io.Writer, deps Deps) int {
	_ = in

	if len(args) == 0 {
		fmt.Fprintln(errOut, "missing command")
		return exitUsage
	}
	// ... further command parsing
	return exitOK
}

Yes, the signature got longer. But now tests can assemble a fully controlled world: FakeClock, Store, output buffers—and that’s it.

6. Test: freeze time and get a stable string

Now let’s show why we did all this: a test that doesn’t depend on what day it is today (and today is, by the way, January 16, 2026, but the test doesn’t care).

Command add: printing created_at in RFC3339

Let’s make the command print the task creation time. Important: lock down the format and zone; otherwise one developer will get +0300 and another will get Z.

package cli

import (
	"fmt"
	"time"
)

func printCreated(out io.Writer, t time.Time) {
	fmt.Fprintf(out, "created_at=%s\n", t.UTC().Format(time.RFC3339))
}

If you’re thinking “why UTC?”, the answer is simple: UTC is almost always better for contracts and tests. Local time is a great way to get “it doesn’t reproduce on my machine”.

Test with FakeClock

package cli

import (
	"bytes"
	"strings"
	"testing"
	"time"

	"example.com/todo/clock"
	"example.com/todo/todo"
)

func TestAdd_FixedTime(t *testing.T) {
	fixed := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)

	deps := Deps{Store: todo.NewStore(), Clock: clock.FakeClock{T: fixed}}
	var out, errOut bytes.Buffer

	code := Run([]string{"add", "buy milk"}, strings.NewReader(""), &out, &errOut, deps)

	if code != exitOK || errOut.String() != "" || !strings.Contains(out.String(), "created_at=2026-01-01T00:00:00Z\n") {
		t.Fatalf("code=%d stdout=%q stderr=%q", code, out.String(), errOut.String())
	}
}

Notice the psychologically important effect: you’re no longer “catching” time with a regexp and you’re not doing strings.ReplaceAll in the test. The test checks a normal program contract.

7. Stable output order: sorting as part of the logic

Time is handled. Now the second classic “wiggler” is order.

If you store tasks in map[int]Task, this code looks innocent:

for _, t := range s.items {
	fmt.Fprintln(out, t.Title)
}

But print order will be random. Go intentionally does not guarantee map iteration order, and that’s correct: otherwise people would start relying on it as “hidden sorting” and then suffer when things change.

In a CLI this almost always means one thing: before printing, we turn the data into a slice and sort it.

Collect tasks into a slice

package todo

func (s *Store) All() []Task {
	res := make([]Task, 0, len(s.items))
	for _, t := range s.items {
		res = append(res, t)
	}
	return res
}

Sort by ID

You can sort by CreatedAt, by Title, by Done, by anything—the key is that the rule is stable and user-understandable. To start, sort by ID.

package cli

import (
	"slices"

	"example.com/todo/todo"
)

func sortTasksByID(tasks []todo.Task) {
	slices.SortFunc(tasks, func(a, b todo.Task) int {
		return a.ID - b.ID
	})
}

Yes, there’s a micro-nuance here: the difference a.ID - b.ID can theoretically overflow, but in a learning todo app the IDs will be small. If you want a “grown-up” version, compare via if and return -1/0/1.

Command list: sort and print

package cli

import (
	"fmt"
	"io"

	"example.com/todo/todo"
)

func runList(out io.Writer, s *todo.Store) {
	tasks := s.All()
	sortTasksByID(tasks)

	for _, t := range tasks {
		fmt.Fprintf(out, "%d | %s | done=%v\n", t.ID, t.Title, t.Done)
	}
}

Now the output is stable. And it’s not “for tests”. It’s just a quality CLI: if a user runs todo-cli list twice, they see the same order, not a lottery.

8. Determinism and golden tests

Golden tests are useful when the output is large: a table, a list, a report. But they become hell if the output changes because of time or map order. You literally start “updating the golden” on every run and suddenly realize the test isn’t protecting anything anymore—it’s just recording whatever happened to come out.

When you:

  1. freeze time via FakeClock,
  2. sort output before printing,
  3. lock down formatting (UTC() + RFC3339, consistent \n),

golden tests start working as intended: they catch real output changes, not noise.

A small useful habit: if you see that the golden file updates too often, most of the time the problem isn’t the golden approach—it’s that the output is non-deterministic.

Dependency diagram after the refactor

Sometimes it helps to literally see the dependency flow:

flowchart TD
    Main[main.go] -->|builds dependencies| Run["cli.Run(..., deps)"]
    Run --> Store[todo.Store]
    Run --> Clock[clock.Clock]
    Clock -->|prod| Real[clock.RealClock]
    Clock -->|tests| Fake[clock.FakeClock]
    Run --> Out[io.Writer stdout]
    Run --> Err[io.Writer stderr]

Note that FakeClock does not “replace the standard library”. It simply occupies the dependency slot. This is honest, transparent architecture.

9. Common mistakes

Mistake #1: “I made a FakeClock, but somewhere time.Now() still remains”.
This is the most common scenario: you carefully threaded Clock into Add, but then in list you added a column generated_at=time.Now() for beauty. And that’s it—tests wiggle again. The fix is simple: agree that inside testable logic, time is taken only from Clock. Keep time.Now() at the very boundary (for example, in main, if you really need it), but not in commands.

Mistake #2: Time formatting without UTC() and without a fixed layout.
If you print t.String(), you give formatting over to the standard library and the environment locale. Somewhere it will be one thing, somewhere another, and you’ll also get extra details that the user doesn’t need. For a CLI contract, choose an explicit format (often time.RFC3339) and convert to UTC() before formatting so you don’t depend on the machine’s timezone.

Mistake #3: Sorting is done in the test, not in the command code.
Sometimes a student sees unstable output and makes a “hack”: they take stdout, split into lines, sort lines in the test, and compare. This treats the symptom but breaks the meaning: you’re no longer testing the real command contract. It’s better to sort the data before printing in list so it’s good for the user too.

Mistake #4: Unstable sorting by a non-unique key.
If you sort only by Title and two titles are identical, the relative order can depend on the original order (which is random for a map). As a result it looks like “there is sorting”, yet the output still wiggles. Solution: add a secondary key, for example (Title, ID) or (CreatedAt, ID).

Mistake #5: Mixing the result and diagnostics into one io.Writer.
When stdout and stderr are mixed, tests either start ignoring part of the output or compare “junk” together with the result. In real life it’s just as bad: the user wants to parse stdout, not read errors there. So error messages and usage go to errOut, the command result goes to out, and this must always be enforced—especially when you add new lines like “generated at …”.

1
Task
Go SELF, level 53, lesson 3
Locked
Clock dependency
Clock dependency
1
Task
Go SELF, level 53, lesson 3
Locked
Leaderboard
Leaderboard
1
Task
Go SELF, level 53, lesson 3
Locked
Adding an entry
Adding an entry
1
Task
Go SELF, level 53, lesson 3
Locked
Task list
Task list
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION