JavaRush /Курси /Go SELF /Стабілізація тестів: фіксуємо час

Стабілізація тестів: фіксуємо час

Go SELF
Рівень 53 , Лекція 3
Відкрита

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. Ви буквально починаєте «оновлювати еталон» після кожного запуску й раптом розумієте, що тест уже нічого не захищає — він просто записує те, що вийшло.

Коли ви:

  1. фіксуєте час через FakeClock,
  2. сортуєте вивід перед друком,
  3. фіксуєте форматування (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 …».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ