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

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

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

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

Тестируем pgx на живой базе

Модуль Postgres из четвёртого урока завёл живую базу в тесте — теперь подключим к ней настоящий клиент и получим pgx тесты в самом честном смысле. pgxpool.New вместо подделки интерфейса, реальные INSERT и SELECT, настоящая ошибка уникального индекса вместо выдуманной из мока — это и есть тесты базы данных go, которые ловят то, что юнит-прогон с заглушками пропускает. Соберём полный интеграционный тест репозитория: от postgres.Run и ConnectionString до Terminate, с миграциями, CRUD и проверкой 23505, а заодно сравним четыре стратегии изоляции данных. Если код ходит в базу, тесты обязаны ходить в базу — всё остальное компромисс.

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

У репозитория в Go обычно тонкий слой: методы принимают структуру, отдают структуру, а между ними — SQL. Пока этот слой прикрыт моком, тесты проверяют только вашу веру в базу: что INSERT вернёт nil, что SELECT отдаст те же поля, что ошибки не случится. Живой Postgres мыслит иначе — у него свои типы, ограничения, планировщик и каскады, и он ни за что не притворится моком. Тест репозитория на настоящей базе — это договор с движком: схема создаётся так же, как в проде, запросы проходят через настоящий парсер, ошибки возвращаются с настоящими кодами SQLSTATE. Платить придётся секундами: контейнер стартует дольше, чем создался мок, и прогон пакета требует Docker на машине. Но юнит-тест с заглушкой отвечает на вопрос «вызвал ли код метод», а интеграционный — «работает ли код вместе с Postgres». Для слоя хранилища второй вопрос и есть рабочий: именно он отделяет зелёный прогон от работающей функции. В этом уроке соберём такой тест целиком — от запуска контейнера до уборки после себя — и посмотрим, где он ловит то, что мок пропускает молча.

pgx тесты: каркас из postgres.Run и pgxpool.New#

Каркас любого pgx-теста собирается из четырёх шагов, и все они уже знакомы по уроку про модуль Postgres: postgres.Run поднимает контейнер и возвращает postgres.PostgresContainer, ConnectionString собирает DSN с реальным хостом и портом — их знает только Docker, — pgxpool.New открывает пул по этому DSN, pool.Ping подтверждает, что база отвечает. Импорт выглядит как `github.com/testcontainers/testcontainers-go/modules/postgres`, а пул берётся из пакета `pgxpool` библиотеки pgx: одна строка DSN — и нативный клиент работает без database/sql. Уборка живёт в t.Cleanup: пул закрывается первым, Terminate останавливает контейнер вторым — если погасить базу раньше, закрытие пула встретит мёртвые соединения и добавит в лог шум. Каркас оформляется хелпером с t.Helper: setupTestDB принимает testing.T и возвращает *pgxpool.Pool, и каждый тест пакета получает свежую базу одной строкой. Пул на тест — не расточительство: соединения живут в пределах Cleanup, и гонки за свободные коннекты между тестами не возникают; экономить пулы имеет смысл позже, когда появится reuse. Адрес контейнера меняется от запуска к запуску: порт выбирает Docker из свободных, поэтому DSN собирается вызовом ConnectionString, а не константой в конфиге.

Хелпер: контейнер, DSN, пул и уборка

GO
func setupTestDB(t *testing.T) *pgxpool.Pool {
    t.Helper()
    ctx := context.Background()

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

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

    pool, err := pgxpool.New(ctx, dsn)
    if err != nil {
        t.Fatalf("создание пула: %v", err)
    }
    if err := pool.Ping(ctx); err != nil {
        t.Fatalf("база не отвечает: %v", err)
    }

    t.Cleanup(func() {
        pool.Close()
        if err := pgContainer.Terminate(ctx); err != nil {
            t.Logf("останов postgres: %v", err)
        }
    })

    return pool
}

Хелпер объявляет t.Helper, чтобы трейсбеки падений указывали на строку теста, а не на setup. ConnectionString просит sslmode=disable: у тестового контейнера нет сертификатов, и параметр дописывается к DSN как аргумент. Ping — не украшение: он гарантирует, что о неживой базе мы узнаем до теста, а не внутри него. В Cleanup сначала закрывается пул, потом Terminate останавливает контейнер и убирает его вместе с томом.

Миграции в тесте: Exec или WithInitScripts#

Схему в тестовую базу можно доставить двумя способами. Первый — pool.Exec с простым CREATE TABLE прямо из хелпера: минимальная миграция на три строки, вся схема видна в самом тесте, ничего не прячется в файлах. Второй — WithInitScripts из модуля: он монтирует ваши SQL-файлы в /docker-entrypoint-initdb.d/, и официальный образ Postgres исполняет их при старте контейнера в порядке сортировки имён — если порядок критичен, у модуля есть WithOrderedInitScripts, который нумерует файлы сам. Практическое правило: если схему ведёт отдельный инструмент миграций и файлы уже лежат в репозитории — WithInitScripts прогонит именно их, и тест увидит ту же схему, что прод; файлы при этом исполняются образом до того, как пул подключится, — к первому Ping схема уже готова. Если тесту нужны две таблицы и индекс — Exec дешевле и прозрачнее: никакой синхронизации файлов, вся схема перед глазами, а вариация схемы под конкретный тест — правка одной строки, а не файла. Смешивать оба пути в одном пакете не нужно: потом никто не вспомнит, кто владеет какой таблицей.

Минимальная миграция через Exec

GO
func migrateTestSchema(ctx context.Context, pool *pgxpool.Pool) error {
    _, err := pool.Exec(ctx, `
        CREATE TABLE users (
            id    BIGSERIAL PRIMARY KEY,
            email TEXT NOT NULL,
            name  TEXT NOT NULL
        )
    `)
    if err != nil {
        return err
    }

    _, err = pool.Exec(ctx,
        "CREATE UNIQUE INDEX users_email_key ON users (email)",
    )

    return err
}

Exec возвращает CommandTag и ошибку; для DDL интересна только вторая. Уникальный индекс создаётся отдельной командой — так его поведение проще показать в тесте на дубликат: без него INSERT с тем же email тихо запишется второй раз. BIGSERIAL даёт тесту настоящие сгенерированные ключи вместо выдуманных единиц из мока.

CRUD на живой базе и ошибка 23505#

Полный CRUD-тест делает три вещи, которые мок не проверит никогда. INSERT с RETURNING id возвращает сгенерированный ключ — заглушка подставила бы единицу, база честно считает BIGSERIAL и отдаёт следующее значение. SELECT со Scan раскладывает колонки по переменным: несовпадение типов вскроется здесь, потому что Scan конвертирует настоящий вывод движка, а не структуру, которую вы же и заготовили. UPDATE сверяется через RowsAffected: ноль затронутых строк значит, что промахнулись по id, и тест обязан это видеть. Отдельный козырь живой базы — ошибки ограничений: второй INSERT с тем же email падает, и pgx возвращает *pgconn.PgError с полем Code. Проверка через errors.As — каноничный способ из курса pgx: код 23505 означает unique_violation, 23503 — foreign_key_violation, 23502 — not_null_violation. Таблицы SQLSTATE живут в документации Postgres, и они стабильнее любых сообщений: текст ошибки меняется между версиями, код — нет. Тот же тест с моком вернул бы nil на обоих INSERT — потому что и индекс, и дубликат существовали только в вашей голове. NULL-колонки сюда же: в поле без указателя Scan вернёт ошибку конвертации, и увидите вы её только на настоящем выводе движка.

Тест репозитория: вставить, прочитать, обновить

GO
func TestUserRepository_CRUD(t *testing.T) {
    ctx := context.Background()
    pool := setupTestDB(t)
    if err := migrateTestSchema(ctx, pool); err != nil {
        t.Fatalf("миграции: %v", err)
    }

    // INSERT: RETURNING отдаёт сгенерированный базой ключ
    var id int64
    err := pool.QueryRow(ctx,
        "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id",
        "anna@example.com", "Анна",
    ).Scan(&id)
    if err != nil {
        t.Fatalf("insert: %v", err)
    }

    // SELECT: Scan раскладывает колонки по переменным
    var name string
    if err := pool.QueryRow(ctx,
        "SELECT name FROM users WHERE id = $1", id,
    ).Scan(&name); err != nil || name != "Анна" {
        t.Fatalf("select: name=%q err=%v", name, err)
    }

    // UPDATE: RowsAffected подтверждает, что строка нашлась
    tag, err := pool.Exec(ctx,
        "UPDATE users SET name = $1 WHERE id = $2", "Анна П.", id,
    )
    if err != nil || tag.RowsAffected() != 1 {
        t.Fatalf("update: affected=%d err=%v", tag.RowsAffected(), err)
    }
}

QueryRow с RETURNING — идиома pgx для вставки: одна поездка в базу и ключ на руках. Scan у Row возвращает ошибку запроса или сканирования в одном err — разделять их не нужно. RowsAffected у CommandTag после Exec — единственная честная проверка UPDATE: запрос «прошёл» и без совпадений, и без этой проверки тест молчит о промахе.

Изоляция данных: четыре стратегии#

Как только pgx-тестов становится больше одного, вопрос изоляции данных решает, останутся ли тесты базы данных go воспроизводимыми: каждый прогон должен видеть базу в одном и том же состоянии, иначе тесты начнут падать по очереди — в зависимости от того, кто шёл первым. Это главный компромисс направления: скорость против независимости. Свежая база на тест — новый контейнер из postgres.Run: полная изоляция, но секунды на каждый тест. Отдельная схема на тест — CREATE SCHEMA test_xxx и SET search_path в строке подключения: один контейнер, у каждого теста своё пространство имён, дёшево и почти так же чисто. Транзакция с откатом — тест работает внутри Begin, а Cleanup вызывает Rollback: мгновенно, но только пока тест не делает DDL или собственный COMMIT. TRUNCATE между тестами — чистые таблицы одним вызовом, но легко забыть таблицу и получить данные соседа. У модуля есть и пятый путь: Snapshot после миграций и Restore перед тестом — база копируется из шаблона, механика спрятана в модуле. Стратегия выбирается на весь пакет, а не на отдельный тест: половина тестов на транзакциях, половина на TRUNCATE — гарантий нет ни у кого. Таблица ниже сводит варианты по скорости и надёжности.

Стратегии изоляции: цена и гарантии#

СтратегияКак работаетПлюс / минус
Свежая база на тестновый postgres.Run на каждый тест, уборка в Terminateмаксимальная изоляция; дороже всех по времени
Схема на тестCREATE SCHEMA плюс SET search_path в DSNодин контейнер, быстро; объекты уровня базы общие
Транзакция с откатомBegin до теста, Rollback в Cleanupсамая быстрая; ломается на DDL и внутреннем COMMIT
TRUNCATEочистка таблиц между тестаминаглядно; хрупко — забытая таблица протянет мусор
Snapshot и Restoreснапшот после миграций, Restore перед тестомсхема копируется мгновенно; живёт на механике модуля

Проверка: COMMIT внутри тестовой транзакции

Тест внутри стратегии «транзакция с откатом» выполняет собственный COMMIT, чтобы проверить каскады. Что произойдёт?

Проверка: распознать дубликат

Как надёжно отличить нарушение уникального индекса от любой другой ошибки pgx?

Почему моки ловят не всё: типы, планировщик, каскады#

Мок — это зеркало ваших ожиданий, и четыре класса багов он не отражает в принципе. Типы: pgx сканирует timestamptz, NUMERIC, UUID, массивы — несовпадение обнаружится только при живом Scan, потому что конвертацию выполняет драйвер против настоящего вывода движка. Планировщик: запрос, который на заглушке «возвращает список», на живой таблице может читать её целиком — seq scan увидит только Postgres, а тест с EXPLAIN увидит его вместе с базой. Ограничения: NOT NULL, CHECK, внешние ключи и уникальные индексы существуют только в схеме — мок о них не знает, а база расскажет кодом ошибки. Каскады: ON DELETE CASCADE и триггеры сработают лишь в реальном движке, и тест, удаляющий родителя, проверит, что дети уехали сами. Добавим пятый класс — парсинг SQL: мок принимает любую строку, а база разбирает её синтаксисом; опечатка в имени колонки на заглушке зелёная, на живой базе — честная ошибка undefined column. Юнит-слой с заглушками оставьте для бизнес-правил: там он быстр и уместен. Но граница — ровно интерфейс хранилища: за ним начинается база, и тестировать её нужно настоящей.

Чек-лист живого теста репозитория#

  • postgres.Run и пул — в хелпере с t.Helper: каркас не дублируется по тестам
  • ConnectionString собирает DSN: хост и порт знает только контейнер, руками их не собирать
  • pool.Ping сразу после pgxpool.New: неживая база должна упасть до теста, а не в нём
  • t.Cleanup закрывает пул первым, Terminate останавливает контейнер вторым
  • 23505 ловится через errors.As и pgconn.PgError — не сравнением текстов
  • RowsAffected после UPDATE обязателен: без него тест молчит о промахе
  • одна стратегия изоляции на весь пакет: смешение схем, TRUNCATE и транзакций хуже неидеального выбора

Что напечатает обработчик ошибки

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

_, err := pool.Exec(ctx,
    "INSERT INTO users (email, name) VALUES ($1, $2)",
    "anna@example.com", "Пётр",
)
// anna@example.com уже в таблице, уникальный индекс на месте
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
    log.Print(pgErr.Code)
}

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

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

dsn, err := pgContainer.(ctx, "sslmode=disable") pool, err := pgxpool.(ctx, dsn) if err := pool.(ctx); err != nil { t.Fatal(err) } var pgErr *pgconn.PgError if errors.(err, &pgErr) && pgErr.Code == "23505" { t.Log("дубликат email") }

Расставьте шаги хелпера setupTestDB

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

Строки

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

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

Карточки: pgx на живой базе

Термин

postgres.Run

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

Запускает контейнер Postgres и возвращает PostgresContainer: по умолчанию пользователь и база postgres, команда с fsync=off ради скорости тестов.

1 / 6

Что дальше: база-контейнер на каждый пакет — цена входа, и её снижают reuse и параллельность: переиспользование контейнера между тестами разберёт шестнадцатый урок, а управление конкурентным прогоном — семнадцатый, там же — изоляция под t.Parallel. Фикстуры уровня пакета и TestMain красиво складываются в Testify-наборы из девятнадцатого урока. Если связка код — база — тесты переросла этот уровень, системная перспектива есть в продвинутом курсе go-advanced. Домашняя проверка на честность: уберите из миграции уникальный индекс и посмотрите, как поведёт себя тест на дубликат — второй INSERT запишется молча, и это лучший аргумент за живую базу, который вы увидите собственными глазами. А когда тестов станет много, вернитесь к таблице изоляции и выберите одну стратегию для пакета — она окупится уже на следующем прогоне. Порядок один и тот же: живая база, честные ошибки, уборка после себя — остальное детали.

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

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

Вывод

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

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

  • Каркас pgx-теста: postgres.Run, ConnectionString, pgxpool.New, pool.Ping; уборка в t.Cleanup — пул закрывается первым, Terminate вторым.
  • Миграции: pool.Exec с CREATE TABLE для минимальной схемы или WithInitScripts — SQL-файлы уезжают в /docker-entrypoint-initdb.d/ и исполняются при старте.
  • Реальный CRUD проверяет RETURNING, Scan и RowsAffected; дубликат ловится как pgconn.PgError с кодом 23505 через errors.As.
  • Изоляция: свежая база, схема на тест, транзакция с откатом, TRUNCATE — скорость против надёжности; reuse и параллельность — уроки 16-17.

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

Соберите setupTestDB с миграцией из двух таблиц — users и orders с внешним ключом на users — и напишите CRUD-тест: INSERT с RETURNING, SELECT со Scan, UPDATE с проверкой RowsAffected. Добавьте тест на дубликат email с errors.As и pgconn.PgError, затем прогоните второй тест под транзакцией с откатом и под TRUNCATE — сравните время прогона и зафиксируйте выбор стратегии в комментарии.

Личные заметкиПусто
Хранятся только в вашем браузере (Testcontainers · Тестируем pgx на живой базе)

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

Мой проект работает через database/sql, а не через нативный pgx. Подойдёт ли этот каркас?

Да: DSN от ConnectionString одинаков для обоих режимов, и sql.Open принимает тот же postgres-DSN. Отличие в слое запросов: database/sql сканирует типы строже, а pgxpool даёт нативные типы pgx. У модуля есть и WithSQLDriver — он пригодится, если механике снапшотов нужно ходить именно вашим драйвером. Каркас Run → ConnectionString → пул → Cleanup при этом не меняется.

Почему контейнер стартует быстро, а первый прогон занимает минуты?

Первая загрузка образа — это docker pull, дальше образ живёт в локальном кэше. Сам Postgres стартует за секунды, и модуль ускоряет его честным приёмом: контейнер запускается с fsync=off, потому что терять данные тестовой базе нечего. Ускорение повторных прогонов — reuse из шестнадцатого урока и параллельность из семнадцатого.

Где посмотреть полный список кодов вроде 23505?

Это SQLSTATE — каталог кодов ошибок в документации Postgres. Для тестов хранилища хватает горячей пятёрки: 23505 — unique_violation, 23503 — foreign_key_violation, 23502 — not_null_violation. Работать с ними в pgx удобнее всего через pgconn.PgError и errors.As — как в курсе pgx, где связке посвящён отдельный разбор ошибок.

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

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

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

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

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

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

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