Содержание урока · 10 разделов
- Интеграционные тесты Go: что пропускают моки
- Что мок проглотит, а реальный сервис поймает
- Идея: контейнер поднимается из кода теста
- Построчный разбор: Run, Cleanup, ConnectionString
- Архитектура: Docker API, ожидание, подключение
- Место в экосистеме: рядом с testify
- Что библиотека делает за вас
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Пока программа маленькая, соблазн велик: покрыть всё юнит-тестами с моками, и отчёт о покрытии позеленеет. Мок — фотография зависимости: интерфейс совпадает, а поведения нет. Он отвечает так, как вы предположили при написании, а не так, как сервер отвечает в жизни. До поры до времени это спасает, но прод не консультируется с вашими предположениями: база применяет свою схему, 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
GOfunc 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.
Подключение к живой базе из теста
GOdb, 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 прячут типовые стратегии за готовые опции; как писать свои условия — курс посвятит двенадцатому уроку. Заметьте симметрию с продом: тот же демон, тот же образ, тот же протокол подключения — различается лишь то, кто управляет жизненным циклом и где берётся порт. Поэтому тест, зелёный против контейнера, редко обманывает: среда воспроизводится по-настоящему, а не приблизительно.
Тест на апсёрт пользователя был зелёным полгода — мок-репозиторий принимал любые запросы. На ревью я заменил мок на настоящий Postgres из testcontainers, и тест покраснел за четыре секунды: в запросе был INSERT с ON CONFLICT без указания колонки-конфликта, что Postgres запрещает. Мок знал только сигнатуру метода, база знает правила. Исправление заняло одну строку, а прод избежал 500 на первом же обновлении профиля. Правило простое: чем ближе тест к реальному диалекту, тем раньше ловится ошибка — и тем спокойнее ночной сон дежурного. С тех пор интеграционный тест на запись идёт в репозиторий вместе с кодом — как страховка от повторения.
Место в экосистеме: рядом с testify#
testcontainers не отменяет привычный арсенал тестировщика — он его достраивает. Ассерты, сравнения и фикстуры остаются в testify: require и assert проверяют результаты, suite собирает общие подготовки, и в девятнадцатом уроке курса мы поднимем контейнеры один раз на весь пакет через TestMain. Деление труда простое: testify отвечает за утверждения, testcontainers — за окружение, и путать роли не нужно — от подмены зависимостей ассерты умнее не станут, зато под них подкладывается настоящая среда. Рядом живут и драйверы: database/sql из стандартной библиотеки или pgx как отдельный клиент — оба подключаются к строке, которую отдал контейнер. Сам testcontainers занимает нишу инфраструктуры тестов: он не заменяет моки в чисто юнит-тестах — там они уместны, — но закрывает слой настоящих сервисов: базы, кэши, брокеры, внешние API. Практический ориентир «куда что положить»: если проверка завязана на правила внешнего сервиса — она интеграционная; если на ветвления вашей функции — юнит-тест с моком уместен и быстрее. Граница между слоями — не догма, а инструмент: держите оба, но не пытайтесь одним закрыть всё.
Что библиотека делает за вас#
- находит Docker и создаёт контейнер из образа прямо из кода теста
- дожидается готовности по wait-стратегии: логи, порт, HTTP или SQL
- отдаёт строку подключения со случайным портом на хосте
- убирает контейнер и тома после теста — даже если тест упал
- подчищает осиротевшие ресурсы сборщиком Ryuk по ярлыкам сессии
- даёт модули-обёртки: postgres, redis, kafka и ещё десятки сервисов
Честно: в браузерной песочнице тесты этого курса не запускаются — им нужен локальный Docker-демон и настоящий go test, а песочница исполняет только программы на чистом Go без внешнего мира. Поэтому код здесь не помечен как запускаемый и разобран построчно: читайте, потом гоняйте на своей машине. Проверка среды за минуту: docker run hello-world — если увидели приветствие, тесты урока поедут. А если Docker ещё не установлен, начните с него — инфраструктурные тесты живут там, где живёт Docker. Остальные уроки курса устроены так же: сначала построчный разбор, потом локальная практика на вашем Docker.
Проверка: что происходит внутри Run
postgres.Run вернулся без ошибки. Что успело произойти между стартом контейнера и возвратом?
Проверка: откуда берётся порт
Какой порт окажется в строке подключения от ConnectionString?
Что вернут два вызова подряд
Прочитайте код и выберите, что он выведет:
// тот же контейнер, два вызова подряд
s1, _ := pgc.ConnectionString(ctx, "sslmode=disable")
s2, _ := pgc.ConnectionString(ctx, "sslmode=disable")
fmt.Println(s1 == s2)Соберите каркас теста
Восстановите три ключевых вызова: запуск контейнера модуля, уборку и строку подключения.
Расставьте шаги интеграционного теста
Соберите по порядку: контекст, запуск контейнера, уборка, строка подключения, соединение и пинг.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: знакомство с testcontainers
Что дальше: второй урок — установка и первый контейнер через 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 не оставил после прогона осиротевших контейнеров.
Личные заметкиПусто
Вопросы и ответы по уроку
Нужен ли Docker на CI?
Да: тесты говорят с Docker API, поэтому в GitHub Actions достаточно стандартного воркера с Docker, а локально — запущенного демона. Если Docker недоступен, postgres.Run вернёт ошибку при старте, и тест упадёт красным — это честный сигнал про окружение, а не флейк. Чинить код в этом случае бесполезно: сначала демон, потом прогон. Отдельно следите за правами: на Linux-раннерах с rootless-режимом иногда требуется включить privileged-контейнеры для сборщика мусора.
Не слишком ли медленны интеграционные тесты?
Первый прогон платит за скачивание образа, дальше контейнер стартует за секунды: модуль postgres запускает сервер с fsync=off, что делает диск быстрым. Порты случайные — тесты бегут параллельно без конфликтов. Отладка расхождения схемы в проде стоит дороже секунд на прогоне, а ночные недоумения из-за мока — дороже всего: платим скоростью, получаем честные ответы сервера.
Почему нельзя просто держать одну общую тестовую базу?
Общая база течёт: тесты читают чужие данные, дерутся за схему и ломают друг друга при параллельном прогоне. Отдельный контейнер на тест даёт чистую изоляцию: своё состояние, свой порт, своя уборка. Если старт всё же покажется дорогим, переиспользование контейнеров разберём в шестнадцатом уроке — но начинайте честно: контейнер на тест.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Go с нуля: базовый синтаксис и конструкции
Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.
- Писать, компилировать и запускать программы на Go
- Свободно работать с переменными, типами, операторами
- Управлять потоком выполнения: if, switch, for, range
- Хранить данные в массивах, срезах и картах