Содержание урока · 10 разделов
Проблема, которую решает suite, знакома каждому, кто писал больше двух интеграционных тестов: пятнадцать Test-функций — и пятнадцать копий одного и того же кода подъёма контейнера. Меняется только тело теста, а boilerplate размножается: декларация, запуск, проверка ошибки, регистрация уборки, сборка строки подключения, пул. Однажды копии расходятся: в одном тесте поправили таймаут wait-стратегии, в другом забыли, — и пакет начинает флапать на ровном месте. testify отвечает на это структурой: тесты становятся методами одной структуры, а общий код уезжает в хуки — SetupSuite исполняется один раз на прогон, SetupTest обрамляет каждый метод. Второй уровень — TestMain: функция, которую testing вызывает до и после всех тестов пакета; в неё складывают фикстуры, которые должны случиться ровно один раз и пережить любой провал. Уровни не конкурируют: suite управляет группой методов, TestMain — всем пакетом сразу, и на практике их совмещают. Механику Terminate и t.Cleanup из урока о жизненном цикле считаем знакомой — теперь она поднимается с уровня теста на уровень пакета.
Suite: один контейнер на весь пакет#
Testify suite — это структура, встраивающая suite.Suite: тесты становятся методами с префиксом Test, а библиотека прогоняет их через фиксированные хуки. Общие ресурсы объявляются полями: контейнер, пул соединений, готовые клиенты репозиториев — каждый метод видит их как s.pool, без глобальных переменных и передачи аргументов. Внутри SetupSuite поднимают тяжёлое: postgres.Run из модуля создаёт контейнер один раз на весь прогон, дёргать Docker на каждый тест не нужно — на пакете из десятков тестов это разница в минуты. Ошибку подъёма проверяют через s.Require().NoError: падение хука помечает suite проваленным, и методы не стартуют вовсе. Один провал при этом не хоронит соседей: упавший метод помечается, а остальные исполняются дальше — со свежим SetupTest. Тут же зовут testcontainers.CleanupContainer(s.T(), ctr) — регистрация на общем T сработает в конце suite.Run при любом исходе. Доступ к текущему тесту даёт s.T(): это тот самый *testing.T, с которым работают t.Cleanup, t.Helper и t.Parallel. Запуск — suite.Run(t, new(BookingsSuite)): Test-методы библиотека находит рефлексией, а методы-помощники без префикса Test игнорирует — их зовут руками из тестов. Так suite собирает связанные сценарии в одну страницу отчёта: имена методов читаются как главы одного раздела.
Suite с контейнером на весь пакет
GOpackage bookings_test
import (
"context"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
)
type BookingsSuite struct {
suite.Suite
ctr *postgres.PostgresContainer
pool *pgxpool.Pool
}
func (s *BookingsSuite) SetupSuite() {
s.T().Helper()
ctx := context.Background() // живёт до конца suite.Run
ctr, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("app"),
postgres.WithUsername("app"),
postgres.WithPassword("app"),
)
testcontainers.CleanupContainer(s.T(), ctr) // до проверки ошибки: nil безвреден
s.Require().NoError(err)
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
s.Require().NoError(err)
pool, err := pgxpool.New(ctx, dsn)
s.Require().NoError(err)
s.ctr, s.pool = ctr, pool
}
func (s *BookingsSuite) TearDownSuite() {
s.pool.Close() // контейнер добьёт Cleanup от CleanupContainer
}
func (s *BookingsSuite) SetupTest() {
_, err := s.pool.Exec(context.Background(),
"TRUNCATE bookings, payments RESTART IDENTITY")
s.Require().NoError(err)
}
func (s *BookingsSuite) TestCreateBooking() {
// s.pool готов, таблицы чисты — тестируем репозиторий
}
func TestBookingsSuite(t *testing.T) {
suite.Run(t, new(BookingsSuite))
}Пул и контейнер живут полями структуры — глобальных переменных нет. CleanupContainer вызван до проверки ошибки: при nil он безвреден, а регистрация на общем T сработает в конце suite.Run даже если SetupSuite упал. TRUNCATE в SetupTest возвращает таблицы в исходное состояние перед каждым методом: тесты не зависят от порядка исполнения. Test-методы находит рефлексия, ручного списка тестов нет.
Хуки suite: что туда класть#
| Хук | Частота | Что туда класть |
|---|---|---|
| SetupSuite | один раз на прогон suite | контейнеры, пул соединений, миграции схемы, справочные данные |
| TearDownSuite | один раз в конце прогона | закрыть пул, TerminateContainer для ресурсов без Cleanup |
| SetupTest | перед каждым методом | чистка состояния: TRUNCATE таблиц, FLUSHALL, сброс последовательностей |
| TearDownTest | после каждого метода | локальные ресурсы метода: транзакция, временные ключи |
| BeforeTest / AfterTest | вокруг каждого метода | логи и метрики с именами suite и теста |
| s.T() | в любом методе | доступ к *testing.T: Cleanup, Helper, Skip, Parallel |
Хуки suite: что куда класть#
Хуков у suite больше двух, и путаница между ними — источник флаки. SetupSuite исполняется один раз на прогон: контейнеры, пул, миграции схемы — всё, что дорого и не меняется от теста к тесту. SetupTest и TearDownTest обрамляют каждый метод: сюда уходит чистка состояния — TRUNCATE таблиц, FLUSHALL в Redis, сброс последовательностей идентификаторов. Тесты обязаны стартовать с одинаковым состоянием, иначе порядок исполнения начнёт влиять на результат, и классическое «зелёное локально, красное на CI» станет нормой пакета. BeforeTest и AfterTest получают имена suite и теста — удобное место для логов и метрик по методам. Полный порядок исполнения легко запомнить как матрёшку: внешний хук один, внутренние — на каждый метод, и внешние не повторяются. Общий ресурс из поля suite нельзя мутировать по мелочи в методах: методы внутри suite исполняются последовательно, но каждый получает ту же базу, что и предыдущий, а с t.Parallel внутри — уйдут одновременно и подерутся за одни таблицы. Изоляция данных и портов между параллельными прогонами — тема урока о параллельности, а здесь держим простое правило: фикстуры — в хуках, состояние — чистым на входе каждого метода. Так пакет выдерживает и добавление новых методов, и перестановку старых без правок фикстур.
Живое место: t.Parallel внутри suite-методов ломает чистку. SetupTest опустошает таблицы, параллельный метод B врывается в середину метода A — и оба пишут в одну базу одновременно: ассерты флапают, вставленные строки исчезают, а на одиночном прогоне всё зелёное. Локально на одном воркере дефект не воспроизводится неделями, а потом всплывает на CI. Лечение: либо не ставить t.Parallel внутри одного suite поверх общей базы, либо изолировать данные — отдельные схемы, префиксы ключей, свои контейнеры. Справочники из SetupSuite при этом читаются параллельными методами спокойно: они read-only.
SetupTest: чистая база перед каждым тестом#
Чистое состояние — главный контракт suite: каждый метод стартует с одинаковыми данными, и падение одного теста не портит соседей. Для базы это TRUNCATE с RESTART IDENTITY: счётчики сбрасываются, ссылки между таблицами не мешают; для Redis — FLUSHALL; для очередей — опустошение перед прогоном; монго-коллекции чистят удалением документов, сохраняя индексы. Чистка живёт в SetupTest, а не в TearDownSuite: убирать после себя бесполезно, если упавший тест оставил мусор посреди пакета — следующий метод унаследует грязную базу и упадёт каскадом, уведя разбор в сторону от настоящей причины. Для Redis-пула параллельно живут префиксы ключей: чистка через FLUSHALL дешевле, но опаснее при общем инстансе. Ускорение — чистить только затронутые таблицы, а не всю схему: TRUNCATE трёх таблиц заметно дешевле пересоздания базы. Хелпер чистки оформляют методом suite с t.Helper внутри, чтобы номер строки падения указывал на тест, а не на хелпер. Справочные данные, общие для всех методов — словари, справочники, константы каталога, — сеют один раз в SetupSuite: они read-only, и пересеивать их на каждом тесте — выброшенные секунды прогона. Драйвер pgx для такого пула разобран в отдельном курсе.
Проверка: сколько раз SetupTest
В suite три Test-метода. Сколько раз исполнятся SetupSuite и SetupTest?
CleanupContainer вместо ручного defer#
Почему CleanupContainer удобнее ручного defer ctr.Terminate(ctx)? Первое: он nil-безопасен — вызов сразу после создания, ещё до проверки ошибки, не паникует: nil-контейнер для него no-op. Второе: внутри он зовёт tb.Helper и tb.Cleanup — Terminate регистрируется рядом с контейнером и исполняется при любом исходе прогона: провале ассертов, t.FailNow и панике. Третье и главное — уборка умная: TerminateContainer под капотом проглатывает безопасные ошибки — контейнер уже удалён, удаление уже идёт, ресурс не найден, — и require.NoError внутри срабатывает только на настоящие проблемы. Ручной defer, который честно проверяет ошибку Terminate, уронит тест на «уже остановлен» — классическая грабля двойной уборки, когда Cleanup уже сработал, а defer ещё жив. Четвёртое: сигнатура принимает testing.TB, поэтому одна и та же строка работает в обычном тесте, в бенчмарке и в s.T() из suite. Наконец, обёртка избавляет от сложной ветки при досрочном выходе: провал посреди хука не перепрыгивает регистрацию — она уже случилась. Для сетей есть симметричный CleanupNetwork. Остатки после смерти процесса зачищает супервизор из урока про Ryuk.
TestMain: фикстуры уровня пакета
GOvar testPool *pgxpool.Pool
func TestMain(m *testing.M) {
ctx := context.Background()
ctr, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("app"),
postgres.WithUsername("app"),
postgres.WithPassword("app"),
)
if err != nil {
log.Fatalf("подъём контейнера: %v", err)
}
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
if err != nil {
log.Fatalf("строка подключения: %v", err)
}
testPool, err = pgxpool.New(ctx, dsn)
if err != nil {
log.Fatalf("пул: %v", err)
}
code := m.Run() // все Test-функции пакета
testPool.Close()
if err := testcontainers.TerminateContainer(ctr); err != nil {
log.Printf("уборка пакета: %v", err)
}
os.Exit(code)
}TestMain — точка входа пакета тестов: до m.Run поднимаются фикстуры, после — уборка. Контейнер здесь один на все Test-функции пакета, включая обычные тесты без suite. Код возврата m.Run — единственный источник статуса: любой провал теста превращается в код процесса, и именно его читает CI. Явный os.Exit(code) делает порядок операций однозначным.
Связка testcontainers testify не притянута со стороны: testify — прямая зависимость самой библиотеки, объявленная в её go.mod, и собственный код ассертами пользуется. CleanupContainer внутри держится на require.NoError — умная уборка построена на тех же require и assert, что и ваши тесты. Хелперы библиотеки устроены так же: SkipIfProviderIsNotHealthy помечает тест пропущенным через t.Skipf, когда демон Docker не отвечает, — пакет в CI без контейнеров живёт, вместо роя красных тестов; RequireContainerExec исполняет команду в контейнере и проверяет и ошибку, и код выхода. Собственные тесты проекта сверяют значения через require.Equal и require.NotEmpty — стиль един от хелперов до уборки. Идиома «testmain go» тем временем решает задачу шире suite: фикстуры уровня пакета достаются и обычным Test-функциям, которые ничего не знают о suite. Обратная сила та же: одна ошибка в TestMain валит весь пакет до первого теста, поэтому проверки там строгие. Подробно про обе библиотеки ассертов — в курсе testify.
Проверка: nil под CleanupContainer
postgres.Run вернул ошибку, ctr равен nil, но CleanupContainer(s.T(), ctr) уже вызван до проверки ошибки. Что произойдёт?
require и assert: кто роняет тест#
Ассерты в suite живут в двух формах: пакеты require и assert напрямую или методы s.Require() и s.Assert(), которые suite раздаёт от тех же пакетов. Разница принципиальна: require при провале вызывает FailNow и останавливает тест немедленно, assert лишь помечает провал и продолжает исполнение. На пути к фикстурам выбирают require: ошибка подключения к базе не должна вести код дальше к nil-пулу — паника размажет настоящую причину по стектрейсу. Ассерты результата берут assert'ом, когда после провала безопасно дособрать остальные проверки: за один прогон соберётся список всех расхождений, а не первый попавшийся. В хуках выбор сделан за вас: там, где ошибка обрывает дальнейший путь, require — единственный безопасный вариант. Собственные хелперы помечают t.Helper: строки падения укажут на вызов в тесте, а не на тело хелпера. Внутри методов suite require и assert доступны и без импорта пакетов — через поля структуры, и это держит файлы тестов короткими. Выбор между формами — вопрос вкуса, но в одном файле его держат единым: смешение s.Require и require из пакета путает читателя.
Код выхода без os.Exit
Прочитайте код и выберите, что он выведет:
// Хвост TestMain:
// code := m.Run()
// _ = testcontainers.TerminateContainer(ctr)
// return // os.Exit забыли
// В пакете два теста, один из них упал.Соберите каркас suite
Восстановите запуск suite, умную уборку и немедленный провал хука.
Соберите порядок прогона suite
Расставьте события прогона suite от запуска пакета до уборки.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Типовые ошибки фикстур пакета#
- поднимать контейнер в каждом Test-методе: пакет тянется минутами вместо одного подъёма в SetupSuite
- чистить данные в TearDownSuite, а не в SetupTest: мусор посреди прогона переживёт следующие тесты
- assert на пути к фикстурам: nil-пул после провала роняет тест паникой вместо внятной ошибки
- defer ctr.Terminate в хуке: defer сработает в конце хука, а не в конце пакета
- мутировать общие поля suite в методах: последовательное исполнение ещё не изоляция данных
- t.Parallel внутри suite поверх общей базы без изоляции: методы дерутся за одни таблицы
- вызывать m.Run дважды: второй прогон идёт поверх уже закрытых фикстур
- рассчитывать только на супервизора Ryuk: живые контейнеры между тестами тратят память и порты
Честная оговорка: ни suite, ни TestMain в песочнице сайта не исполнятся — подъём контейнера начинается с обращения к демону Docker, которого в браузерной среде нет. Рабочая схема локальная: сохраните код урока в *_test.go рядом с go.mod, подтяните testify и модуль postgres, запустите go test -v — в выводе видно исполнение хуков: SetupSuite один раз, SetupTest перед каждым методом. Весь код разобран построчно, так что отсутствие Docker чтению не мешает. Эксперимент: добавьте t.Log в каждый хук и сверьте наблюдаемый порядок с таблицей выше.
Карточки: фикстуры пакета
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Suite из testify превращает тесты в методы одной структуры: контейнер и пул живут полями, подъём — один раз в SetupSuite на весь прогон.
- SetupTest и TearDownTest обрамляют каждый метод: TRUNCATE таблиц и FLUSHALL ключей держат состояние чистым независимо от порядка методов.
- CleanupContainer зовут сразу после создания, до проверки ошибки: nil — no-op, безопасные ошибки уборки проглатываются, Cleanup срабатывает при любом исходе.
- TestMain поднимает фикстуры уровня пакета: контейнер до m.Run, уборка после, os.Exit(code) отдаёт CI код прогона.
Домашнее задание
Соберите suite на postgres-модуле: SetupSuite поднимает контейнер и пул, CleanupContainer регистрирует уборку, SetupTest чистит две таблицы. Добавьте два Test-метода с t.Log и по выводу go test -v убедитесь, что чистка исполняется перед каждым. Затем перенесите подъём контейнера в TestMain с m.Run и os.Exit и сравните, сколько контейнеров поднимает пакет в обоих вариантах.
Личные заметкиПусто
Вопросы и ответы по уроку
SetupSuite упал — исполнятся ли Test-методы?
Нет: suite помечает общий тест проваленным и пропускает методы — запускать их без контейнера и пула бессмысленно. При этом уборка не теряется: Cleanup, зарегистрированный CleanupContainer на общем T, исполняется в конце suite.Run при любом исходе, как и любой Cleanup фреймворка. TearDownSuite в этом сценарии прибирает то, что успело создаться до провала.
Suite или TestMain — что выбрать?
Suite даёт хуки на каждый тест и группировку методов — выбор по умолчанию для пакета с общей базой. TestMain — уровень пакета: один контейнер на все Test-функции, включая обычные тесты без suite. Их совмещают: TestMain поднимает самый дорогой контейнер, suite внутри распределяет состояние по методам. Дублировать подъём в обоих местах — гонять Docker зря. Практический ориентир: всё, что дорого и общее для пакета, тянется наверх, всё, что специфично для сценария, остаётся в хуках suite.
Почему в хуках require, а не assert?
require при провале вызывает FailNow немедленно: хук останавливается, методы не стартуют, паники на nil-пуле не случится. assert лишь помечает провал и продолжает исполнение — после провального NoError код полез бы дальше с nil-контейнером и упал бы уже паникой, размазав настоящую причину по стектрейсу. Правило простое: путь к фикстурам — require, ассерты результата — assert или s.Assert().
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Go с нуля: базовый синтаксис и конструкции
Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.
- Писать, компилировать и запускать программы на Go
- Свободно работать с переменными, типами, операторами
- Управлять потоком выполнения: if, switch, for, range
- Хранить данные в массивах, срезах и картах