Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 19 из 25 ~18 мин +50 XP

Testify и TestMain: фикстуры пакета

Пакет интеграционных тестов живёт дольше одного теста, и его фикстуры стоят дороже любых ассертов: контейнер базы, пул соединений, чистые таблицы перед каждым методом. Урок собирает этот каркас целиком: suite из testify поднимает один контейнер в SetupSuite и раздаёт его всем методам, TestMain берёт на себя уровень пакета с os.Exit в конце. Связка «testcontainers testify» здесь разобрана от хуков до ассертов, а идиома «testmain go» — на рабочем каркасе с m.Run. Отдельный сюжет — уборка: CleanupContainer из testcontainers делает то, что ручной defer обещает, но не гарантирует. Всё это исполняется только рядом с живым Docker: в песочнице сайта код читается, но не запускается.

Содержание урока · 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 с контейнером на весь пакет

GO
package 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 внутри — уйдут одновременно и подерутся за одни таблицы. Изоляция данных и портов между параллельными прогонами — тема урока о параллельности, а здесь держим простое правило: фикстуры — в хуках, состояние — чистым на входе каждого метода. Так пакет выдерживает и добавление новых методов, и перестановку старых без правок фикстур.

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: фикстуры уровня пакета

GO
var 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, умную уборку и немедленный провал хука.

func TestBookingsSuite(t *testing.T) { suite.(t, new(BookingsSuite)) } func (s *BookingsSuite) SetupSuite() { ctr, err := postgres.Run(ctx, "postgres:16-alpine") testcontainers.(s.T(), ctr) s.().NoError(err) }

Соберите порядок прогона suite

Расставьте события прогона suite от запуска пакета до уборки.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Типовые ошибки фикстур пакета#

  • поднимать контейнер в каждом Test-методе: пакет тянется минутами вместо одного подъёма в SetupSuite
  • чистить данные в TearDownSuite, а не в SetupTest: мусор посреди прогона переживёт следующие тесты
  • assert на пути к фикстурам: nil-пул после провала роняет тест паникой вместо внятной ошибки
  • defer ctr.Terminate в хуке: defer сработает в конце хука, а не в конце пакета
  • мутировать общие поля suite в методах: последовательное исполнение ещё не изоляция данных
  • t.Parallel внутри suite поверх общей базы без изоляции: методы дерутся за одни таблицы
  • вызывать m.Run дважды: второй прогон идёт поверх уже закрытых фикстур
  • рассчитывать только на супервизора Ryuk: живые контейнеры между тестами тратят память и порты

Карточки: фикстуры пакета

Термин

suite.Suite

нажмите, чтобы перевернуть
Определение

Структура для встраивания: тесты становятся методами с префиксом Test, общие ресурсы — полями. Даёт s.T(), s.Assert() и s.Require(); запускается через suite.Run(t, new(...)).

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 и сравните, сколько контейнеров поднимает пакет в обоих вариантах.

Личные заметкиПусто
Хранятся только в вашем браузере (Testcontainers · Testify и TestMain: фикстуры пакета)

Вопросы и ответы по уроку

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 Base25 уроков~12 чновичок25 уроков уже доступны

Go с нуля: базовый синтаксис и конструкции

Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.

  • Писать, компилировать и запускать программы на Go
  • Свободно работать с переменными, типами, операторами
  • Управлять потоком выполнения: if, switch, for, range
  • Хранить данные в массивах, срезах и картах