Содержание урока · 10 разделов
- Паттерны тестов Go: общий TestMain и os.Exit
- Пакет-фикстура: internal/testenv
- Snapshot и Restore: чистая база без пересоздания
- Один образ — много тестов, запуск по требованию
- Паттерн, когда применять, цена
- Границы паттернов: когда схема мешает
- Чек-лист выбора паттерна
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Двадцать три урока сложились в арсенал: GenericContainer, модули, wait-стратегии, сети, параллельность, уборщик. По отдельности это инструменты, вместе — детали, из которых собираются паттерны тестов Go: повторяемые схемы, в которых контейнеры, фикстуры и уборка стоят на отведённых местах. Отличие паттерна от удачного фрагмента кода в воспроизводимости: схема объясняет не только что писать, но и когда применять, и чем придётся заплатить. В этом уроке пять схем, каждая прошла обкатку на исходниках библиотеки и в реальных репозиториях: общий TestMain с os.Exit, пакет-фикстура internal/testenv, Snapshot и Restore на Postgres, один образ на весь прогон и ленивый запуск контейнера по требованию. Названия условны, механика — нет: за каждой стоит конкретный вызов API, который мы уже трогали. Сквозные детали прежние: wait-стратегия перед возвратом, Terminate и страховка уборщика после, — паттерн лишь решает, кому принадлежит контейнер и когда он стартует. Детали фикстур пакета разбирались в уроке про testify и TestMain, изоляция данных — в уроке про параллельность.
Паттерны тестов Go: общий TestMain и os.Exit#
Когда каждый тест поднимает собственный контейнер, пакет платит за каждый старт дважды: временем ожидания и риском флейка. Паттерн общего TestMain меняет раскладку: контейнер поднимается один раз до всех тестов, Test-функции делят его между собой, уборка идёт после m.Run. Точка входа стандартна: func TestMain(m *testing.M), внутри — подготовка, m.Run() и os.Exit с её кодом. Контакт жёсткий: TestMain — обычная функция, и если она упадёт раньше m.Run, тесты пакета не начнутся вовсе. Поэтому ошибку старта оформляют громко — log.Fatalf с причиной, — а не молчаливым выходом, который оставит зелёный прогон без единого теста. Деление контейнера — не бесплатная щедрость: состояние общее, и грязь одного теста видна другому. Для базы это лечит Snapshot/Restore из третьего паттерна, для брокеров — топики и префиксы на тест. Terminate после m.Run обязателен: CleanupContainer тут не помощник, он привязан к testing.TB, а до первого теста его нет. Порядок финала важен: уборка до os.Exit — иначе код возврата уйдёт системе раньше, чем контейнер получит сигнал останова. Присваивайте пакетные переменные до старта тестов: при -race детектор следит за ними как за обычным глобальным состоянием.
Общее окружение пакета
GOpackage users_test
import (
"context"
"log"
"os"
"testing"
"github.com/testcontainers/testcontainers-go/modules/postgres"
)
var pg *postgres.PostgresContainer
func TestMain(m *testing.M) {
ctx := context.Background()
ctr, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("users"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
)
if err != nil {
log.Fatalf("общий postgres: %v", err)
}
pg = ctr
code := m.Run()
if err := ctr.Terminate(context.Background()); err != nil {
log.Printf("уборка: %v", err)
}
os.Exit(code)
}TestMain исполняется один раз на пакет: до него не стартует ни один тест. postgres.Run поднимает общий контейнер, ошибка старта гасит процесс через log.Fatalf — пакет краснеет целиком, но с понятной причиной. m.Run возвращает код прогона, Terminate убирает контейнер, os.Exit передаёт код CI. Свежий context.Background в уборке обязателен: контексты тестов к этому моменту уже завершены.
Пакет-фикстура: internal/testenv#
Три пакета с одинаковым запуском postgres — сигнал: тестовые фикстуры Go созрели для собственного пакета. Классика жанра — internal/testenv: внутренний каталог, недоступный внешним импортёрам, с функциями вроде MustPostgres, MustRedis и ResetAll. Хелпер прячет postgres.Run с общими настройками образа, Once-защиту и загрузку миграций; тест становится декларативным: ctr := testenv.MustPostgres(t, ctx) — и дальше только сценарий. Пакет-фикстура — это код, и требования к нему обычные: собственные тесты, ревью, владелец. Ошибка в фикстуре краснит десятки пакетов разом, зато чинится один раз и в одном месте — в этом вся выгода. Границу разумного подсказывает счётчик копий: один хелпер живёт на месте, второй терпит, третий — переезжает в testenv. Внутри живут и производные помощники: строка подключения для postgres, адрес для redis, заготовка пула поверх базы. Тогда тесты не трогают модуль напрямую, а смена образа или настроек — правка одного файла; значения по умолчанию держите в константах пакета. В песочнице такой пакет не собрать — нужен локальный модуль с go.mod.
Хелперы пакета testenv
GOpackage testenv
import (
"context"
"sync"
"testing"
"github.com/testcontainers/testcontainers-go/modules/postgres"
)
var (
once sync.Once
ctr *postgres.PostgresContainer
ready error
)
// Postgres лениво поднимает общий контейнер при первом вызове,
// остальные получают тот же инстанс.
func Postgres(ctx context.Context) (*postgres.PostgresContainer, error) {
once.Do(func() {
ctr, ready = postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("app"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
)
})
return ctr, ready
}
// MustPostgres — вариант для тестов: база не готова, тест падает сам.
func MustPostgres(tb testing.TB, ctx context.Context) *postgres.PostgresContainer {
c, err := Postgres(ctx)
if err != nil {
tb.Fatalf("общий postgres: %v", err)
}
return c
}Пакет testenv лежит в internal: снаружи модуля его никто не импортирует. sync.Once гарантирует, что postgres.Run исполнится ровно один раз, даже если тесты дёргают хелпер из разных горутин; остальные вызовы получают готовый контейнер и сохранённую ошибку. MustPostgres принимает testing.TB: неудача старта роняет конкретный тест с внятным сообщением, а не возвращает молчаливый nil.
Snapshot и Restore: чистая база без пересоздания#
На Postgres изоляцию данных даёт связка методов модуля: Snapshot копирует базу в шаблонную — по умолчанию migrated_template, — а Restore стирает рабочую базу и пересоздаёт её из шаблона. Механика честная: Snapshot выполняет CREATE DATABASE с TEMPLATE и флагом is_template, повторный вызов перезаписывает снимок; Restore сначала гасит чужие соединения, затем стирает базу с FORCE и собирает её заново из шаблона. Отсюда два практических правила. Первое: у модуля по умолчанию база называется postgres — системное имя, и снапшоты на нём откажутся работать, так что WithDatabase с обычным именем обязателен. Второе: Restore убивает соединения — параллельным тестам он не товарищ, каждому t.Parallel нужна своя схема или своя база. Типовой конвейер: миграции один раз после старта, снимок один раз после миграций, откат перед каждым тестом. Имя снимка настраивается опцией: отдельные шаблоны удобны, когда тестам нужны разные стартовые состояния. Восстановление идёт SQL-командами, а если драйвер недоступен, модуль падает обратно на psql через exec — база всё равно вернётся к снимку. Сам модуль Postgres разобран в четвёртом уроке.
Снимок один раз, откат на каждый тест
GO// в testenv: снимок делается один раз после миграций
var shot sync.Once
func SnapshotOnce(ctx context.Context) error {
var err error
shot.Do(func() {
err = ctr.Snapshot(ctx) // по умолчанию база migrated_template
})
return err
}
// в тесте: откат к снимку перед сценарием
func TestCreateBooking(t *testing.T) {
ctx := t.Context()
c := testenv.MustPostgres(t, ctx)
if err := testenv.SnapshotOnce(ctx); err != nil {
t.Fatalf("снапшот: %v", err)
}
if err := c.Restore(ctx); err != nil {
t.Fatalf("откат базы: %v", err)
}
// база чиста: пишем, читаем, ломаем —
// следующий тест начнёт ровно с этого состояния
}Snapshot копирует базу в шаблонную: CREATE DATABASE с TEMPLATE и флагом is_template; повторный вызов перезаписывает снимок, так что дёргать его на каждый тест незачем. Restore гасит чужие соединения, стирает базу с FORCE и пересоздаёт её из шаблона. Снимать снимок удобно один раз после миграций — из TestMain или хелпера — а восстанавливать перед каждым тестом.
Схема выглядит универсальной, пока в пакете не появляется t.Parallel: Restore стирает базу целиком, и параллельный тест теряет соединение посреди запроса. Флейк выходит загадочный — тест падает не на своих данных, а на чужой уборке, и поиск занимает вечер. Отсюда дисциплина: снапшот-паттерн живёт в последовательных пакетах, а параллельным держит схему или базу на тест. Спорное мнение с проверкой на практике: откат в t.Cleanup каждого теста, будто это спасает порядок, — не спасает, Cleanup в параллельном прогоне не выстраивается в очередь, и соединения всё равно рвутся. Выход, если параллельность нужна позарез, — отдельная база на тест или группу: откат стирает только свою базу и чужим не мешает.
Один образ — много тестов, запуск по требованию#
Два паттерна про экономию. Первый — один образ на много тестов: docker pull тяжёлого образа дорожает с каждым прогоном, а кэш локальной машины хранит слои долго. Прогрейте кэш до тестов — в CI отдельным шагом, локально руками, — и прогоны платят только за старт контейнера: секунды вместо минут загрузки. Второй — ленивый запуск: контейнер поднимается при первом обращении к хелперу, а не в TestMain. Выгода в пакетах, где зависимость нужна половине тестов: быстрые тесты не ждут чужого postgres, а падение старта всплывает с внятным сообщением в конкретном тесте. Механика — sync.Once: тело Do исполнится один раз за процесс, остальные вызовы получат готовый контейнер и сохранённую ошибку. Если первый старт упал, Once повторять попытку не станет — ошибку увидят все: это фича, а не баг, прогон честно краснеет вместо трёх зависаний. Кэш общий для всей машины: образ, скачанный любым другим проектом, тестам загрузкой уже не грозит. Хелпер с Once свободно композируется с TestMain: каркас остаётся, а дорогие зависимости переезжают внутрь функций-получателей.
Паттерн, когда применять, цена#
| Паттерн | Когда применять | Цена |
|---|---|---|
| Общий TestMain + os.Exit | все тесты пакета живут с одной зависимостью | единая точка отказа: падение старта краснит весь пакет |
| Пакет-фикстура internal/testenv | одинаковые хелперы кочуют между пакетами | нужен владелец: пакет — такой же код, с тестами и ревью |
| Snapshot/Restore на Postgres | тесты пишут в общую базу и хотят чистый старт | Restore гасит соединения: несовместим с t.Parallel по данным |
| Один образ — много тестов | тяжёлый образ и сотни тестов на нём | первый прогон платит за pull, дальше спасает кэш |
| Ленивый запуск по требованию | зависимость нужна части тестов пакета | ошибки старта всплывают посреди прогона, а не в начале |
| Контейнер на каждый тест | максимальная изоляция: тесты портят окружение | самый медленный прогон: старт и ожидание на каждый тест |
Таблица не выбирает паттерн за вас — она расставляет цены. Практический алгоритм проще. Первый вопрос: сколько тестов пакета трогают зависимость? Все — общий TestMain, часть — ленивый запуск. Второй: пишут ли тесты в общее состояние? Да — снимок и откат для базы либо схема на тест; нет — достаточно общего контейнера. Третий: сколько раз хелпер скопирован между пакетами? Больше двух — собирайте internal/testenv. Схемы складываются: общий TestMain с ленивым запуском внутри, пакет-фикстура со снапшотами на Postgres. Собрать такой стенд целиком — дело итогового проекта, где эти приёмы соединяются в один прогон. Соотносите цены с частотой прогонов: схема, экономящая минуту на прогон, окупает час настройки за сотню запусков CI. Ещё критерий — онбординг: новичок обязан поднимать тесты одной командой go test, без инструкции по стендам.
Границы паттернов: когда схема мешает#
У каждого паттерна есть грань, за которой он вредит. Общий TestMain усложняет отладку одиночного теста: go test -run теперь тянет весь контейнерный багаж пакета — иногда это цена за скорость остальных. Пакет-фикстура без тестов и владельца превращается в свалку: полсотни функций, половина мертва, чинить страшно. Снимок на каждую мелочь замедляет прогон: откат не бесплатен, и тесту, которому нужна одна пустая таблица, не обязательно восстанавливать всю базу. Ленивый запуск прячет ошибки старта внутрь прогона: на CI это краснеет позже и ищется дольше. Правило простое: паттерн — инструмент со сроком годности, и как только он замедляет больше, чем экономит, его разбирают обратно до обычных тестов с собственными контейнерами. Отдельная ловушка — смешение схем без плана: TestMain поднимает контейнер, testenv поднимает второй, тесты поднимают третий, и прогон множит контейнеры, хотя каждый паттерн по отдельности экономил. Аудит элементарен: docker ps посреди прогона должен показывать ровно ожидаемое число контейнеров.
Честная оговорка: в песочнице сайта код урока не исполняется — тестам нужен локальный Docker с живым демоном, которого браузерная среда не даёт. Схема знакомая по всему курсу: соберите фрагменты в пакеты рядом с go.mod, убедитесь, что docker info отвечает, и запустите go test — общий TestMain, testenv и снапшоты отработают на вашей машине. Эксперимент на понимание: закомментируйте Terminate в TestMain и найдите осиротевший контейнер через docker ps -a; уборщик приберёт его только по завершении сессии. Проверьте и граничный сценарий: прервите прогон клавишами — сессионный уборщик подхватит сирот после смерти процесса.
Проверка: TestMain падает на старте
В TestMain общий postgres.Run вернул ошибку, и хелпер вызвал log.Fatalf. Что увидит прогон пакета?
Проверка: снапшот системной базы
Контейнер postgres поднят без WithDatabase — база по умолчанию называется postgres. Что вернёт Snapshot?
Сколько контейнеров поднимется
Прочитайте код и выберите, что он выведет:
// Тесты A, B и C одного пакета подряд вызывают
// testenv.MustPostgres(t, ctx).
// Внутри хелпера — sync.Once с postgres.Run.Соберите TestMain
Восстановите каркас общего окружения: ошибка старта, код прогона и уборка.
Соберите конвейер фикстуры пакета
Расставьте шаги: от внутреннего пакета до кода возврата.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Чек-лист выбора паттерна#
- зависимость нужна всем тестам пакета — TestMain; только части — ленивый запуск через sync.Once
- ошибку старта общего контейнера оформляйте внятно: log.Fatalf с причиной, а не тихий выход
- три пакета с одинаковыми хелперами — сигнал вынести код в internal/testenv
- тесты пишут в общую базу — снимок после миграций и Restore перед тестом
- Restore разрывает соединения: рядом с t.Parallel держите схему или базу на тест
- тяжёлый образ прогрейте заранее: docker pull до прогона, тесты ждут только старт
- общий контейнер не отменяет уборку: Terminate в TestMain после m.Run обязателен
- паттерн не заменяет wait: контейнер обязан быть готовым к моменту возврата хелпера
Карточки: паттерны
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Общий TestMain поднимает контейнер один раз на пакет и завершается os.Exit с кодом m.Run; ошибка старта роняет процесс до тестов и должна быть внятной.
- internal/testenv — пакет-фикстура: хелперы прячут postgres.Run, sync.Once и миграции; три скопированных хелпера — сигнал вынести код.
- Snapshot копирует базу Postgres в шаблон migrated_template, Restore откатывает её с гашением соединений и FORCE; системная база postgres не восстанавливается.
- Один образ греет кэш Docker, ленивый запуск через sync.Once стартует контейнер по требованию; паттерн выбирают по доле тестов, трогающих зависимость.
Домашнее задание
Соберите пакет из трёх тестов с общим TestMain: postgres.Run с WithDatabase, миграции-заглушка, Snapshot после миграций, Restore перед каждым тестом. Затем вынесите хелпер в internal/testenv с sync.Once и сравните время прогона до и после. Финал: при ошибке старта верните управление из TestMain до m.Run и убедитесь, что прогон завершается без единого теста. Зафиксируйте docker ps до и после прогона: контейнеры обязаны исчезнуть.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем общий TestMain отличается от testify suite из девятнадцатого урока?
Механика одна — контейнер на пакет, — различается каркас. TestMain встроен в testing и не тянет зависимостей: подготовка до m.Run, уборка после. Suite даёт хуки SetupSuite и SetupTest, доступ к Require и изоляцию состояния suite-объекта, а CleanupContainer вызывается из его T. Практическое правило: один тестовый тип с общими полями — suite, разрозненные функции пакета — TestMain. Смешивать можно: TestMain поднимает контейнер, suite живёт поверх. Выбор отражается и на логах: suite печатает шаги по тестам, TestMain — один журнал на пакет.
Snapshot дорогой? Не упадёт ли скорость прогона?
Снимок — это CREATE DATABASE по шаблону: копия на уровне каталога данных, без дамп-файла и сети, поэтому дорогим обычно оказывается не сам Snapshot, а Restore перед каждым тестом: он гасит соединения и пересоздаёт базу. Практика: снимайте снимок один раз после миграций и восстанавливайте только тесты, которые реально пишут в базу; читающим тестам откат не нужен. На практике откат занимает секунды против долей секунды на снимок — замерьте на своём объёме данных перед выбором схемы.
Можно ли лениво запускать не только базы, но и брокеры?
Да, схема sync.Once не привязана к модулю: MustKafka и MustRabbitMQ пишутся по тому же лекалу — Run с общими настройками, Once, сохранённая ошибка, tb.Fatalf на неудаче. Разница в подготовке: брокерам часто нужны топики или обменники до тестов, и эту инициализацию тоже прячут в Once — она исполнится один раз вместе с контейнером. Правило то же: одна инициализация на процесс, сохранённая ошибка для всех вызовов.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Go с нуля: базовый синтаксис и конструкции
Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.
- Писать, компилировать и запускать программы на Go
- Свободно работать с переменными, типами, операторами
- Управлять потоком выполнения: if, switch, for, range
- Хранить данные в массивах, срезах и картах