---
title: "Знакомство: реальные зависимости в тестах"
description: "Интеграционные тесты Go с testcontainers: чего не видят моки, как контейнер поднимается из кода теста и отдаёт строку подключения — первый тест с postgres.Run."
course: "Testcontainers в Go: интеграционные тесты с Docker"
url: "https://golangmaster.ru/courses/testcontainers/lessons/01-znakomstvo-testcontainers/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

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

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

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

Пока программа маленькая, соблазн велик: покрыть всё юнит-тестами с моками, и отчёт о покрытии позеленеет. Мок — фотография зависимости: интерфейс совпадает, а поведения нет. Он отвечает так, как вы предположили при написании, а не так, как сервер отвечает в жизни. До поры до времени это спасает, но прод не консультируется с вашими предположениями: база применяет свою схему, Redis истекает ключи, брокер перемешивает сообщения. Классический сценарий: CI зелёный, релиз уехал, и через час дежурный читает стектрейс об ошибке синтаксиса, которую мок не заметил. Интеграционный слой существует ровно для этого зазора — проверить, что ваш код работает против настоящего сервиса, а не против вашего же представления о нём. Юнит-слой при этом не выкидываем: он быстрый и проверяет логику ветвлений, а интеграционный отвечает на вопрос, который мок не задавал, — понимает ли настоящий сервис то, что вы ему отправляете. Если вы ещё не писали тесты на Go, вернитесь к [основам Go](/courses/go-basics/): от этого урока нужен только пакет 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 прячут типовые стратегии за готовые опции; как писать свои условия — курс посвятит двенадцатому уроку. Заметьте симметрию с продом: тот же демон, тот же образ, тот же протокол подключения — различается лишь то, кто управляет жизненным циклом и где берётся порт. Поэтому тест, зелёный против контейнера, редко обманывает: среда воспроизводится по-настоящему, а не приблизительно.

> **Живое место: апсёрт, который мок одобрил.** Тест на апсёрт пользователя был зелёным полгода — мок-репозиторий принимал любые запросы. На ревью я заменил мок на настоящий Postgres из testcontainers, и тест покраснел за четыре секунды: в запросе был INSERT с ON CONFLICT без указания колонки-конфликта, что Postgres запрещает. Мок знал только сигнатуру метода, база знает правила. Исправление заняло одну строку, а прод избежал 500 на первом же обновлении профиля. Правило простое: чем ближе тест к реальному диалекту, тем раньше ловится ошибка — и тем спокойнее ночной сон дежурного. С тех пор интеграционный тест на запись идёт в репозиторий вместе с кодом — как страховка от повторения.

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

testcontainers не отменяет привычный арсенал тестировщика — он его достраивает. Ассерты, сравнения и фикстуры остаются в [testify](/courses/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 вернулся без ошибки. Что успело произойти между стартом контейнера и возвратом?

- Ничего: контейнер стартовал, и Run вернулся сразу
- Библиотека исполнила миграции вашей схемы
- ✅ Прошли wait-стратегии: строка готовности в логах и слушающий порт
- Библиотека выждала фиксированные 30 секунд на всякий случай

После старта контейнера Run исполняет wait-стратегии из опций и возвращается, только когда они прошли: ForLog ловит строку готовности дважды — postgres перезапускает себя после инициализации, — а ForListeningPort убеждается, что порт слушается. Миграции testcontainers не делает: схема — забота вашего приложения или init-скриптов.

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

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

- 5432 — порт изнутри контейнера
- ✅ Случайный свободный порт на хосте, проброшенный в контейнер
- Фиксированный 15432, зарезервированный библиотекой
- Порт из переменной окружения DOCKER_PORT

ConnectionString спрашивает у контейнера проброс порта: Docker берёт 5432/tcp изнутри и отдаёт случайный свободный порт на хосте — поэтому два теста с базами не дерутся за один порт. Из хоста, порта и учётки по умолчанию функция собирает postgres://user:pass@host:port/db.

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

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

fmt.Println(s1 == s2)
```

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

- ✅ true — порт закреплён за контейнером, строки совпадут
- false — каждый вызов отдаст новый случайный порт
- Паника: ConnectionString нельзя вызывать дважды
- false — второй вызов вернёт пустую строку

Проброс порта выдаётся контейнеру при создании и живёт, пока жив контейнер: повторный вызов вернёт тот же хост и порт, пользователь, пароль и база не меняются — строки совпадут. Новую строку с новым портом получит только новый контейнер, например во втором тесте.

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

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

```go
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")
```

Подсказка: Запуск модуля — Run, уборка — TerminateContainer, строка подключения — ConnectionString.

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

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

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

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

1. `ctx := context.Background()`
2. `pgc, err := postgres.Run(ctx, "postgres:16-alpine")`
3. `if err != nil { t.Fatalf("запуск: %v", err) }`
4. `t.Cleanup(func() { testcontainers.TerminateContainer(pgc) })`
5. `connStr, err := pgc.ConnectionString(ctx, "sslmode=disable")`
6. `db, err := sql.Open("pgx", connStr)`
7. `if err := db.PingContext(ctx); err != nil { t.Fatal(err) }`
8. `// тест идёт против настоящей базы`

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

- **postgres.Run** — Функция модуля: Run(ctx, образ, опции...) стартует контейнер Postgres и не возвращается, пока не пройдут wait-стратегии. По умолчанию пользователь, пароль и база — все три postgres.
- **ConnectionString** — Метод контейнера: собирает postgres://user:pass@host:port/db. Порт — случайный проброс Docker, дополнительные аргументы дописываются в query, например sslmode=disable.
- **wait-стратегия** — Условие готовности сервиса: ForLog — строка в логах, ForListeningPort — порт слушается, плюс HTTP и SQL варианты. Run ждёт их прохождения и возвращает ошибку по дедлайну.
- **TerminateContainer** — Остановка и удаление контейнера вместе с томами — в t.Cleanup или defer. Срабатывает даже при падении теста; ошибки уборки возвращаются вызывающему.
- **Мок против контейнера** — Мок повторяет интерфейс, но не поведение: диалект SQL, миграции, TTL, порядок сообщений и протокол остаются непроверенными. Контейнер проверяет всё это честно.

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

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

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

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

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