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" или добавите лишний пробел — пример упадёт. Иногда это раздражает (особенно когда очень хочется «чуть‑чуть красоты»), но в долгую это спасает: документация всегда соответствует реальности.
4. Имена Examples и привязка к GoDoc
Теперь важная часть: почему мы вообще так заморачиваемся с именами Example...? Потому что GoDoc и pkg.go.dev умеют по имени Example‑функции понять, к чему этот пример относится, и показать его рядом с нужным элементом API.
В Go приняты соглашения по именованию Example‑функций, и они реально работают на практике (вы это увидите, когда откроете документацию пакета). Ниже — краткая таблица, чтобы было удобно как шпаргалка.
| Имя example‑функции | К чему относится в документации |
|---|---|
|
к пакету в целом |
|
к функции/переменной/типу Foo |
|
к типу Type |
|
к методу Method типа Type |
|
к Foo, но как отдельный сценарий (suffix для различения) |
Смысл suffix‑части простой: иногда у одной функции есть несколько «режимов применения», и вы хотите два примера. Тогда делаете ExampleParseID_simple() и ExampleParseID_error() — и оба будут относиться к ParseID, просто как отдельные кейсы.
Эта механика активно используется в стандартной библиотеке. А ещё вы будете регулярно встречать примеры вида ExampleRegexp_FindString() — такой формат можно увидеть в живом рассказе про добавление примеров в пакет regexp.
5. Практические варианты Examples
Example без // Output:: compile‑only примеры
Иногда пример хочется показать, но он не может быть детерминированным по stdout. Например, код обращается к сети, зависит от текущего времени или от порядка файлов в директории. В таких случаях у 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‑тест — это рецепт, который должен получаться одинаковым каждый раз. Если вы добавляете туда «щепотку случайности по вкусу» — тестовый раннер этого вкуса не оценит.
Whole‑file examples: когда одного func Example...() недостаточно
Иногда вы хотите в примере объявить тип и метод, чтобы показать, как ваш API работает с интерфейсами или с кастомными структурами. И тут новичок натыкается на суровую правду: внутри функции нельзя объявить метод (методы объявляются на уровне пакета). Значит, обычного func Example...() может быть недостаточно.
Для таких ситуаций в Go существуют “whole‑file examples”: это когда целый _test.go файл становится примером, и в нём разрешено объявлять вспомогательные типы/методы на уровне пакета, а потом уже писать Example...().
В учебном контексте это удобно показывать на простом сценарии: допустим, у нас есть Status и String() у статуса задачи, и мы хотим пример, где статус печатается красиво.
6. Пример: добавляем Examples в пакет todo
Чтобы не делать примеры ни о чем, будем считать, что у нас уже есть небольшой пакет todo, который мы развиваем по курсу. Он не обязан быть огромным; нам достаточно нескольких функций/типов, чтобы показать документационные примеры.
Представим, что в пакете todo уже есть:
- ParseID(s string) (int, error) — парсит ID,
- ErrEmptyTitle и ValidateTitle(title string) error — валидация,
- Status со String() — человекочитаемое состояние.
Теперь создадим файл example_test.go в пакете todo. Начнём с примера для ValidateTitle. Мы хотим показать «типовой отказ», потому что именно это обычно важнее, чем happy‑path.
package todo
import (
"fmt"
)
func ExampleValidateTitle() {
err := ValidateTitle("")
fmt.Println(err == ErrEmptyTitle) // true
// Output: true
}
Обратите внимание на стиль: мы не печатаем текст ошибки целиком (он может поменяться), а проверяем контракт, который хотим зафиксировать. Если вы договорились, что ValidateTitle("") возвращает именно ErrEmptyTitle, то такой Example защищает этот контракт лучше любой лекции.
Теперь добавим пример для ParseID, но сделаем два сценария: успешный и ошибочный. Для этого используем suffix.
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).
7. Чем Example отличается от unit‑теста
Иногда начинающие разработчики путают Example‑тесты с обычными unit‑тестами и пытаются в Example запихнуть все проверки сразу. Чтобы мозгу было проще, держите такую схему:
flowchart TD
A[Unit-тест TestXxx] --> B[Проверяем много кейсов]
A --> C[Можно использовать t.Fatalf/t.Errorf]
A --> D[Фокус: корректность внутри]
flowchart TD
E[Example-тест ExampleXxx] --> F[Показываем один сценарий использования]
E --> G[Обычно печатаем stdout]
E --> H[Фокус: как пользоваться API + стабильный результат]
Unit‑тест отвечает на вопрос «это работает?», а Example отвечает на вопрос «как этим пользоваться так, чтобы было правильно и ожидаемо». В хорошем проекте они дополняют друг друга, а не конкурируют.
8. Типичные ошибки
Ошибка №1: пример зависит от нестабильного порядка или времени.
Это самая неприятная категория: пример может проходить у вас локально и падать у коллеги. Часто причиной становится печать map без сортировки или использование time.Now(). Лекарство простое: в примерах печатайте только детерминированные вещи, а если нужен порядок — сортируйте данные до печати.
Ошибка №2: // Output: написан «примерно так», но не совпадает посимвольно.
Часто ломает новичков не логика, а один лишний пробел или перенос строки. Учитывайте, что раннер сравнивает stdout строго. Делайте вывод коротким, не печатайте лишние пояснения, а если нужно показать сложную структуру, печатайте булевы проверки (true/false) или компактные значения.
Ошибка №3: попытка зафиксировать текст чужой ошибки как часть контракта.
Если ваш ParseID внутри использует strconv.Atoi, текст ошибки может быть не тем, что вы хотите обещать пользователю. Пример вида // Output: strconv.Atoi: parsing "nope": invalid syntax будет хрупким. Обычно лучше проверять «ошибка есть» или сравнивать с вашей собственной ошибкой/типом ошибки, если вы действительно это гарантируете.
Ошибка №4: Example превращён в мини‑учебник на 50 строк.
Example хорошо работает, когда несёт одну идею. Если вы пытались в одном примере показать и создание задач, и сортировку, и форматирование, и обработку пяти ошибок — вы, скорее всего, написали плохую документацию. Разделяйте на несколько примеров через suffix: ExampleFoo_basic, ExampleFoo_error, ExampleFoo_customFormat.
Ошибка №5: пример написан в неправильном пакете и «случайно видит внутренности».
Если вы пишете примеры в package todo, они имеют доступ ко всем неэкспортируемым именам пакета. Иногда это соблазняет и пример начинает пользоваться тем, что пользователь пакета не увидит. Если вы хотите примеры именно как «внешнее использование», пишите их в package todo_test и импортируйте todo как внешний пакет. Это дисциплинирует и делает примеры честнее.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ