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:
- freeze time via FakeClock,
- sort output before printing,
- 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 …”.
GO TO FULL VERSION