---
title: "Итоговый проект: интеграционный стенд"
description: "Итоговый проект testcontainers: интеграционный стенд сервиса коротких ссылок — Postgres и Redis, миграции, Snapshot и Restore, run-чекер перед мержем."
course: "Testcontainers в Go: интеграционные тесты с Docker"
url: "https://golangmaster.ru/courses/testcontainers/lessons/25-itogovyj-proekt/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 24
---

# Итоговый проект: интеграционный стенд

*Курс «Testcontainers в Go: интеграционные тесты с Docker», урок 25 из 25. ~24 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/testcontainers/lessons/25-itogovyj-proekt/*

Финальный сборочный урок: из приёмов всего курса складывается один интеграционный стенд go-сервиса коротких ссылок — с настоящим Postgres, настоящим Redis и прогоном, которому можно верить. Сценарий «testcontainers проект» здесь закрывается целиком: TestMain поднимает базу и кэш из модулей, миграции готовят схему, wait-проверка через ConnectionString и Ping пропускает тесты только к живым зависимостям, Snapshot и Restore откатывают данные между группами, а run-чекер с race-детектором стоит на пути каждого мержа. Интеграционный стенд go-сервиса — это не про количество кода, а про дисциплину фикстур: кто поднимает окружение, кто чистит данные, кто отвечает за уборку. Соберём пакет по шагам, свяжем каждый компонент с уроком курса и оставим список тем, куда двигаться дальше. Код рассчитан на локальный Docker — в песочнице он не запустится, и это честная оговорка, а не сюрприз.

Проект — сервис коротких ссылок: принимает длинный 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

```go
var (
	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](/courses/testcontainers/lessons/04-postgres-module/), но для крупной схемы явный шаг миграций нагляднее: ошибка SQL падает с номером миграции, а не похоронена в логах инициализации образа. К первому тесту схема обязана существовать — тест, создающий свои таблицы на лету, размазывает схему по пакету. Готовность проверяем в два слоя: BasicWaitStrategies дождалась живого процесса, а прикладную готовность даёт ForSQL из [урока об ожиданиях](/courses/testcontainers/lessons/12-ozhidaniya-wait/) — SELECT 1 по DSN через database/sql, что и есть Ping на уровне протокола. Контрольный pool.Ping после подключения подтверждает, что пул собран и база отвечает тому же DSN, по которому будут работать тесты. Нюанс: ForSQL исполняет запрос через database/sql, поэтому драйвер обязан быть зарегистрирован в программе — импортируйте stdlib-пакет pgx, если пул в тестах pgxpool, иначе стратегия упадёт с неизвестным драйвером, не дойдя до базы.

### setupTestEnv: готовность, миграции, снимок

```go
func 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

```go
func 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 откатил базу, но не кэш.** Ловушка, которая проявляется не сразу: Restore откатывает только Postgres, Redis остаётся при своих ключах. Группа тестов репозитория сохранила коды, кэш-слой положил их в Redis, Cleanup откатил базу — а горячие ключи пережили откат. Следующая группа идёт за кодом в кэш, находит значение, которого в базе больше нет, и тест зеленеет на данных-призраках либо краснеет непонятно почему. Лечение простое: FlushAll в том же t.Cleanup, что и Restore, — оба хранилища откатываются парой, и порядок групп перестаёт иметь значение. Альтернатива — держать группы раздельными: сначала чистый репозиторий, потом кэш, и чистить Redis на входе в каждую группу. Снимок — фикстура базы, а не всей машины: всё, что живёт вне Postgres, чистится руками.

### Кэш-слой на Redis: хит, мисс и инвалидация

Кэш тестируется тремя сценариями, и все они уже знакомы по [уроку про Redis](/courses/testcontainers/lessons/06-redis-module/). Мисс — это не пустая строка, а сентинел-ошибка 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.** Честная оговорка: в песочнице сайта этот проект не запустится — модули поднимают настоящие контейнеры через локальный Docker, а браузерная среда исполняет только тематические фрагменты без демона. Рабочая схема домашняя: склонируйте структуру пакета из урока, положите рядом go.mod с testcontainers-go, pgx и go-redis, запустите docker ps для проверки демона — и go test соберёт стенд на вашей машине. Первый прогон скачает образы Postgres и Redis, второй пойдёт заметно быстрее на прогретом кэше образов. Весь код разобран построчно, поэтому чтение без Docker тоже осмысленно.

Путь «testcontainers проект» пройден: от первого контейнера во втором уроке до стенда, который выдерживает параллельные пакеты и чужие машины. Что осталось за кадром — структурные приёмы наращивания: когда пакетов станет много, хелперы вроде setupTestEnv переедут в общий пакет testutil, а про то, как не превратить их в свалку, рассказано в [уроке про паттерны](/courses/testcontainers/lessons/24-patterny/). Там же — договорённости об именах фикстур и порядке групп, которые в большом репозитории экономят часы. Дальше — список тем на вырост, а фундамент у вас уже собран: зависимости настоящие, состояние управляемое, уборка гарантированная, прогон настоящий.

### Проверка: выключенная уборка

**Вопрос:** Прогон упал с непонятной ошибкой, и вы хотите осмотреть контейнеры руками. Что сделает TESTCONTAINERS_RYUK_DISABLED=true, выставленный на время локальной отладки?

- Ускорит уборку: контейнеры будут удаляться сразу после каждого теста
- ✅ Уборщик-супервизор не заведётся, контейнеры переживут конец прогона — их можно осмотреть через docker logs и docker exec
- Отключит wait-стратегии: тесты перестанут ждать готовности зависимостей
- Зачистит контейнеры всех других сессий на этой машине

Переменная отключает только уборщика: контейнеры остаются жить после прогона и доступны для осмотра. Плата — ручная зачистка docker rm и prune по меткам, иначе зомби накопятся. Выключать уборку допустимо в локальной отладочной сессии; в CI это путь к исчерпанным портам и памяти у соседних джоб.

### Проверка: что увидит следующий тест

**Вопрос:** Тест испортил справочные данные и завершился; Restore в t.Cleanup отработал без ошибок. Что увидит следующий тест?

- ✅ Базу, пересозданную из шаблона: схема и справочники на месте, следов теста нет
- Пустую базу без схемы: Restore стирает всё содержимое
- Базу с изменениями теста: Restore только закрывает соединения
- Ошибку подключения: база-шаблон удаляется вместе с контейнером

Restore дропает рабочую базу и пересоздаёт её из шаблона, снятого Snapshot после миграций, — несколько SQL-команд вместо нового контейнера. Следующий тест стартует с той же точки, что и первый. Единственное, что откат не трогает, — Redis: кэш чистят отдельно, парой с Restore.

### Что упадёт при go test -count=2

```go
// Пакет кэш-тестов: TestCacheMiss, затем TestCacheHit.
// FlushAll между тестами забыли.
// TestCacheMiss: cache.Get(ctx, code) — ждём redis.Nil
// TestCacheHit: cache.Set(ctx, code, ttl), затем Get — ждём значение
```

**Что выведет программа?**

- Оба теста зелёные в обоих прогонах: состояние Redis на них не влияет
- ✅ Второй прогон падает в TestCacheMiss: ключ остался с первого прогона, мисс не случится
- Второй прогон падает в TestCacheHit: значение из кэша исчезло
- Тесты выполнятся в обратном порядке и оба пройдут

Повторный прогон живёт в том же процессе против того же Redis: ключ, записанный первым прогоном, встречает второй, и мисс больше не происходит — тест краснеет. FlushAll в Cleanup делает прогон идемпотентным. По той же причине -count=1 в run-чекере: отключение кэша результатов заставляет каждый тест действительно исполниться.

### Соберите setupTestEnv

Восстановите три опорных вызова: DSN, ожидание готовности по DSN и снимок состояния.

```go
dsn, err := pg.___(ctx, "sslmode=disable")

strategy := wait.___("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)
}

// ... пул, миграции, клиенты слоёв ...

// чистое состояние после миграций уходит в шаблон
return pg.___(ctx)
```

Подсказка: DSN отдаёт ConnectionString с маппированным портом, готовность ждёт ForSQL с SELECT 1 по DSN, а состояние после миграций уходит в Snapshot.

Правильные ответы в пропусках: `ConnectionString`, `ForSQL`, `Snapshot`

### Соберите стенд по шагам

Расставьте шаги сборки интеграционного стенда в рабочем порядке.

Правильный порядок строк:

1. `TestMain поднимает postgres.Run и redis.Run до m.Run`
2. `TerminateContainer в defer регистрирует уборку на весь пакет`
3. `ConnectionString отдаёт DSN с маппированным портом`
4. `ForSQL выполняет SELECT 1 по DSN, пока база не ответит`
5. `pool.Ping подтверждает готовность пулу и базе`
6. `миграции применяют schema.sql к чистой базе`
7. `Snapshot фиксирует состояние после миграций в шаблоне`
8. `тесты слоёв идут группами: репозиторий, затем кэш`
9. `Restore и FlushAll в Cleanup откатывают данные между группами`
10. `run-чекер go test -race -count=1 стоит на пути каждого мержа`

### Что дальше учить

- урок 20 про CI: этот же стенд в GitHub Actions — без ручных прогонов перед пушем
- урок 22 про отладку: методичный разбор упавшего интеграционного теста по логам
- урок 23 про грабли: порты, скорость и флейки, уже собранные до вас
- курс pgx: пулы, транзакции и batch-запросы за пределами тестов
- курс go-redis: структуры данных, пайплайны и конфигурация клиента
- курс asynq: фоновые задачи сервиса на том же Redis
- сети и свои образы из уроков 14 и 15: стенды из нескольких контейнеров
- курс go-advanced: контексты, гонки и жизненные циклы процессов

### Карточки: стенд целиком

- **TestMain** — Точка входа пакета: поднимает postgres.Run и redis.Run до m.Run, регистрирует TerminateContainer в defer. Исполняется один раз; падение фикстур стопает весь пакет с ясной ошибкой, а не сотней красных тестов.
- **setupTestEnv** — Прикладной хелпер пакета: DSN из ConnectionString, ожидание ForSQL по DSN, контрольный pool.Ping, миграции схемы, клиенты слоёв и Snapshot чистого состояния.
- **Snapshot и Restore** — Snapshot копирует базу в шаблон один раз после миграций, Restore дропает рабочую базу и пересоздаёт из шаблона — в t.Cleanup, со свежим context.Background. Откатывает только Postgres, Redis чистится отдельно.
- **redis.Nil** — Сентинел-ошибка go-redis для отсутствующего ключа: кэш-мисс проверяется через errors.Is, а не сравнением текста. После инвалидации ключа Get снова возвращает redis.Nil.
- **TESTCONTAINERS_RYUK_DISABLED** — Выключает уборщика на время сессии: контейнеры переживают прогон и доступны docker logs и exec для отладки. Только локально и только временно; в CI уборку не выключают.
- **go test -race -count=1** — Run-чекер перед мержем: -race включает детектор гонок, -count=1 отключает кэш результатов тестов и делает прогон настоящим. Совпадает с тем, что позже исполнит CI.

## Коротко: что запомнить

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