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

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

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

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

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

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

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

Проект — сервис коротких ссылок: принимает длинный 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, но для крупной схемы явный шаг миграций нагляднее: ошибка SQL падает с номером миграции, а не похоронена в логах инициализации образа. К первому тесту схема обязана существовать — тест, создающий свои таблицы на лету, размазывает схему по пакету. Готовность проверяем в два слоя: BasicWaitStrategies дождалась живого процесса, а прикладную готовность даёт ForSQL из урока об ожиданиях — 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 не оставит порченых данных следующему тесту.

Кэш-слой на 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 контейнеры-зомби съедают память и порты соседних джоб, а механика уборщика и его границ — тема восемнадцатого урока.

Путь «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 и снимок состояния.

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)

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

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

Строки

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

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

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

  • урок 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. Исполняется один раз; падение фикстур стопает весь пакет с ясной ошибкой, а не сотней красных тестов.

1 / 6

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

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

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

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

Почему один стенд на пакет, а не контейнер на каждый тест?

Скорость и управляемость: 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 Base25 уроков~12 чновичок25 уроков уже доступны

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

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

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