JavaRush /Курси /Go SELF /Example‑тести як документація

Example‑тести як документація

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

1. Що таке Example‑тести і чому це документація

Якщо відкрити документацію багатьох стандартних пакетів Go на pkg.go.dev, ви побачите блоки «Example» поруч із функціями та типами. Це не картинки для настрою і не псевдокод «у стилі Go». У Go такі приклади — це цілком справжній Go‑код, який лежить у файлах *_test.go, може виконуватися під час go test і тим самим гарантує, що документація не застаріє після рефакторингу. Саме тому Example‑тести такі цінні: вони одночасно вчать користуватися API і захищають проєкт від «гарних, але брехливих» прикладів.

Головна ідея проста: ми пишемо функцію з іменем, що починається на Example, друкуємо щось у stdout і додаємо коментар // Output:. Під час go test фреймворк захоплює stdout і порівнює його з очікуваним виводом. Якщо значення не збігається, приклад вважається тестом, що впав. Таку поведінку описано в офіційній статті про testable examples.

Щоб закріпити думку, уявіть, що Example — це «демо‑сценарій», який на кожному прогоні тестів запускається знову. Якщо демо перестає збігатися з реальністю, тест падає. Тоді документація буквально перестає бути «думкою автора» і стає перевірюваним фактом.

Де живуть Example‑тести і як їх запускає go test

Example‑тести живуть у звичайних тестових файлах: будь-який файл, ім’я якого закінчується на _test.go, підходить. Зазвичай проєкти заводять окремий example_test.go, щоб не змішувати приклади зі звичайними unit‑тестами, але технічно це не обов’язкове правило, а радше питання гігієни.

Від звичайного тесту Example відрізняється тим, що:

  • функція починається з Example...,
  • у неї немає параметрів,
  • усередині зазвичай використовують fmt.Print/Println/Printf (тобто stdout),
  • а очікуваний результат задається блоком // Output:.

Під час запуску тестів go test -v ви побачите, що Example‑функції запускаються майже як звичайні тести — окремими записами «RUN». У статті про examples це показано на реальному виводі go test.

Важливо розуміти одну деталь: Example — це не «магія документації», а саме тестовий механізм. Отже, правила компіляції, імпорти, пакети, помилки компілятора — усе так само, як у звичайному Go‑коді. Жодних поблажок за те, що це лише «приклад для людей». Компілятор не робить винятків.

2. // Output: і перевірка виводу

Давайте спокійно розберемося, що відбувається, коли ви пишете // Output:. Під час виконання Example‑функції тестовий раннер перехоплює все, що друкується у стандартний вивід, і порівнює це з тим, що ви написали після Output:. Якщо вивід збігся — приклад успішний, якщо ні — він падає, і ви отримаєте повідомлення виду «got / want». Це ключовий механізм, описаний в офіційній статті «Testable Examples in Go».

Найчастіша помилка новачків тут — думати, що «Output» працює «приблизно». Ні, він працює точно. Пробіли, переноси рядків і лапки — усе важливо. І це добре: приклад стає надійним контрактом.

Міні‑приклад на максимально побутовому рівні (не прив’язаний до нашого застосунку, просто щоб відчути механіку):

package demo

import "fmt"

func ExampleHello() {
	fmt.Println("hello")
	// Output: hello
}

Якщо ви зміните рядок на "Hello" або додасте зайвий пробіл — приклад упаде. Іноді це дратує (особливо коли дуже хочеться «трішки краси»), але в довгу це рятує: документація завжди відповідає реальності.

3. Імена Example‑функцій і прив’язка до GoDoc

Тепер важлива частина: чому ми взагалі так уважно ставимося до імен Example...? Тому що GoDoc і pkg.go.dev уміють за іменем Example‑функції зрозуміти, до чого належить цей приклад, і показати його поруч із потрібним елементом API.

У Go є усталені домовленості щодо іменування Example‑функцій, і в реальній практиці вони чудово працюють — ви це побачите, коли відкриєте документацію пакета. Нижче подано коротку таблицю, щоб її було зручно тримати під рукою як шпаргалку.

Назва example‑функції До чого належить у документації
Example()
до пакета загалом
ExampleFoo()
до функції/змінної/типу Foo
ExampleType()
до типу Type
ExampleType_Method()
до методу Method типу Type
ExampleFoo_suffix()
до Foo, але як окремий сценарій (суфікс для розрізнення)

Сенс суфіксної частини простий: інколи у однієї функції є кілька режимів використання, і вам потрібні два приклади. Тоді ви робите ExampleParseID_simple() і ExampleParseID_error() — і обидва належатимуть до ParseID, просто як окремі кейси.

Ця механіка активно використовується у стандартній бібліотеці. А ще ви регулярно зустрічатимете приклади на кшталт ExampleRegexp_FindString() — такий формат можна побачити в матеріалах про додавання прикладів у пакет regexp.

4. Практичні варіанти Examples

Example без // Output:: лише компіляційні приклади

Іноді приклад хочеться показати, але стандартний вивід не може бути детермінованим. Наприклад, код звертається до мережі, залежить від поточного часу або від порядку файлів у каталозі. У таких випадках у Go є компроміс: ви можете написати Example‑функцію без // Output:.

Що тоді станеться? Приклад буде скомпільований, але зазвичай не виконуватиметься як перевірка stdout. Це теж описано в статті про examples: відсутність Output перетворює Example на перевірку «хоча б компілюється».

Це корисно частіше, ніж здається. Такий приклад усе одно:

  • показується в документації,
  • гарантує, що сигнатури функцій і назви пакетів не змінилися так, що приклад став невалідним,
  • і не змушує вас вигадувати «стабільний вивід» там, де його об’єктивно немає.

Невеликий приклад у стилі «ми просто демонструємо виклик і не обіцяємо stdout»:

package todo

func ExampleParseID_compilesOnly() {
	_, _ = ParseID("10")
	// немає Output: приклад «не застаріває на рівні синтаксису»
}

На рівні навчання це ще й психологічно зручно: можна спочатку навчитися писати приклади «як код», а потім уже додавати // Output: там, де це доречно.

Детермінованість: як не зробити Example крихким

З Example‑тестами є одна підступна пастка: ви можете написати приклад, який іноді друкує одне, іноді інше. І тоді тести будуть то зелені, то червоні, і ви відчуєте себе героєм фільму жахів «Прокляття недетермінізму».

Найчастіші джерела недетермінізму для новачка — це час і мапи (map), бо порядок ітерації по map не гарантується. Ще одна класика — випадкові числа, якщо ви не фіксуєте їх seedʼом (але навіть із seedʼом ви часто не хочете тягнути rand у приклад).

Тому хороший Example зазвичай друкує щось просте й стабільне. Якщо вам потрібен порядок — сортуйте. Якщо вам потрібен час — не друкуйте time.Now() у прикладі. Якщо вам потрібно показати форматування — друкуйте фіксований рядок.

Можна уявити це як кухонне правило: Example‑тест — це рецепт, який має виходити однаково щоразу. Якщо ви додаєте туди «дрібку випадковості за смаком», тестовий раннер цього смаку не оцінить.

Приклади на рівні файла: коли одного func Example...() недостатньо

Іноді в прикладі треба оголосити тип і метод, щоб показати, як ваш API працює з інтерфейсами або з кастомними структурами. Тут новачок натрапляє на сувору правду: усередині функції не можна оголосити метод — методи оголошуються на рівні пакета. Отже, звичайного func Example...() може бути недостатньо.

Для таких ситуацій у Go існують «whole-file examples»: увесь файл _test.go стає прикладом, і в ньому можна оголошувати допоміжні типи й методи на рівні пакета, а вже потім писати Example...().

У навчальному контексті це зручно показати на простому сценарії: припустімо, у нас є Status і String() у статусу задачі, і ми хочемо приклад, де статус друкується у зрозумілому вигляді.

5. Приклад: додаємо Examples у пакет todo

Щоб не робити приклади абстрактними, вважатимемо, що в нас уже є невеликий пакет todo, який ми розвиваємо в межах курсу. Він не зобов’язаний бути великим; нам достатньо кількох функцій і типів, щоб показати документаційні приклади.

Уявімо, що в пакеті todo уже є:

  • ParseID(s string) (int, error) — парсить ID,
  • ErrEmptyTitle і ValidateTitle(title string) error — валідація,
  • Status зі String() — зрозумілий для людини стан.

Тепер створимо файл example_test.go у пакеті todo. Почнемо з прикладу для ValidateTitle. Ми хочемо показати типовий збій, бо саме він зазвичай важливіший за успішний сценарій.

package todo

import (
	"fmt"
)

func ExampleValidateTitle() {
	err := ValidateTitle("")
	fmt.Println(err == ErrEmptyTitle) // true
	// Output: true
}

Зверніть увагу на стиль: ми не друкуємо текст помилки повністю (він може змінитися), а перевіряємо контракт, який хочемо зафіксувати. Якщо ви домовилися, що ValidateTitle("") повертає саме ErrEmptyTitle, то такий Example захищає цей контракт краще за будь-яку лекцію.

Тепер додамо приклад для ParseID, але зробимо два сценарії: успішний і помилковий. Для цього використаємо суфікс.

package todo

import (
	"fmt"
)

func ExampleParseID_ok() {
	id, err := ParseID("42")
	fmt.Println(id, err) // 42 <nil>
	// Output: 42 <nil>
}

А тепер негативний сценарій. Тут важливо не намагатися «вгадати» повний текст помилки strconv.Atoi, бо він може бути не тим, що ви хочете зафіксувати в контракті. Краще показати, що err != nil.

package todo

import (
	"fmt"
)

func ExampleParseID_bad() {
	_, err := ParseID("nope")
	fmt.Println(err != nil) // true
	// Output: true
}

Це дуже по‑Go: приклад фіксує поведінку, але не робить ваш API заручником формулювання системної помилки.

Тепер зробимо приклад, який прив’язується до методу String() у типу Status. Тут якраз буде видно домовленість ExampleType_Method():

package todo

import (
	"fmt"
)

func ExampleStatus_String() {
	fmt.Println(StatusDone) // done
	// Output: done
}

Тут є важливий момент: щоб це працювало, у пакеті справді має бути тип Status, значення StatusDone і метод String() string, щоб fmt.Println друкував рядкове представлення. І це прекрасно: Example змушує нас тримати документацію і код синхронними.

Приклад для пакета загалом: Example() як «швидкий старт»

Інколи ви хочете, щоб документація пакета показувала найпростіший сценарій «як цим користуватися», не прив’язуючись до конкретної функції. Для цього пишуть Example() без продовження. Такий приклад потрапить у секцію прикладів пакета.

Зробімо «швидкий старт»: перевіримо заголовок і розпарсимо ID.

package todo

import (
	"fmt"
)

func Example() {
	fmt.Println(ValidateTitle("buy milk") == nil) // true
	id, _ := ParseID("7")
	fmt.Println(id) // 7
	// Output:
	// true
	// 7
}

Зауважте, як оформлюється багаторядковий очікуваний вивід: після // Output: кожен рядок виводу повторюється окремим коментарем. Це той самий стиль, який ви побачите у стандартній бібліотеці (наприклад, у прикладах до regexp).

6. Чим Example відрізняється від unit‑тесту

Інколи початківці плутають Example‑тести зі звичайними unit‑тестами й намагаються запхнути в Example всі перевірки разом. Щоб було простіше, тримайте таку схему:

flowchart TD
    A[Юніт-тест TestXxx] --> B[Перевіряємо багато випадків]
    A --> C[Можна використовувати t.Fatalf/t.Errorf]
    A --> D[Фокус: внутрішня коректність]

    E[Example-тест ExampleXxx] --> F[Показуємо один сценарій використання]
    E --> G[Зазвичай друкуємо в stdout]
    E --> H[Фокус: як користуватися API + стабільний результат]

Unit‑тест відповідає на запитання «це працює?», а Example — на запитання «як цим користуватися так, щоб було правильно й очікувано». У хорошому проєкті вони доповнюють одне одного, а не конкурують.

7. Типові помилки

Помилка № 1: приклад залежить від нестабільного порядку або часу.
Це найнеприємніша категорія: приклад може проходити у вас локально і падати в колеги. Часто причиною стає друк map без сортування або використання time.Now(). Ліки прості: у прикладах друкуйте лише детерміновані речі, а якщо потрібен порядок — сортуйте дані перед друком.

Помилка № 2: // Output: написано «приблизно так», але не збігається посимвольно.
Часто ламає новачків не логіка, а один зайвий пробіл або перенос рядка. Враховуйте, що раннер порівнює stdout суворо. Робіть вивід коротким, не друкуйте зайвих пояснень, а якщо потрібно показати складну структуру, друкуйте булеві перевірки (true/false) або компактні значення.

Помилка № 3: спроба зафіксувати текст чужої помилки як частину контракту.
Якщо ваш ParseID усередині використовує strconv.Atoi, текст помилки може бути не тим, що ви хочете обіцяти користувачу. Приклад виду // Output: strconv.Atoi: parsing "nope": invalid syntax буде крихким. Зазвичай краще перевіряти «помилка є» або порівнювати з вашою власною помилкою чи типом помилки, якщо ви справді це гарантуєте.

Помилка № 4: Example перетворено на міні‑підручник на 50 рядків.
Example добре працює, коли несе одну ідею. Якщо ви намагалися в одному прикладі показати і створення задач, і сортування, і форматування, і обробку п’яти помилок — ви, найімовірніше, написали погану документацію. Розділяйте це на кілька прикладів через суфікс: ExampleFoo_basic, ExampleFoo_error, ExampleFoo_customFormat.

Помилка № 5: приклад написано в неправильному пакеті й він «випадково бачить внутрішні деталі».
Якщо ви пишете приклади в package todo, вони мають доступ до всіх неекспортованих імен пакета. Іноді це спокушає, і приклад починає користуватися тим, чого користувач пакета не побачить. Якщо ви хочете приклади саме як «зовнішнє використання», пишіть їх у package todo_test і імпортуйте todo як зовнішній пакет. Це дисциплінує і робить приклади чеснішими.

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