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

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

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

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

Знакомство: реальные зависимости в тестах

Интеграционные тесты Go — это тесты против настоящих зависимостей: живой базы, кэша, брокера сообщений, а не подделок в памяти. Тест с моками зелёный на ноутбуке и падает в проде на первой же особенности диалекта; тест с настоящим Postgres ловит это до мержа. Курс про [testcontainers](/courses/testcontainers/) — про то, как поднимать такие зависимости из кода теста: библиотека testcontainers go заводит контейнер прямо в тесте, дожидается готовности, отдаёт строку подключения и убирает за собой. В первом уроке — что пропускают моки, как устроен путь Docker API, контейнер, ожидание, подключение, и минимальный тест с postgres.Run, разобранный построчно. Единственное требование среды — локальный Docker: весь код курса исполняется через go test на вашей машине.

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

Пока программа маленькая, соблазн велик: покрыть всё юнит-тестами с моками, и отчёт о покрытии позеленеет. Мок — фотография зависимости: интерфейс совпадает, а поведения нет. Он отвечает так, как вы предположили при написании, а не так, как сервер отвечает в жизни. До поры до времени это спасает, но прод не консультируется с вашими предположениями: база применяет свою схему, Redis истекает ключи, брокер перемешивает сообщения. Классический сценарий: CI зелёный, релиз уехал, и через час дежурный читает стектрейс об ошибке синтаксиса, которую мок не заметил. Интеграционный слой существует ровно для этого зазора — проверить, что ваш код работает против настоящего сервиса, а не против вашего же представления о нём. Юнит-слой при этом не выкидываем: он быстрый и проверяет логику ветвлений, а интеграционный отвечает на вопрос, который мок не задавал, — понимает ли настоящий сервис то, что вы ему отправляете. Если вы ещё не писали тесты на Go, вернитесь к основам Go: от этого урока нужен только пакет testing и привычка запускать go test. Дальше — почему моки пропускают главное и чем закрывается эта дыра.

Интеграционные тесты Go: что пропускают моки#

Перечислим конкретно, что ускользает от мока — и что возвращается под контроль интеграционными тестами. Диалект SQL: мок-репозиторий принимает любую строку запроса, а настоящий Postgres разбирает её по-настоящему — ON CONFLICT, RETURNING, оконные функции и типы вроде jsonb либо работают, либо нет. Миграции: в мок-мире схема живёт в воображении, в реальном — это сотни применённых миграций, и колонка, добавленная в код без миграции, найдётся только против живой базы. TTL: мок-хранилище держит ключ вечно, а настоящий Redis истечёт через заданные секунды — тест на кэш без истечения проверяет выдумку. Порядок сообщений: брокер раскладывает сообщения по партициям по ключу, и консьюмер видит строгую последовательность; канал в памяти перемешивает и теряет их. Порты и протоколы: настоящий сервис ждёт TCP-подключение, рукопожатие, авторизацию, пул соединений — мок просто вызывает функцию. Каждый из пяти пунктов — классический источник багов, которые не ловит юнит-слой; их объединяет одно: проверяет их только настоящий сервис в настоящем окружении, и именно поэтому курс начинается с проблемы, а не с списка функций. Таблица ниже собирает пункты в одну карту.

Что мок проглотит, а реальный сервис поймает#

Ожидание кодаМокРеальная зависимость
ON CONFLICT, RETURNING — диалект SQLпринимает любую строкуошибка синтаксиса или драйвера прямо в тесте
схема из миграцийживёт в воображении кодарасхождение с базой ловится на прогоне
TTL ключа в Redisключ веченключ истёк через 60 секунд — и это правда
порядок сообщений брокераканал в памятипартиция и ключ дают реальный порядок
порт и протоколвызов функции в памятиTCP, рукопожатие, случайный порт на хосте
типы и NULLлюбые значения проходятсервер проверяет и честно режет

Идея: контейнер поднимается из кода теста#

Идея testcontainers проста и дерзка: тест сам становится оператором Docker. Внутри тестового тела вы просите библиотеку поднять контейнер из образа — например postgres:16-alpine, — и через несколько секунд получаете настоящую базу, привязанную к вашему прогону. Библиотека берёт на себя операторскую работу: создаёт контейнер через Docker API, дожидается готовности по условиям-стратегиям, отдаёт строку подключения и после теста убирает всё, что натворила. У testcontainers go один каркас на все сервисы: Run из модуля или generic-функция, ожидание готовности, строка подключения, уборка. Контейнер — собственность конкретного теста: своё состояние, свой случайный порт, своя схема; параллельные пакеты не делят одну базу, а значит не подслушивают и не портят данные друг друга. Уборку страхует сборщик Ryuk: библиотека вешает на ресурсы ярлыки сессии, и осиротевшие контейнеры подчищаются даже после падения процесса — детали в восемнадцатом уроке, пока достаточно знать, что мусор не копится. Формулировка «из кода теста» — ключевая: не compose-файл перед прогоном, не общий стенд под присмотром дежурного, а обычная функция Go, вызванная внутри TestXxx. Такой тест переносится между машинами без оговорок: склонировали репозиторий, запустили go test — окружение собралось само, вплоть до версии базы.

Минимальный тест с настоящим Postgres

GO
func TestUserRepository(t *testing.T) {
	ctx := context.Background()

	pgc, err := postgres.Run(ctx,
		"postgres:16-alpine",
		postgres.BasicWaitStrategies(),
	)
	if err != nil {
		t.Fatalf("запуск postgres: %v", err)
	}

	t.Cleanup(func() {
		if err := testcontainers.TerminateContainer(pgc); err != nil {
			t.Logf("остановка контейнера: %v", err)
		}
	})

	connStr, err := pgc.ConnectionString(ctx, "sslmode=disable")
	if err != nil {
		t.Fatalf("строка подключения: %v", err)
	}

	// postgres://postgres:postgres@localhost:54321/postgres?sslmode=disable
	// строка подключения готова — подключаемся в следующем фрагменте
}

postgres.Run стартует контейнер и не возвращается, пока не пройдут wait-стратегии: тест получает базу, которая реально отвечает. t.Cleanup планирует уборку через TerminateContainer — она сработает и при падении теста. ConnectionString отдаёт готовую строку со случайным портом хоста: два таких теста в параллельных пакетах не столкнутся. Импорт модуля — github.com/testcontainers/testcontainers-go/modules/postgres. Образ скачивается один раз и кэшируется демоном — последующие прогоны стартуют заметно быстрее.

Построчный разбор: Run, Cleanup, ConnectionString#

Читаем построчно. postgres.Run(ctx, "postgres:16-alpine", postgres.BasicWaitStrategies()) — вход в модуль: первый аргумент — контекст, который ведёт сам запуск — скачивание образа, создание, старт и ожидание; после возврата Run контейнер живёт, пока его не уберут TerminateContainer или сборщик Ryuk. Второй аргумент — образ; дальше идут опции. Опция BasicWaitStrategies — пара условий готовности: стратегия ForLog ждёт строку «database system is ready to accept connections» дважды, потому что postgres при первичной инициализации перезапускает себя, а затем ForListeningPort убеждается, что порт реально слушается. Между стартом контейнера и возвратом Run прячется всё ожидание — к моменту возврата сервис готов. t.Cleanup планирует уборку: TerminateContainer(pgc) останавливает контейнер, удаляет его вместе с томами и возвращает ошибку, если уборка не удалась; cleanup исполняется даже при панике теста. ConnectionString собирает строку вида postgres://postgres:postgres@localhost:54321/postgres?sslmode=disable — пользователь, пароль и имя базы по умолчанию все три postgres, порт — случайный проброс на хосте, а дополнительные аргументы дописываются в query. Если собственные значения заданы опциями WithUsername, WithPassword и WithDatabase, они попадут в строку вместо дефолтных — библиотека читает фактические переменные окружения контейнера. Ошибку Run проверяют сразу: чаще всего это не скачавшийся образ или молчащий демон, и оба случая — про окружение, а не про код теста. Значение по умолчанию для образа живёт в RunContainer — там тот же postgres:16-alpine.

Подключение к живой базе из теста

GO
db, err := sql.Open("pgx", connStr)
if err != nil {
	t.Fatalf("открыть соединение: %v", err)
}
defer db.Close()

if err := db.PingContext(ctx); err != nil {
	t.Fatalf("база не ответила: %v", err)
}

if _, err := db.ExecContext(ctx,
	"CREATE TABLE users (id int primary key, email text not null)",
); err != nil {
	t.Fatalf("схема: %v", err)
}

// дальше — обычные SELECT и INSERT: они едут по TCP в контейнер

sql.Open открывает пул соединений к живой базе по строке из ConnectionString; драйвер — любой postgres-драйвер для database/sql, например pgx через свой stdlib-адаптер. PingContext подтверждает, что сервер отвечает по настоящему протоколу, а ExecContext создаёт таблицу: синтаксис, типы и NULL проверяет настоящий Postgres. Дальше в тесте идут обычные запросы — каждый уезжает по TCP в контейнер и обратно. Если PingContext не отвечает, хотя Run вернулся успешно, — перепроверьте опции запуска: обычно теряется опция готовности.

Архитектура: Docker API, ожидание, подключение#

Под капотом нет магии, только Docker API. Цепочка выглядит так: тест вызывает Run — библиотека через Docker API создаёт и стартует контейнер на вашем демоне, том самом, которому отвечает docker ps в терминале; после старта исполняются wait-стратегии — библиотека читает логи контейнера, опрашивает порты, может дёргать HTTP или SQL, пока сервис не станет живым; затем из контейнера берутся хост и проброшенный порт, и ConnectionString складывает из них строку; последний шаг — ваш код открывает соединение через database/sql или pgx. Порт — случайный свободный на хосте: два теста, запущенные параллельно, не дерутся за один. Стратегии комбинируются: ForAll требует выполнения всех условий, ForAny — любого; если ни одно не исполнилось за отведённый дедлайн, Run возвращает ошибку, и тест честно краснеет вместо тихого зависания. Модули вроде postgres прячут типовые стратегии за готовые опции; как писать свои условия — курс посвятит двенадцатому уроку. Заметьте симметрию с продом: тот же демон, тот же образ, тот же протокол подключения — различается лишь то, кто управляет жизненным циклом и где берётся порт. Поэтому тест, зелёный против контейнера, редко обманывает: среда воспроизводится по-настоящему, а не приблизительно.

Место в экосистеме: рядом с testify#

testcontainers не отменяет привычный арсенал тестировщика — он его достраивает. Ассерты, сравнения и фикстуры остаются в testify: require и assert проверяют результаты, suite собирает общие подготовки, и в девятнадцатом уроке курса мы поднимем контейнеры один раз на весь пакет через TestMain. Деление труда простое: testify отвечает за утверждения, testcontainers — за окружение, и путать роли не нужно — от подмены зависимостей ассерты умнее не станут, зато под них подкладывается настоящая среда. Рядом живут и драйверы: database/sql из стандартной библиотеки или pgx как отдельный клиент — оба подключаются к строке, которую отдал контейнер. Сам testcontainers занимает нишу инфраструктуры тестов: он не заменяет моки в чисто юнит-тестах — там они уместны, — но закрывает слой настоящих сервисов: базы, кэши, брокеры, внешние API. Практический ориентир «куда что положить»: если проверка завязана на правила внешнего сервиса — она интеграционная; если на ветвления вашей функции — юнит-тест с моком уместен и быстрее. Граница между слоями — не догма, а инструмент: держите оба, но не пытайтесь одним закрыть всё.

Что библиотека делает за вас#

  • находит Docker и создаёт контейнер из образа прямо из кода теста
  • дожидается готовности по wait-стратегии: логи, порт, HTTP или SQL
  • отдаёт строку подключения со случайным портом на хосте
  • убирает контейнер и тома после теста — даже если тест упал
  • подчищает осиротевшие ресурсы сборщиком Ryuk по ярлыкам сессии
  • даёт модули-обёртки: postgres, redis, kafka и ещё десятки сервисов

Проверка: что происходит внутри Run

postgres.Run вернулся без ошибки. Что успело произойти между стартом контейнера и возвратом?

Проверка: откуда берётся порт

Какой порт окажется в строке подключения от ConnectionString?

Что вернут два вызова подряд

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

// тот же контейнер, два вызова подряд
s1, _ := pgc.ConnectionString(ctx, "sslmode=disable")
s2, _ := pgc.ConnectionString(ctx, "sslmode=disable")

fmt.Println(s1 == s2)

Соберите каркас теста

Восстановите три ключевых вызова: запуск контейнера модуля, уборку и строку подключения.

pgc, err := postgres.(ctx, "postgres:16-alpine") if err != nil { t.Fatalf("запуск: %v", err) } t.Cleanup(func() { if err := testcontainers.(pgc); err != nil { t.Logf("остановка: %v", err) } }) connStr, err := pgc.(ctx, "sslmode=disable")

Расставьте шаги интеграционного теста

Соберите по порядку: контекст, запуск контейнера, уборка, строка подключения, соединение и пинг.

Строки

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

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

Карточки: знакомство с testcontainers

Термин

postgres.Run

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

Функция модуля: Run(ctx, образ, опции...) стартует контейнер Postgres и не возвращается, пока не пройдут wait-стратегии. По умолчанию пользователь, пароль и база — все три postgres.

1 / 5

Что дальше: второй урок — установка и первый контейнер через generic-функцию без модулей; четвёртый — модуль Postgres целиком: свои базы, пользователи и init-скрипты; двенадцатый — wait-стратегии и собственные условия готовности. Практика — только локально: нужны Docker и go test, в браузерной песочнице тесты с контейнерами не воспроизвести, зато каждый фрагмент кода курса разобран построчно, как в этом уроке. Прямо сейчас проверьте окружение: docker ps в терминале должен ответить таблицей контейнеров, а не ошибкой соединения — после этого весь курс открыт. Если демон не запущен, начните с его старта: тесты без него не поедут, а с ним поедут ровно так, как было показано выше.

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

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • Моки проверяют только интерфейс: диалект SQL, миграции, TTL ключей и порядок сообщений остаются за кадром — их ловят интеграционные тесты против настоящих сервисов.
  • testcontainers поднимает контейнер из кода теста: postgres.Run стартует образ и не возвращается, пока wait-стратегии не подтвердят готовность — по логам и порту.
  • ConnectionString отдаёт строку postgres:// со случайным портом хоста, а t.Cleanup с TerminateContainer убирает контейнер и тома даже после падения теста, а осиротевшие ресурсы страхует Ryuk.
  • testcontainers дополняет testify, а не заменяет: testify ассертит результаты, testcontainers подкладывает под них живое окружение — Docker обязателен.

Домашнее задание

Запустите тест урока локально и убедитесь, что он зелёный, затем добавьте в него INSERT и SELECT по созданной таблице users. Уберите postgres.BasicWaitStrategies() из вызова Run и сравните поведение на холодной базе — после эксперимента верните опцию. Финал: прогоните тест с go test -count=2 и проверьте, что второй прогон получает свежий контейнер, а не хвосты первого. Заодно убедитесь, что docker ps -a не оставил после прогона осиротевших контейнеров.

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

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

Нужен ли Docker на CI?

Да: тесты говорят с Docker API, поэтому в GitHub Actions достаточно стандартного воркера с Docker, а локально — запущенного демона. Если Docker недоступен, postgres.Run вернёт ошибку при старте, и тест упадёт красным — это честный сигнал про окружение, а не флейк. Чинить код в этом случае бесполезно: сначала демон, потом прогон. Отдельно следите за правами: на Linux-раннерах с rootless-режимом иногда требуется включить privileged-контейнеры для сборщика мусора.

Не слишком ли медленны интеграционные тесты?

Первый прогон платит за скачивание образа, дальше контейнер стартует за секунды: модуль postgres запускает сервер с fsync=off, что делает диск быстрым. Порты случайные — тесты бегут параллельно без конфликтов. Отладка расхождения схемы в проде стоит дороже секунд на прогоне, а ночные недоумения из-за мока — дороже всего: платим скоростью, получаем честные ответы сервера.

Почему нельзя просто держать одну общую тестовую базу?

Общая база течёт: тесты читают чужие данные, дерутся за схему и ломают друг друга при параллельном прогоне. Отдельный контейнер на тест даёт чистую изоляцию: своё состояние, свой порт, своя уборка. Если старт всё же покажется дорогим, переиспользование контейнеров разберём в шестнадцатом уроке — но начинайте честно: контейнер на тест.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Go Base25 уроков~12 чновичок25 уроков уже доступны

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

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

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