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

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

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

Урок 24 из 25 ~16 мин +50 XP

Паттерны: тестовые хелперы и структура

Финальная теория курса собирает рассыпанные приёмы в паттерны тестов Go: пять проверенных схем организации интеграционных тестов, от общего TestMain до пакета-фикстуры internal/testenv. Каждый паттерн — ответ на конкретную боль: медленный прогон, дубли кода, грязная база, рой одинаковых контейнеров. Разберём тестовые фикстуры Go в разрезе testcontainers: где жить общему окружению, зачем Snapshot и Restore на Postgres, когда контейнер поднимать лениво и почему один образ дешевле десяти. Паттерны тестов Go разобраны с ценой каждого решения: выгода не бывает бесплатной, и схему придётся выбирать под свой пакет. Каркас у всех схем общий — контейнер, готовность, уборка, — меняется только владелец и момент старта. Осторожность ради: код урока требует живого Docker и в песочнице не запустится — но читать его можно без машины.

Содержание урока · 10 разделов

Двадцать три урока сложились в арсенал: 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 детектор следит за ними как за обычным глобальным состоянием.

Общее окружение пакета

GO
package 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

GO
package 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 или хелпера — а восстанавливать перед каждым тестом.

Один образ — много тестов, запуск по требованию#

Два паттерна про экономию. Первый — один образ на много тестов: 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 посреди прогона должен показывать ровно ожидаемое число контейнеров.

Проверка: TestMain падает на старте

В TestMain общий postgres.Run вернул ошибку, и хелпер вызвал log.Fatalf. Что увидит прогон пакета?

Проверка: снапшот системной базы

Контейнер postgres поднят без WithDatabase — база по умолчанию называется postgres. Что вернёт Snapshot?

Сколько контейнеров поднимется

Прочитайте код и выберите, что он выведет:

// Тесты A, B и C одного пакета подряд вызывают
// testenv.MustPostgres(t, ctx).
// Внутри хелпера — sync.Once с postgres.Run.

Соберите TestMain

Восстановите каркас общего окружения: ошибка старта, код прогона и уборка.

func TestMain(m *testing.M) { ctx := context.Background() ctr, err := postgres.Run(ctx, "postgres:16-alpine") if err != nil { log.("postgres: %v", err) } code := m.() _ = ctr.(context.Background()) os.(code) }

Соберите конвейер фикстуры пакета

Расставьте шаги: от внутреннего пакета до кода возврата.

Строки

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

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

Чек-лист выбора паттерна#

  • зависимость нужна всем тестам пакета — TestMain; только части — ленивый запуск через sync.Once
  • ошибку старта общего контейнера оформляйте внятно: log.Fatalf с причиной, а не тихий выход
  • три пакета с одинаковыми хелперами — сигнал вынести код в internal/testenv
  • тесты пишут в общую базу — снимок после миграций и Restore перед тестом
  • Restore разрывает соединения: рядом с t.Parallel держите схему или базу на тест
  • тяжёлый образ прогрейте заранее: docker pull до прогона, тесты ждут только старт
  • общий контейнер не отменяет уборку: Terminate в TestMain после m.Run обязателен
  • паттерн не заменяет wait: контейнер обязан быть готовым к моменту возврата хелпера

Карточки: паттерны

Термин

TestMain

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

Точка входа пакета тестов: подготовка окружения до m.Run, уборка после него, os.Exit с кодом прогона. Общий контейнер, поднятый здесь, делят все тесты пакета.

1 / 6

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

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

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

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

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

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

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

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