testcontainers¶
Деякі речі чесно не підробиш. Запит з віконною функцією, обмеження унікальності, рівень ізоляції транзакцій — єдиний спосіб дізнатись, чи вони працюють — це запустити їх проти справжньої бази даних. testcontainers запускає таку прямо з вашого тесту.
Модулі:
github.com/testcontainers/testcontainers-goта його модулі для окремих технологій.
Стаття про фейки та заглушки доводила, що тести самого сховища не можуть використовувати фейкове сховище. Ось що вони використовують натомість.
c, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("app"),
postgres.WithUsername("u"),
postgres.WithPassword("p"),
)
dsn, err := c.ConnectionString(ctx, "sslmode=disable")
Дочекайтесь готовності¶
Запущений контейнер — це ще не готова база даних. Без стратегії очікування ви підключаєтесь до порту, що відкритий, але ще не приймає запити, і отримуєте тест, що провалюється один раз із двадцяти:
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(60*time.Second),
)
WithOccurrence(2) — не забобон. Образ Postgres запускає сервер один
раз, щоб виконати скрипти ініціалізації, вимикає його й запускає
знову — тож перше входження цього рядка належить одноразовому
екземпляру.
Інші стратегії: wait.ForListeningPort, wait.ForHTTP("/health"),
wait.ForSQL. Надавайте перевагу тій, що доводить придатність
сервісу до використання, а не тій, що доводить лише відкритий порт.
Запускайте один раз на весь тестовий бінарник¶
Запуск контейнера домінує над усім іншим. Робіть це один раз, за
sync.Once:
var (
once sync.Once
baseDSN string
setupErr error
)
func sharedPostgres(t *testing.T) string {
t.Helper()
once.Do(func() {
c, err := postgres.Run(ctx, "postgres:16-alpine", /* ... */)
if err != nil {
setupErr = err
return
}
baseDSN, setupErr = c.ConnectionString(ctx, "sslmode=disable")
})
if setupErr != nil {
t.Fatalf("starting postgres: %v", setupErr)
}
return baseDSN
}
Різниця разюча:
--- PASS: TestInsertAndRead (1.52s) ← started the container
--- PASS: TestSecondUsesSameContainer (0.01s) ← reused it
Зауважте, помилка захоплюється в змінну, а не провалюється всередині
once.Do. Виклик t.Fatalf там перервав би перший тест, що тримає
блокування, і залишив би once позначеним завершеним, тож кожен
подальший тест мовчки отримав би порожній DSN.
Одна точка входу для тестів¶
Обгорніть це так, щоб тести казали один рядок:
func SetupTestDB(t *testing.T) *sql.DB {
t.Helper()
dsn := sharedPostgres(t)
db, err := sql.Open("pgx", dsn)
require.NoError(t, err)
t.Cleanup(func() { db.Close() })
// fresh schema for this test
_, err = db.Exec(`DROP TABLE IF EXISTS users;
CREATE TABLE users (id bigserial PRIMARY KEY, name text NOT NULL)`)
require.NoError(t, err)
return db
}
Ізоляція тестів один від одного важливіша за ізоляцію від контейнера. Варіанти, від найдешевшого: очистити (truncate) таблиці, яких торкнувся тест; запускати кожен тест у транзакції, що завжди відкочується; або клонувати базу даних з мігрованого шаблону.
Клон із шаблону — той варіант, що масштабується. Мігруйте один раз у шаблонну базу даних, потім на кожен тестовий бінарник:
Postgres копіює файли, тож це набагато швидше, ніж повторний запуск міграцій, і кожен бінарник отримує повну ізоляцію, включно з DDL. Між тестами всередині одного бінарника зазвичай достатньо очищення торкнутих таблиць.
Дайте CI перевикористати сервісний контейнер¶
CI часто вже надає базу даних, і запуск ще однієї всередині нього повільний чи неможливий. Спершу перевірте наявність зовнішньо наданого DSN і повертайтесь до testcontainers лише як до запасного варіанту:
func testDSN(t *testing.T) string {
if dsn := os.Getenv("TEST_DATABASE_DSN"); dsn != "" {
return dsn // CI service container
}
return sharedPostgres(t) // local Docker
}
Один хелпер, два середовища, без build-обмежень. Це часто й є різниця між тестами бази даних, що запускаються в CI, і тими, що тихо цього не роблять.
Запускайте свої справжні міграції проти контейнера, а не пишіть схему вручну — тоді тести також доводять, що міграції працюють.
Прибирання¶
testcontainers.CleanupContainer(t, c) прив'язує тривалість життя
контейнера до тесту. Для спільного контейнера сайдкар-"жнець" Ryuk
прибирає його, коли тестовий процес завершується, включно з ситуацією
після паніки чи SIGKILL.
Ryuk потребує доступу до сокета Docker. Під нестандартним рантаймом —
Colima, Podman, віддалений хост — це зазвичай означає встановлення
TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE чи DOCKER_HOST, інакше
контейнери протікають. Варто прописати це у вашому Makefile, а не
змушувати кожного розробника відкривати це заново.
Вмикайте під час виконання, ніколи через build-обмеження¶
Ці тести потребують Docker, тож деякі середовища не можуть їх
запускати. Спокуса — //go:build integration. Опирайтеся їй: якщо
ніхто не передає -tags integration, тести не запускаються ніде, і
при цьому виглядають присутніми в репозиторії весь час.
Пропускайте під час виконання, де пропуск видно у виводі:
Ще краще — інвертуйте це в CI: зробіть відсутність Docker там провалом, щоб неправильно налаштований пайплайн був гучним, а не тихо зеленим. Це той самий аргумент, що й у статті про збірку та кодогенерацію.
За межами Postgres¶
Існують модулі для Redis, Kafka, RabbitMQ, MongoDB, LocalStack і
багатьох інших — усі тієї самої форми. testcontainers.GenericContainer
запускає будь-який образ, тож у всього, що має контейнер, є тестовий
дублікат.
У що це обходиться¶
- Секунди, не мілісекунди. Тримайте ці тести окремо від швидких
юніт-тестів, щоб
go test ./...на пакеті лишався швидким. - Docker має бути доступний, включно з CI.
- Образи треба завантажити, що повільно вперше й потребує мережі.
Фіксуйте теги —
postgres:16-alpine, ніколиlatest— щоб версія не змінилась під вами. - Паралелізм потребує обдумування. Тести, що ділять один контейнер, не ізольовані за замовчуванням — саме для цього й потрібне скидання на кожен тест.
Використовуйте це для шару сховища й усього, що справді має форму інтеграційного тесту. Бізнес-логіку все ще варто тестувати проти інтерфейсів, за мілісекунди.
З досвіду Python: це
pytest-dockerчиtestcontainers-python, де життєвий цикл контейнера керується з тестового бінарника, а не з фікстури.sync.Onceвідіграє роль фікстури з областю дії сесії.
Швидка довідка¶
| Задача | Форма |
|---|---|
| запустити Postgres | postgres.Run(ctx, "postgres:16-alpine", opts...) |
| чекати придатності | wait.ForLog(...).WithOccurrence(2) |
| рядок підключення | c.ConnectionString(ctx, "sslmode=disable") |
| запустити один раз | sync.Once, захоплюючи помилку в змінну |
| точка входу на тест | хелпер SetupTestDB(t) з t.Cleanup |
| ізоляція | truncate, відкат, або шаблонна база даних |
| схема | запускайте справжні міграції |
| завершення | CleanupContainer, або Ryuk для спільного |
| нестандартний Docker | TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE |
| пропуск | перевірка змінної середовища під час виконання — ніколи build-обмеження |
| образи | зафіксовані теги, ніколи latest |