1. Example‑тести
Коли ми читаємо документацію або README, ми підсвідомо довіряємо прикладам. Проблема в тому, що код змінюється, а приклади часто залишаються без змін і перетворюються на «документаційну археологію».
У Go цю проблему розв’язали елегантно: приклад можна зробити виконуваним і перевірюваним. Такий приклад живе поруч із тестами, і go test перевірятиме, чи справді він виводить те, що обіцяє.
Ідея проста: якщо приклад «зіпсується», тест упаде — і ви дізнаєтеся про це одразу під час прогону тестів, а не через пів року, коли колега про нього вже й забуде. Це автоматична перевірка документації: приклад стає частиною контролю якості.
Чому Example‑тести не замінюють модульні тести
Дуже легко почати використовувати Example‑тести «замість усього». Вони гарно виглядають, читаються як демонстрація й не вимагають t.Fatalf. Але їхня роль інша.
Звичайний TestXxx доречний, коли ви хочете перевірити багато варіантів, акуратно порівняти значення, перевірити помилки, крайні випадки та отримати точні повідомлення про те, що зламалося.
Example зручний, коли ви хочете показати людині, як виглядає використання функції й який текст вона побачить у stdout. На практиці це часто виглядає так: модульні тести перевіряють логіку «по-дорослому», а Example‑тести закріплюють кілька найзрозуміліших сценаріїв, щоб документація не застаріла.
2. Мінімальний Example і роль // Output:
Давайте розберемося без поспіху: Example‑тест — це функція у файлі *_test.go, у якої ім’я починається з Example. На відміну від звичайного тесту TestXxx(t *testing.T), у Example‑функції немає аргументів.
Ось найпростіший «скелетний» приклад, який компілюється й уже схожий на Example‑тест:
package greet
import "fmt"
func ExampleHello() {
fmt.Println("hello")
// Output: hello
}
Зверніть увагу на три речі: це *_test.go, ім’я починається з Example, і всередині є вивід у stdout та коментар // Output: — саме він перетворює приклад на перевірюваний тест.
Як працює перевірка stdout через // Output:
Найважливіше в Example‑тестах — те, що під час виконання раннер перехоплює все, що ви виводите у стандартний вивід (stdout), і порівнює це з текстом у коментарі // Output:. Збіглося — приклад пройшов. Не збіглося — тест упав, і у виводі ви побачите «got/want».
Візьмімо невеликий приклад. Припустімо, у нас є функція, яка перевертає рядок. Реалізація зараз неважлива — важлива поведінка прикладу:
package reverse
import "fmt"
func ExampleString() {
fmt.Println(String("hello"))
// Output: olleh
}
Коли ви запускаєте go test -v, ви побачите, що приклад запускається як частина тестів, майже як звичайний Test...:
=== RUN ExampleString
--- PASS: ExampleString (0.00s)
А якщо ви (випадково або «заради експерименту») напишете неправильне очікування:
func ExampleString() {
fmt.Println(String("hello"))
// Output: golly
}
То під час go test отримаєте падіння зі зрозумілим порівнянням:
--- FAIL: ExampleString (0.00s)
got:
olleh
want:
golly
FAIL
Якщо // Output: немає
Іноді приклад потрібен, але запускати його в тестах не можна або безглуздо: код ходить у мережу, залежить від оточення, потребує ручного налаштування або просто занадто «важкий» для кожного прогону.
У Go є просте правило: якщо в Example‑функції немає // Output:, вона компілюється, але не запускається.
Це корисно з двох причин. По-перше, ви все одно гарантуєте, що приклад не застарів і не дійшов до стану «не компілюється». По-друге, ви не перетворюєте go test на лотерею «впав інтернет — впали тести».
Приклад лише для компіляції:
package greet
import "fmt"
func ExampleHello_compilesOnly() {
fmt.Println(Hello("Gopher"))
// Коментаря Output немає — приклад компілюється, але не виконується.
}
Якщо ви запускаєте go test -v, ви помітите, що такий приклад не з’являється у списку запущених Example‑тестів (тому що він не виконується).
3. Імена Example‑функцій і прив’язка до API
Коли проєкт росте, у пакеті з’являється багато функцій, і приклади теж хочеться тримати впорядковано. У Go є домовленість щодо іменування: ім’я Example‑функції можна пов’язати з конкретною функцією, типом або методом. Це впливає на те, як документація показує приклади, і допомагає людям швидко знаходити потрібний приклад очима.
Ось основні форми:
| Ім’я Example‑функції | Ідея прив’язки |
|---|---|
|
приклад для пакета цілком |
|
приклад для ідентифікатора (функція або тип) |
|
приклад для методу типу |
Окрема приємна особливість: можна зробити кілька прикладів для одного й того самого ідентифікатора. Для цього додають суфікс після підкреслення, зазвичай з малої літери.
func ExampleString() { /* ... */ }
func ExampleString_second() { /* ... */ }
func ExampleString_third() { /* ... */ }
З точки зору go test це просто різні Example‑функції. З точки зору людей — можливість показати «базовий сценарій», «крайовий сценарій» і «складніший приклад», не перетворюючи один приклад на пів сторінки.
4. Приклад: пакет taskfmt
Зараз зробимо маленький фрагмент «спільного застосунку», який можна буде розвивати далі: утиліту для форматування задач. Поки без CLI і без файлів — лише логіка форматування рядків.
Уявімо, що в нас є пакет taskfmt. Він відповідає лише за те, як задача виглядає у тексті. У задачі три поля: ID, Title, Done.
Файл task.go:
package taskfmt
type Task struct {
ID int
Title string
Done bool
}
Тепер функція, яка перетворює задачу на рядок, — ідеальний кандидат для Example: вхід → читабельний вивід.
Файл format.go:
package taskfmt
import "fmt"
func FormatTaskLine(t Task) string {
mark := " "
if t.Done {
mark = "x"
}
return fmt.Sprintf("%d. [%s] %s", t.ID, mark, t.Title)
}
Приклад з одним рядком виводу
І ось Example‑тест, який виглядає як документація, але поводиться як тест.
Файл format_test.go:
package taskfmt
import "fmt"
func ExampleFormatTaskLine() {
t := Task{ID: 3, Title: "Купити молоко", Done: false}
fmt.Println(FormatTaskLine(t))
// Output: 3. [ ] Купити молоко
}
Тут ми використовуємо fmt.Println: він додає переведення рядка, а раннер Example‑тестів порівнює вивід рядок за рядком із тим, що написано після // Output:.
Приклад із кількома рядками
Один рядок — добре, але інколи хочеться показати поведінку на кількох об’єктах. У Example‑тестах це нормально: просто друкуйте кілька рядків і перелічуйте їх у // Output: пострічково. Головне — щоб вивід був стабільним і передбачуваним.
Зробімо приклад, який друкує дві задачі: одну виконану й одну невиконану.
package taskfmt
import "fmt"
func ExampleFormatTaskLine_twoTasks() {
fmt.Println(FormatTaskLine(Task{ID: 1, Title: "Прочитати документацію", Done: true}))
fmt.Println(FormatTaskLine(Task{ID: 2, Title: "Написати код", Done: false}))
// Output:
// 1. [x] Прочитати документацію
// 2. [ ] Написати код
}
Формат тут такий: після // Output: ідуть рядки очікуваного виводу, кожен починається з // і пробілу. По суті, ви фіксуєте «еталон» того, що має бути в консолі.
5. Детермінізм: щоб Example не був «флейковим»
Є важлива психологічна пастка: Example здається «простим», тому хочеться показати в ньому щось живе — наприклад, вивід із map, поточний час або випадкове число. А потім раптом приклад починає падати: то порядок ключів інший, то час інший, то випадкове число інше.
Example з // Output: — це тест, а тест любить повторюваність.
Наприклад, якщо ви зробите так:
package taskfmt
import "fmt"
func Example_bad_NonDeterministic() {
m := map[string]int{"b": 2, "a": 1}
for k, v := range m {
fmt.Println(k, v)
}
// Output:
// a 1
// b 2
}
То цей Example може впасти, бо порядок обходу map не гарантований.
Правильний підхід на цьому рівні — не намагатися «довести раннеру, що порядок не важливий», а просто зробити вивід детермінованим: зібрати ключі в слайс, відсортувати їх і друкувати по черзі.
Сенс простий: Example‑тест — це договір «один і той самий вхід → один і той самий вивід».
6. Як запускати Example‑тести та перевіряти запуск
Коли ви щойно додали Example‑тест, хочеться переконатися, що він узагалі запускається. Найзручніший спосіб — go test -v: він показує, які тести справді виконувалися, зокрема Example....
Якщо ви не бачите свій Example... у -v, зазвичай це означає одне з двох: або ви забули // Output: (і тоді він компілюється, але не запускається), або ім’я, файл чи пакет оформлені не так, як ви думаєте.
Тут допомагає проста дисципліна: під час налагодження тестів спершу вмикаємо -v і переконуємося, що запускається саме те, що ми й хотіли перевірити.
7. Типові помилки під час написання Example‑тестів
Помилка № 1: забули // Output: і дивуються, чому приклад не запускається.
Це дуже поширена історія: ви написали func Example...(), надрукували щось красиве, запускаєте go test — і тиша. Причина в тому, що приклад без // Output: компілюється, але не виконується. Така поведінка — норма і корисна особливість для прикладів лише на компіляцію. Але якщо ви хотіли саме перевірку stdout, // Output: обов’язковий.
Помилка № 2: невідповідність пробілів і переносів рядків.
Example‑тести порівнюють текст буквально: зайвий пробіл, інший перенос рядка — і ви отримаєте got/want. Це не «прискіпливість Go», а захист від розповзання виводу. Тому краще друкувати коротко, передбачувано й не намагатися в одному прикладі показати «все форматування світу».
Помилка № 3: недетермінований вивід і ефект «падає час від часу».
Якщо вивід залежить від порядку обходу map, поточного часу або випадковості, Example перетворюється на генератор нестабільних тестів. У навчальних прикладах краще відразу дотримуватися правила: Example має бути повторюваним. Якщо хочеться показати map, робіть стабільний вивід через сортування ключів, а якщо хочеться показати роботу з нестабільним оточенням — приберіть // Output: і залиште приклад лише для компіляції.
Помилка № 4: пишуть у stderr і чекають, що // Output: це перевірить.
Example‑тести порівнюють те, що потрапило у стандартний вивід. Якщо частина повідомлень іде в stderr, у вас виходить дивна картина: у консолі щось друкується, але Example ніби «не бачить» цього тексту. Для навчальних прикладів тримайтеся fmt.Print/fmt.Println без os.Stderr.
Помилка № 5: один величезний Example «на пів пакета».
Example має ілюструвати одну ідею: один основний сценарій використання функції або невеликий фрагмент поведінки. Коли приклад розростається, його важко читати як документацію, і він стає крихким як тест. Набагато краще зробити два Example‑тести з суфіксами _second, _third, ніж одного монстра.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ