Фейки та заглушки¶
Юніт-тесту не має бути потрібна база даних, мережа чи годинник, що дійсно цокає. У Go механізм підміни — це інтерфейс, а сама підміна зазвичай — структура, яку ви пишете за десять рядків.
type stubUsers struct {
ByIDFn func(ctx context.Context, id string) (User, error)
}
func (s stubUsers) ByID(ctx context.Context, id string) (User, error) {
return s.ByIDFn(ctx, id)
}
Проєктуйте під це: залежте від інтерфейсу¶
Нічого з цього не спрацює, якщо тип звертається до глобальної змінної чи сам конструює свою залежність. Передумова в тому, щоб об'єкт під тестом отримував те, що йому потрібно:
UserStore — вузький інтерфейс зі статті про
патерн репозиторію.
Оскільки задоволення інтерфейсу неявне, тестовому дублікату не потрібен
базовий клас, реєстрація чи будь-який зв'язок зі справжньою
реалізацією, крім тих самих методів.
Поля-функції дозволяють кожному тесту сказати, що йому потрібно¶
Заглушка вище тримає по одній функції на метод. Тест заповнює лише ту поведінку, яка його цікавить:
api := API{Users: stubUsers{ByIDFn: func(_ context.Context, id string) (User, error) {
return User{Name: "Ada"}, nil
}}}
Кожен випадок провалу — це один рядок, у чому й суть — це саме ті шляхи, які болісно відтворити проти справжньої залежності:
// missing row
return User{}, ErrNotFound
// the database fell over
return User{}, errors.New("connection reset")
Додайте перевірку на етапі компіляції, щоб заглушка не могла відхилитися від інтерфейсу:
Для інтерфейсу з кількома методами дайте невикористаним тривіальне тіло, що повертає нульові значення. Якщо це стає виснажливим — інтерфейс, ймовірно, задовгий для свого споживача.
Вбудовування інтерфейсу для навмисно часткового фейку¶
Інший поширений ідіом вбудовує інтерфейс замість перелічування кожного методу:
type partialUsers struct {
UserStore // вбудований: задовольняє інтерфейс
ByIDFn func(context.Context, string) (User, error)
}
func (p partialUsers) ByID(ctx context.Context, id string) (User, error) {
return p.ByIDFn(ctx, id)
}
Вбудований інтерфейс дорівнює nil, тому структура задовольняє
UserStore, реалізуючи рівно один метод. Виклик будь-якого іншого
методу панікує через розіменування nil-вказівника.
Це звучить як вада, і саме в цьому суть. Тест перевіряє не лише те, що
код викликає, а й те, чого він не викликає: якщо хтось пізніше змусить
код під тестом викликати Create, тест провалиться голосно замість
того, щоб тихо прийняти нульове значення. Це також означає, що новий
метод в інтерфейсі не ламає жоден наявний дублікат.
Компроміс порівняно з полями-функціями:
| Поля-функції | Вбудований інтерфейс | |
|---|---|---|
| нереалізований метод | повертає нульове значення | панікує |
| новий метод в інтерфейсі | треба додати заглушку | нічого не міняти |
| читається як | явно, багатослівно | стисло, неявно |
Використовуйте поля-функції, коли важливі кілька методів, і вбудовування — коли тест стосується одного методу, а решту ви хочете зробити помилкою.
Записуємо, що сталося¶
Коли перевірка звучить як "цю штуку викликали", хай заглушка записує:
type spyNotifier struct {
sent []string
}
func (s *spyNotifier) Notify(_ context.Context, msg string) error {
s.sent = append(s.sent, msg)
return nil
}
Зверніть увагу на отримувач-вказівник — отримувач-значення додавав
би до копії, і ваш запис зникав би. Передавайте &spyNotifier{}.
Якщо код під тестом конкурентний, захистіть зріз м'ютексом, інакше детектор гонитв справедливо поскаржиться.
Три види дублікатів, і коли кожен підходить¶
| Вид | Що це | Використовуйте, коли |
|---|---|---|
| заглушка (stub) | повертає завчасно задані відповіді | потрібне конкретне значення, зокрема помилка |
| шпигун (spy) | записує виклики | сама поведінка і є виклик |
| фейк (fake) | справжня, проста реалізація | багатьом тестам потрібна робоча поведінка |
Фейк вартий зусиль, коли кільком тестам справді потрібно, щоб залежність поводилась як реальна — наприклад, сховище в пам'яті на основі мапи:
type memUsers struct {
mu sync.Mutex
m map[string]User
}
func (f *memUsers) ByID(_ context.Context, id string) (User, error) {
f.mu.Lock()
defer f.mu.Unlock()
u, ok := f.m[id]
if !ok {
return User{}, ErrNotFound
}
return u, nil
}
Один фейк обслуговує тести цілого пакета, і залишається чесним, бо реалізує той самий інтерфейс, що й реальний код.
Чому без бібліотеки для мокання¶
У кодових базах на Go їх часто немає, і варто зрозуміти чому, а не вважати це недоглядом.
Написані вручну дублікати — це звичайний Go: вони компілюються,
рефакторяться разом з рештою коду вашою IDE, і читання одного каже
точно, що він робить. Згенеровані моки додають крок збірки, залежність
і шар непрямості на кшталт EXPECT().Times(1), що описує саму
реалізацію, а не поведінку — тести, що ламаються, коли ви міняєте
місцями два виклики, які ніколи й не мали фіксованого порядку.
Бібліотеки заслуговують на своє місце при дуже широких інтерфейсах або коли потрібні строгі перевірки порядку викликів. Обидва випадки зазвичай означають, що дизайн міг би бути вужчим. Починайте вручну.
Підміна часу¶
Усе, що всередині викликає time.Now(), неможливо тестувати
детерміновано. Впровадьте його:
fixed := time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC)
s := &Service{now: func() time.Time { return fixed }}
Той самий трюк працює для випадковості й генерації ідентифікаторів. Поле-функція легше за інтерфейс, коли метод лише один.
Не імітуйте час через time.Sleep у тестах. Це робить набір тестів
повільним і нестабільним. Якщо код засинає, це зазвичай ознака того, що
затримка мала б бути параметром.
Що не варто підміняти¶
Підміна всього означає, що ви тестуєте самі підміни. Залишайте справжніми:
- Чисту логіку. Нічого впроваджувати.
- Стандартну бібліотеку. Не обгортайте
encoding/json, щоб її підмінити. - Те, що ви насправді тестуєте. Підміна бази даних у тестах самого сховища означає, що ви тестуєте підміну. Тут потрібна справжня база даних — саме для цього призначені testcontainers, у темі про сторонні бібліотеки.
Правило: підміняйте на межі, яку ви самі контролюєте, у точці, де тест інакше потребував би мережі, диска чи годинника.
З досвіду Python: тут немає
unittest.mock.patch, бо немає чого патчити — ви не можете перепризначити функцію в іншому пакеті під час виконання. Підміна відбувається на етапі конструювання, через параметр. Це більше коду для налаштування, зате робить межі підміни явними прямо в сигнатурах типів.
Швидка довідка¶
| Задача | Форма |
|---|---|
| зробити придатним до підміни | приймайте вузький інтерфейс як поле чи параметр |
| заглушка | структура з полями-функціями, по одному на метод |
| прив'язати до інтерфейсу | var _ Iface = stub{} |
| записувати виклики | шпигун з отримувачем-вказівником |
| багаторазова поведінка | фейк — реалізація в пам'яті |
| підмінити годинник | поле now func() time.Time |
| бібліотека для мокання | зазвичай зайва; починайте вручну |
| не підміняйте | чисту логіку, стандартну бібліотеку чи об'єкт під тестом |