Содержание урока · 10 разделов
- Архитектура стенда: TestMain и setupTestEnv
- Схема, миграции и готовность: ConnectionString и Ping
- Слой репозитория: создание, редирект и 404
- Кэш-слой на Redis: хит, мисс и инвалидация
- Компонент проекта и урок, где он разобран
- Run-чекер перед мержем и отладка стенда
- Что дальше учить
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Проект — сервис коротких ссылок: принимает длинный URL, возвращает код вроде aB3xK9, по GET переадресует на оригинал, а по неизвестному коду отвечает 404. Под ним два слоя, которые и будем тестировать: репозиторий на pgx против Postgres и кэш на Redis, держащий горячие коды. Хендлеры сервиса тонкие: парсинг запроса, вызов слоя, статус-код; вся интересная логика — в репозитории и кэше, поэтому интеграционные тесты стенда нацелены на слои, а HTTP-обвязку оставляют юнит-тестам. Интеграционный стенд go-пакета собирается один раз: в TestMain модули поднимают контейнеры, хелпер setupTestEnv достраивает окружение — DSN, готовность, миграции, клиенты, — и дальше весь пакет живёт на одних зависимостях, а данные откатываются снимками. Такой каркас дешевле контейнера на каждый тест и достаточно изолирован для параллельных пакетов: у каждого своё окружение, свои маппированные порты, свои снимки. Правила стенда просты: зависимости настоящие, состояние управляемое, уборка гарантированная. Разложим эти обязанности по коду слоями.
Архитектура стенда: TestMain и setupTestEnv#
Фикстуры пакета живут в двух точках входа, и у каждой своя роль. TestMain исполняется один раз на пакет: он поднимает Postgres и Redis из модулей, регистрирует уборку и запускает все тесты командой m.Run. Хелпер setupTestEnv — уже ваша прикладная логика: он получает DSN, дожидается готовности базы, применяет миграции схемы, строит клиенты слоёв и фиксирует снимок чистого состояния. Разделение осмысленное: TestMain — про существование зависимостей, setupTestEnv — про их пригодность к вашим тестам. Переменные пакета pg, rdb, repo и cache заполняются именно здесь и дальше читаются тестами напрямую — это сознательный компромисс фикстур пакета, а не случайность. Цену такого решения вы уже знаете из девятнадцатого урока: один стенд на пакет дешевле и быстрее набора контейнеров на тест, а изоляцию данных обеспечивают не новые контейнеры, а снимки и чистка. Часть дизайна стенда — поведение при провале: если фикстура падает на старте, log.Fatalf останавливает пакет одной ясной ошибкой вместо сотни красных тестов. Порядок не перестановочен: сначала контейнеры, потом готовность, потом схема, и только затем снимок.
TestMain: обе зависимости до m.Run
GOvar (
pg *postgres.PostgresContainer
rdb *tcredis.RedisContainer
repo *store.Repository
cache *store.Cache
)
func TestMain(m *testing.M) {
ctx := context.Background()
p, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("links"),
postgres.WithUsername("app"),
postgres.WithPassword("secret"),
postgres.BasicWaitStrategies(),
)
if err != nil {
log.Fatalf("postgres: %v", err)
}
pg = p
defer testcontainers.TerminateContainer(pg)
r, err := tcredis.Run(ctx, "redis:7")
if err != nil {
log.Fatalf("redis: %v", err)
}
rdb = r
defer testcontainers.TerminateContainer(rdb)
if err := setupTestEnv(ctx); err != nil {
log.Fatalf("стенд: %v", err)
}
m.Run()
}TestMain поднимает обе зависимости до m.Run: postgres.Run со своей сборкой ожиданий и redis.Run, у которого wait-стратегии модуль ставит сам. TerminateContainer в defer убирает контейнеры, даже если пакет падает. setupTestEnv — хелпер пакета: получает DSN, ждёт готовность, применяет миграции и собирает клиенты слоёв. Глобальные переменные здесь сознательны: это фикстуры пакета, а не теста.
Схема, миграции и готовность: ConnectionString и Ping#
DSN стенда собирает ConnectionString: хост и маппированный порт Docker она подставляет сама, sslmode=disable задаётся явно — TLS в контейнере нет. Миграции идут следом: схема в одном schema.sql, исполняется одним Exec к чистой базе; альтернатива — init-скрипты модуля из урока про Postgres, но для крупной схемы явный шаг миграций нагляднее: ошибка SQL падает с номером миграции, а не похоронена в логах инициализации образа. К первому тесту схема обязана существовать — тест, создающий свои таблицы на лету, размазывает схему по пакету. Готовность проверяем в два слоя: BasicWaitStrategies дождалась живого процесса, а прикладную готовность даёт ForSQL из урока об ожиданиях — SELECT 1 по DSN через database/sql, что и есть Ping на уровне протокола. Контрольный pool.Ping после подключения подтверждает, что пул собран и база отвечает тому же DSN, по которому будут работать тесты. Нюанс: ForSQL исполняет запрос через database/sql, поэтому драйвер обязан быть зарегистрирован в программе — импортируйте stdlib-пакет pgx, если пул в тестах pgxpool, иначе стратегия упадёт с неизвестным драйвером, не дойдя до базы.
setupTestEnv: готовность, миграции, снимок
GOfunc setupTestEnv(ctx context.Context) error {
dsn, err := pg.ConnectionString(ctx, "sslmode=disable")
if err != nil {
return fmt.Errorf("connection string: %w", err)
}
// порт открыт — ещё не значит, что база готова: ждём SELECT 1 по DSN
strategy := wait.ForSQL("5432/tcp", "pgx", func(host string, port nat.Port) string {
return dsn
}).WithStartupTimeout(30 * time.Second)
if err := strategy.WaitUntilReady(ctx, pg); err != nil {
return fmt.Errorf("postgres не готов: %w", err)
}
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return fmt.Errorf("пул: %w", err)
}
if err := pool.Ping(ctx); err != nil {
return fmt.Errorf("ping: %w", err)
}
schema, err := os.ReadFile("testdata/schema.sql")
if err != nil {
return fmt.Errorf("schema.sql: %w", err)
}
if _, err := pool.Exec(ctx, string(schema)); err != nil {
return fmt.Errorf("миграции: %w", err)
}
repo = store.NewRepository(pool)
redisURL, err := rdb.ConnectionString(ctx)
if err != nil {
return fmt.Errorf("redis url: %w", err)
}
opts, err := redis.ParseURL(redisURL)
if err != nil {
return fmt.Errorf("redis parse: %w", err)
}
cache = store.NewCache(redis.NewClient(opts))
// чистое состояние после миграций уходит в шаблон для Restore
return pg.Snapshot(ctx)
}ConnectionString отдаёт DSN с маппированным портом, ForSQL по этому DSN выполняет SELECT 1, пока база не ответит, — с личным таймаутом в тридцать секунд. Миграции применяются одним Exec, и только после них Snapshot копирует чистое состояние в базу-шаблон. Клиенты слоёв строятся на тех же адресах, что прочитали тесты: расхождений между фикстурами и кодом не возникает.
Слой репозитория: создание, редирект и 404#
Три сценария репозитория закрывают контракт сервиса. Создание: Create сохраняет URL и возвращает ссылку с непустым кодом — сама уникальность кода проверяется ограничением базы, а не кодом теста. Редирект: URLByCode по коду возвращает тот же оригинал, что был сохранён. Наконец 404: по несуществующему коду репозиторий обязан отдать доменную ошибку ErrNotFound, а не сырую pgx.ErrNoRows, — перевод драйверной ошибки в доменную происходит на границе слоя, и тест фиксирует именно контракт. Тот же принцип на отрицательном пути создания: пустой или битый URL репозиторий отклоняет ошибкой валидации до обращения к базе — и это тоже assert, а не догадка. Между тестами данные откатывает пара Snapshot и Restore: снимок снят один раз в setupTestEnv после миграций, а Restore в t.Cleanup возвращает базу к шаблону — несколько SQL-команд вместо нового контейнера с миграциями. Отдельный тест на конфликт повторного кода вписывается в ту же схему: создали код, попытались создать снова, словили ErrNotFound — нет, ошибку уникальности, — и база всё равно откатилась к снимку. Механика снимков разобрана в четвёртом уроке, здесь она работает как элемент стенда.
Тест репозитория с откатом в Cleanup
GOfunc TestRepository_CreateRedirect_NotFound(t *testing.T) {
ctx := t.Context()
t.Cleanup(func() {
if err := pg.Restore(context.Background()); err != nil {
t.Logf("restore: %v", err)
}
})
link, err := repo.Create(ctx, "https://go.dev/doc/")
if err != nil {
t.Fatalf("create: %v", err)
}
if link.Code == "" {
t.Fatal("пустой код короткой ссылки")
}
got, err := repo.URLByCode(ctx, link.Code)
if err != nil {
t.Fatalf("redirect: %v", err)
}
if got != "https://go.dev/doc/" {
t.Fatalf("редирект на %q", got)
}
if _, err := repo.URLByCode(ctx, "no-such-code"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("ожидали 404, получили: %v", err)
}
}Тест накрывает три пункта контракта: непустой код после Create, честный редирект по нему и ErrNotFound по несуществующему. t.Context() живёт до конца теста, а внутри Cleanup уже отменён — Restore там получает свежий context.Background, как учил третий урок. Cleanup сработает при любом исходе: упавший assert не оставит порченых данных следующему тесту.
Ловушка, которая проявляется не сразу: Restore откатывает только Postgres, Redis остаётся при своих ключах. Группа тестов репозитория сохранила коды, кэш-слой положил их в Redis, Cleanup откатил базу — а горячие ключи пережили откат. Следующая группа идёт за кодом в кэш, находит значение, которого в базе больше нет, и тест зеленеет на данных-призраках либо краснеет непонятно почему. Лечение простое: FlushAll в том же t.Cleanup, что и Restore, — оба хранилища откатываются парой, и порядок групп перестаёт иметь значение. Альтернатива — держать группы раздельными: сначала чистый репозиторий, потом кэш, и чистить Redis на входе в каждую группу. Снимок — фикстура базы, а не всей машины: всё, что живёт вне Postgres, чистится руками.
Кэш-слой на Redis: хит, мисс и инвалидация#
Кэш тестируется тремя сценариями, и все они уже знакомы по уроку про Redis. Мисс — это не пустая строка, а сентинел-ошибка redis.Nil: Get по отсутствующему ключу возвращает её, и тест проверяет через errors.Is. Хит: Set с TTL, затем Get по тому же ключу возвращает то же значение — заодно проверяется, что сериализация переживает дорогу туда и обратно. Инвалидация: после удаления ключа повторный Get снова даёт redis.Nil — значит, редирект пойдёт в базу, а не в устаревший кэш. На настоящем сервере это проверяет больше, чем мок: префиксы ключей, TTL, политики вытеснения. TTL проверяется честно: ключ с коротким сроком жизни исчезает из кэша сам, без мока времени, — Redis выставляет его по-настоящему, и тест просто ждёт. Чистка — FlushAll в Cleanup каждого теста, а изоляцию параллельных тестов внутри группы дают префиксы от t.Name(): два теста с разными кодами не трогают ключи друг друга. Один способ на пакет и никакой самодеятельности — правило из шестого урока работает и в финальном проекте.
Компонент проекта и урок, где он разобран#
| Компонент стенда | Откуда приём |
|---|---|
| postgres.Run, WithInitScripts, BasicWaitStrategies | урок 04 — модуль Postgres: база и схема за один вызов |
| ConnectionString и маппированный порт | урок 04 — DSN без ручного подбора порта |
| Snapshot и Restore между группами тестов | урок 04 — откат данных вместо пересоздания контейнера |
| redis.Run, FlushAll, TTL и изоляция ключей | урок 06 — кэш-слой на настоящем Redis |
| wait-стратегии и ForSQL по DSN | урок 12 — готовность сервиса, а не открытый порт |
| TestMain и уборка пакета | урок 19 — фикстуры пакета и CleanupContainer |
| setupTestEnv и хелперы слоёв | урок 24 — структура тестового пакета |
Run-чекер перед мержем и отладка стенда#
Стенд стоит на готовых фикстурах, но смысл ему придаёт прогон, которому доверяют. Run-чекер перед каждым мержем — go test ./... -race -count=1: флаг -race включает детектор гонок и ловит небезопасную работу с общими клиентами и мапами в горутинах, а -count=1 отключает кэширование результатов тестов, так что зелёный вывод означает настоящий прогон, а не чтение кэша прошлой сборки. Локальный чекер повторяет то, что потом сделает CI из двадцатого урока, — и ловит проблемы до пуша, а не в чужой джобе. Разница между ноутбуком и раннером — скорость диска и сети, поэтому таймауты, подобранные впритык, проявляются именно там; прогон перед пушем помогает поймать их на своей машине. Когда прогон всё же падает, включается отладочный режим: TESTCONTAINERS_RYUK_DISABLED=true на время локальной сессии не даёт уборщику заведиться, контейнеры переживают конец тестов, и их можно осматривать docker logs, docker exec и docker ps. Плата — ручная зачистка после себя. Коммитить выключенную уборку нельзя: в CI контейнеры-зомби съедают память и порты соседних джоб, а механика уборщика и его границ — тема восемнадцатого урока.
Честная оговорка: в песочнице сайта этот проект не запустится — модули поднимают настоящие контейнеры через локальный Docker, а браузерная среда исполняет только тематические фрагменты без демона. Рабочая схема домашняя: склонируйте структуру пакета из урока, положите рядом go.mod с testcontainers-go, pgx и go-redis, запустите docker ps для проверки демона — и go test соберёт стенд на вашей машине. Первый прогон скачает образы Postgres и Redis, второй пойдёт заметно быстрее на прогретом кэше образов. Весь код разобран построчно, поэтому чтение без Docker тоже осмысленно.
Путь «testcontainers проект» пройден: от первого контейнера во втором уроке до стенда, который выдерживает параллельные пакеты и чужие машины. Что осталось за кадром — структурные приёмы наращивания: когда пакетов станет много, хелперы вроде setupTestEnv переедут в общий пакет testutil, а про то, как не превратить их в свалку, рассказано в уроке про паттерны. Там же — договорённости об именах фикстур и порядке групп, которые в большом репозитории экономят часы. Дальше — список тем на вырост, а фундамент у вас уже собран: зависимости настоящие, состояние управляемое, уборка гарантированная, прогон настоящий.
Проверка: выключенная уборка
Прогон упал с непонятной ошибкой, и вы хотите осмотреть контейнеры руками. Что сделает TESTCONTAINERS_RYUK_DISABLED=true, выставленный на время локальной отладки?
Проверка: что увидит следующий тест
Тест испортил справочные данные и завершился; Restore в t.Cleanup отработал без ошибок. Что увидит следующий тест?
Что упадёт при go test -count=2
Прочитайте код и выберите, что он выведет:
// Пакет кэш-тестов: TestCacheMiss, затем TestCacheHit.
// FlushAll между тестами забыли.
// TestCacheMiss: cache.Get(ctx, code) — ждём redis.Nil
// TestCacheHit: cache.Set(ctx, code, ttl), затем Get — ждём значениеСоберите setupTestEnv
Восстановите три опорных вызова: DSN, ожидание готовности по DSN и снимок состояния.
Соберите стенд по шагам
Расставьте шаги сборки интеграционного стенда в рабочем порядке.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Что дальше учить#
- урок 20 про CI: этот же стенд в GitHub Actions — без ручных прогонов перед пушем
- урок 22 про отладку: методичный разбор упавшего интеграционного теста по логам
- урок 23 про грабли: порты, скорость и флейки, уже собранные до вас
- курс pgx: пулы, транзакции и batch-запросы за пределами тестов
- курс go-redis: структуры данных, пайплайны и конфигурация клиента
- курс asynq: фоновые задачи сервиса на том же Redis
- сети и свои образы из уроков 14 и 15: стенды из нескольких контейнеров
- курс go-advanced: контексты, гонки и жизненные циклы процессов
Карточки: стенд целиком
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Стенд собирается один раз на пакет: TestMain поднимает Postgres и Redis из модулей, setupTestEnv достраивает DSN, готовность, миграции и клиенты слоёв.
- Готовность проверяется в два слоя: wait-стратегии модулей для процесса, ForSQL с SELECT 1 по DSN из ConnectionString — для прикладной готовности.
- Создание, редирект и 404 закрывают контракт репозитория; данные между группами откатывают Snapshot после миграций и Restore в Cleanup, а кэш чистит FlushAll рядом.
- Run-чекер go test -race -count=1 делает зелёный прогон доказательством: гонки пойманы, кэш результатов отключён.
- TESTCONTAINERS_RYUK_DISABLED оставляет контейнеры для локальной отладки — временно, с ручной зачисткой после себя.
Домашнее задание
Соберите стенд целиком: TestMain с postgres.Run и redis.Run, setupTestEnv с ConnectionString, ForSQL по DSN, миграциями и Snapshot, тесты репозитория на создание, редирект и 404 с Restore в t.Cleanup, кэш-тесты с FlushAll. Прогоните go test -race -count=1 дважды подряд, затем уроните один тест и осмотрите контейнер через docker logs с TESTCONTAINERS_RYUK_DISABLED=true, не забыв зачистить после себя.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему один стенд на пакет, а не контейнер на каждый тест?
Скорость и управляемость: Postgres и Redis стартуют секунды, а с миграциями — десятки, и повторять это на каждый тест незачем. Изоляцию дают не новые контейнеры, а откат данных: Snapshot и Restore возвращают базу к снимку за несколько SQL-команд, FlushAll чистит Redis. Контейнер на тест оправдан, когда тесту нужна уникальная конфигурация сервера — лимиты, локаль, версия образа; для контрактных тестов слоёв общий стенд честнее и в разы быстрее. Первая сборка на холодной машине — исключение: образы ещё не в локальном кэше Docker, и скачание занимает минуты, но это разовая цена, а не цена каждого прогона.
Зачем в run-чекере именно -count=1?
go test кэширует результаты: если пакет и зависимости не менялись, тесты не исполняются, а печатается прошлый вывод — зелёный, но не настоящий. -count=1 отключает этот кэш и заставляет каждый тест исполниться заново, что и проверяет идемпотентность фикстур: стенд с забытой чисткой это немедленно покажет. Вместе с -race прогон ловит и гонки в горутинах сервиса, и мусор, оставленный прошлым прогоном.
Стенд зелёный локально, но красный у коллеги. С чего начать разбор?
С сравнения окружения: версия Docker, свободные порты, прогрет ли кэш образов. Затем включите отладочный прогон с TESTCONTAINERS_RYUK_DISABLED=true, упавший контейнер оставит логи — docker logs покажет, что говорил сервис, а логи теста с t.Logf — что делал тест. Частые виновники — долгий первый старт и таймауты wait-стратегий, у ForSQL таймаут поднимается WithStartupTimeout. Систематический разбор упавших — тема двадцать второго урока, а флейки и порты собраны в двадцать третьем.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Go с нуля: базовый синтаксис и конструкции
Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.
- Писать, компилировать и запускать программы на Go
- Свободно работать с переменными, типами, операторами
- Управлять потоком выполнения: if, switch, for, range
- Хранить данные в массивах, срезах и картах