1. Чому тести часом «червоніють»
Коли ви починаєте тестувати CLI «як функцію», виникає дуже спокуслива ілюзія: якщо ми передали bytes.Buffer замість os.Stdout, то тести вже повністю під контролем. Але потім приходить реальність: команда друкує поточний час (time.Now()), обходить map у випадковому порядку, домішує абсолютні шляхи, а інколи ще й додає «випадковий» зайвий пробіл. І тести починають жити власним життям: сьогодні вони зелені, завтра червоні, а післязавтра ви робите git blame і звинувачуєте кота.
У цій лекції ми зробимо дві речі, які перетворюють тести з «ворожіння на кавовій гущі» на нудну інженерну рутину. А нудно — значить добре. По-перше, винесемо час у залежність через Clock/FakeClock. По-друге, стабілізуємо вивід: сортування перед друком стане не «хитрістю для тестів», а нормальним контрактом команди.
Детермінізм: однаковий вхід → однаковий вивід
Детермінізм — це проста ідея: якщо я двічі запускаю одну й ту саму команду з однаковими аргументами й однаковим вмістом даних, я хочу отримати однаковий текст у stdout. Не «схожий», не «приблизно такий самий», а байт у байт однаковий. Інакше скрипти, пайплайни, golden-тести й навіть звичайна перевірка очима починають ламатися.
CLI особливо чутливий до цього, тому що його результат — це рядок. У графічному інтерфейсі ви ще можете «в цілому» побачити ту саму таблицю, навіть якщо рядки помінялися місцями. А в тестах рядок — це контракт. Якщо контракт тремтить, тести або стають крихкими й ламаються без причини, або розмиваються перевірками на кшталт Contains, доки не перестають щось гарантувати.
Найчастіші джерела недетермінізму в CLI такі:
| Джерело | Як проявляється | Чому тести починають «тремтіти» |
|---|---|---|
| time.Now() у логіці команди | час створення/експорту щоразу інший | golden неможливо порівняти, строгі == падають |
| range по map | порядок рядків змінюється між запусками | вивід коректний, але різний |
| локальна часова зона | в одному оточенні +03:00, в іншому Z | одна й та сама дата форматуються по-різному |
| «плаваючі» пробіли й \n | то є фінальний новий рядок, то немає | diff у тестах виглядає майже як знущання |
Сьогодні зосередимося на двох найпроблемніших: часі та порядку.
2. Інʼєкція часу: робимо time.Now() залежністю
Коли новачок пише CLI, рука автоматично тягнеться до такого коду:
createdAt := time.Now()
Це нормально… до першого тесту. Тому що час — це зовнішній світ. А тести люблять, коли зовнішній світ вимкнено, як сповіщення на телефоні під час іспиту.
Рішення просте: не брати час напряму. Натомість команда має запитувати «поточний час» у залежності, яку можна підмінити.
Інтерфейс Clock
Почнемо з мінімального інтерфейсу. Він має вміти відповідати на запитання «котра година?».
package clock
import "time"
type Clock interface {
Now() time.Time
}
Тут важливий стиль Go: інтерфейс маленький, по суті, без «універсального комбайна».
Реальний годинник: RealClock
У продакшені нам потрібен реальний час:
package clock
import "time"
type RealClock struct{}
func (RealClock) Now() time.Time {
return time.Now()
}
Так, виглядає нудно. І це прекрасно: чим нудніша інфраструктура, тим менше вона ламається.
Тестовий годинник: FakeClock
У тестах хочемо «заморозити» час. Зробимо FakeClock, який повертає заздалегідь заданий time.Time.
package clock
import "time"
type FakeClock struct {
T time.Time
}
func (c FakeClock) Now() time.Time {
return c.T
}
Цього вже достатньо для більшості CLI-тестів. Іноді хочеться вміти «рухати» час (наприклад, перевіряти --since), але навіть тоді ви зазвичай додаєте метод Advance(d time.Duration) — і все одно не чіпаєте time.Now() напряму.
4. Вбудовуємо Clock у todo-застосунок
Тепер пов’яжемо це з нашим навчальним CLI (умовно назвемо його todo-cli). Нехай у нас є задача, і ми хочемо зберігати CreatedAt. Важливо: ми не будуємо «велику архітектуру» заради архітектури — просто акуратно передаємо залежність туди, де вона потрібна.
Модель задачі
package todo
import "time"
type Task struct {
ID int
Title string
Done bool
CreatedAt time.Time
}
Сховище в памʼяті та проблема map
Щоб у наступному розділі було що стабілізувати, зробимо сховище як map[int]Task. У реальності це може бути файл або БД, але для лекції нам важливо інше: map дає випадковий порядок.
package todo
type Store struct {
nextID int
items map[int]Task
}
func NewStore() *Store {
return &Store{nextID: 1, items: make(map[int]Task)}
}
Створення задачі через Clock
Найважливіший момент: час беремо з 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
}
Тут спеціально видно, що Store сам по собі не зобов’язаний зберігати Clock. Іноді зручніше тримати годинник усередині Store, іноді — передавати його параметром. У навчальному проєкті частіше зручно тримати годинник як залежність рівня застосунку (у cli.Run), а не протягувати його в кожен метод вручну. Зараз ми зробили «в лоб», щоб ідея була кришталево ясною: джерело часу відокремлене.
5. Пакування залежностей CLI: Deps
Коли залежностей стає більше ніж одна (сховище, годинник, можливо, конфіг), дуже хочеться не перетворювати Run на «функцію-восьминога». Тому в Go часто роблять структуру залежностей.
package cli
import (
"example.com/todo/clock"
"example.com/todo/todo"
)
type Deps struct {
Store *todo.Store
Clock clock.Clock
}
І тоді Run приймає 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, "бракує команди")
return exitUsage
}
// ... далі розбір команд
return exitOK
}
Так, сигнатура стала довшою. Але тести тепер можуть зібрати повністю контрольований світ: FakeClock, Store, буфери виводу — і готово.
6. Тест: фіксуємо час і отримуємо стабільний рядок
Тепер покажемо, заради чого все це затівалося: тест, який не залежить від того, який сьогодні день (а сьогодні, до речі, 16 січня 2026 р., але тесту байдуже).
Команда add: друкуємо created_at у RFC3339
Зробимо так, щоб команда друкувала час створення задачі. Важливо: фіксуємо формат і часову зону, інакше один розробник отримає +0300, інший — 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))
}
Якщо ви зараз подумали «чому UTC?», відповідь проста: UTC майже завжди краще для контрактів і тестів. Локальний час — чудовий спосіб отримати «а в мене не відтворюється».
Тест із 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())
}
}
Зверніть увагу на психологічно важливий ефект: ви більше не «ловите» час регулярним виразом і не робите strings.ReplaceAll у тесті. Тест перевіряє нормальний контракт програми.
7. Стабільний порядок виводу: сортування як частина логіки
З часом ми розібралися. Тепер другий класичний джерело нестабільності — порядок.
Якщо ви зберігаєте задачі в map[int]Task, то такий код виглядає невинно:
for _, t := range s.items {
fmt.Fprintln(out, t.Title)
}
Але порядок друку буде випадковим. Go спеціально не гарантує порядок обходу map, і це правильно: інакше люди почнуть покладатися на нього як на «приховане сортування», а потім страждатимуть під час змін.
У CLI це майже завжди означає одне: перед друком ми перетворюємо дані на зріз і сортуємо його.
Збираємо задачі в зріз
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
}
Сортуємо за ID
Можна сортувати за CreatedAt, за Title, за Done — за чим завгодно. Головне, щоб правило було стабільним і зрозумілим користувачу. Для початку сортуємо за 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
})
}
Так, тут є мікро-нюанс: різниця a.ID - b.ID теоретично може переповнитися, але для навчального todo-застосунку ID будуть маленькі. Якщо хочете «по-дорослому», використовуйте порівняння через if і повертайте -1/0/1.
Команда list: сортуємо і друкуємо
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)
}
}
Тепер вивід стабільний. І це не «заради тестів». Це просто якісний CLI: якщо користувач двічі вводить todo-cli list, він бачить однаковий порядок, а не лотерею.
8. Детермінізм і golden-тести
Golden-тести корисні, коли вивід великий: таблиця, список, звіт. Але вони перетворюються на пекло, якщо вивід змінюється через час або порядок map. Ви буквально починаєте «оновлювати еталон» після кожного запуску й раптом розумієте, що тест уже нічого не захищає — він просто записує те, що вийшло.
Коли ви:
- фіксуєте час через FakeClock,
- сортуєте вивід перед друком,
- фіксуєте форматування (UTC() + RFC3339, єдині \n),
golden-тести починають працювати як задумано: вони ловлять реальні зміни виводу, а не шум.
Корисна звичка: якщо ви бачите, що golden-файл оновлюється занадто часто, найчастіше проблема не в golden-підході, а в тому, що вивід недетермінований.
Схема залежностей після рефакторингу
Іноді допомагає буквально побачити потік залежностей:
flowchart TD
Main[main.go] -->|збирає залежності| Run["cli.Run(..., deps)"]
Run --> Store[todo.Store]
Run --> Clock[clock.Clock]
Clock -->|продакшен| Real[clock.RealClock]
Clock -->|тести| Fake[clock.FakeClock]
Run --> Out[io.Writer stdout]
Run --> Err[io.Writer stderr]
Зауважте, що FakeClock не «підміняє стандартну бібліотеку». Він просто займає місце залежності. Це чесна, прозора архітектура.
9. Типові помилки
Помилка № 1: «Я зробив FakeClock, але все одно десь залишився time.Now()».
Це найпоширеніший сценарій: ви акуратно протягнули Clock у Add, але потім у list додали колонку generated_at=time.Now() для краси. І все, тести знову тремтять. Лікується просто: домовтеся, що всередині тестованої логіки час береться лише з Clock. time.Now() залишаємо на самому кордоні (наприклад, у main, якщо дуже потрібно), але не в командах.
Помилка № 2: Форматування часу без UTC() і без фіксованого layout.
Якщо ви друкуєте t.String(), ви віддаєте форматування на волю стандартної бібліотеки й локалі оточення. Десь буде одне, десь інше, а ще ви отримаєте зайві деталі, які не потрібні користувачу. Для CLI-контракту обирайте явний формат (часто time.RFC3339) і приводьте до UTC() перед форматуванням, щоб не залежати від часового поясу машини.
Помилка № 3: Сортування робиться в тесті, а не в коді команди.
Іноді студент бачить нестабільний вивід і робить «костиль»: бере stdout, розбиває його на рядки, сортує рядки в тесті й порівнює. Це лікує симптом, але ламає сам сенс: ви більше не тестуєте реальний контракт команди. Правильніше відсортувати дані до друку в list, щоб користувачеві теж було зручно.
Помилка № 4: Нестабільне сортування за неунікальним ключем.
Якщо ви сортуєте лише за Title, а дві назви однакові, відносний порядок може залежати від початкового порядку (який у map випадковий). У підсумку ніби «є сортування», а вивід усе одно тремтить. Рішення: додавайте вторинний ключ, наприклад (Title, ID) або (CreatedAt, ID).
Помилка № 5: В один io.Writer змішали і результат, і діагностику.
Коли stdout і stderr змішуються, тести починають або ігнорувати частину виводу, або порівнювати «сміття» разом із результатом. У реальному житті це теж погано: користувач хоче парсити stdout, а не читати там помилки. Тому повідомлення про помилки та usage — в errOut, результат команди — в out, і цього слід дотримуватися завжди, особливо коли ви додаєте нові рядки на кшталт «generated at …».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ