pgtest

package
v1.3.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 10, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

Documentation

Overview

Package pgtest gives a test binary ONE Postgres and hands each test its own database cloned from a migrated template.

The subject

A test that needs a real database used to start a real container and replay the whole migration chain, per test. The cost is not the tests, it is the harness: a package calling such a helper 91 times pays 91 container starts and 91 chain replays before a single assertion runs. Two packages in this tree grew past the 600s that `go test` applies by default, which means the literal command `go test ./...` could not finish them at ALL — not slowly, not at all — however idle the machine.

This package is the generalisation of the fix already landed by hand in services/iam/internal/repo/kaname/pg, services/nlb/internal/repo/kacho/pg and services/vpc/internal/repo — three byte-for-byte copies of the same 150 lines. It is not a new invention; it is those three with the service-specific parts (the name, and how the schema is applied) lifted into Config.

Why a clone is still isolation

The container starts once, the schema is applied once into a TEMPLATE database, and each caller gets its own database created with `CREATE DATABASE … TEMPLATE t`. Postgres builds that by copying the template's files, so it is cheap while remaining a genuinely separate database: separate catalog, separate rows, separate sequences, separate advisory-lock space. Nothing crosses between two tests that did not cross between two containers. The proofs that depend on real contention — CAS, UNIQUE, EXCLUDE, FOR UPDATE SKIP LOCKED — are unchanged, because concurrent goroutines inside ONE test still contend on the same real rows of the same real database.

What DID change is worth stating rather than leaving to be discovered: two different tests can no longer collide, because they never could — they had separate containers before and have separate databases now. A test that passed only because it had the server to itself still has the database to itself. A test that depended on being alone with the SERVER — on `pg_stat_activity` showing nothing else, on a restart, on a cluster-wide setting — does not, and such a test must keep its own container and say why.

Why the container starts lazily

Starting it from TestMain would pay for a container in every run where every test skips — which is what `-short` does to exactly these tests. Starting it on first use means a package needs no reasoning about `-short` at all: no caller, no container, no cost. The three hand-written copies each had to argue this in a comment; here it is structural.

A missing Docker daemon FAILS, it does not skip. That is the posture the per-test helpers had (a failed container start failed the test) and inverting it would turn "the integration proofs did not run" into a green report.

poolclose.go — почему интеграционная проба закрывает пул ЧЕРЕЗ этот пакет, а не через `defer pool.Close()`.

Предмет

`pgxpool.Pool.Close` ждёт возврата ВСЕХ выданных соединений. Проба, упавшая внутри открытой транзакции, соединение не вернёт никогда: `require.NoError` → `t.FailNow` → `runtime.Goexit` завершает горутину пробы, отложенные функции исполняются, и `pool.Close()` встаёт ждать писателя, которого уже нет.

Дальше происходит подмена вердикта. Пакет упирается в `-timeout` (по умолчанию `go test` даёт 600 с) и печатает `FAIL` — то есть «не выполнилось» приходит к читателю ПОД ВИДОМ красного вердикта. Читатель ищет расхождение поведения, а прогон не состоялся вовсе; вердикта нет ни у одной пробы пакета, включая те, что успели пройти. Наблюдалось: однострочная опечатка в фикстуре стоила десяти минут прогона вместо доли секунды, и умножалась на число прогонов.

Что делает этот файл

Ровно одно: превращает зависание в ОТКАЗ С ИМЕНЕМ. Закрытие получает предел; не уложилось — проба падает, называя пробу, показания пула и снятие. Десять минут молчания меняются на несколько десятков секунд с причиной.

Это НЕ замена дисциплине `defer w.Abort()` сразу после `Writer(ctx)` — та убирает утечку вовсе. Это то, что ловит её отсутствие: у следующей фикстуры, написанной до ужесточения инварианта миграцией, шанса промолчать больше нет.

Index

Constants

View Source
const DefaultPoolCloseBound = 45 * time.Second

DefaultPoolCloseBound — сколько закрытие пула ждёт, прежде чем сдаться.

Величина ВЫВЕДЕНА, а не выбрана на глаз. `pkg/db.NewPool` задаёт соединениям `statement_timeout = 30000` (30 с), поэтому законное закрытие вправе ждать столько же: соединение может быть занято запросом, который сервер снимет сам. Предел ниже тридцати секунд отвергал бы исправный случай — ровно та проверка, которую первый же ложный срабат отключит. Пятнадцать секунд сверху — запас на загруженную машину.

Верхняя граница тоже названа: 45 с против 600 с умолчания `go test` — это тринадцатикратная разница, и при ней причина доезжает до читателя.

View Source
const PoolCloseBoundEnv = "KACHO_PGTEST_POOL_CLOSE_BOUND"

PoolCloseBoundEnv — ручка на случай, когда конвейеру нужен другой предел. Значение читается как `time.ParseDuration`; негодное игнорируется молча по умолчанию было бы неверно, поэтому оно печатается и предел остаётся штатным.

Variables

This section is empty.

Functions

func ClosePoolAtEnd

func ClosePoolAtEnd(t testing.TB, pool *pgxpool.Pool)

ClosePoolAtEnd регистрирует закрытие пула на конец пробы — С ПРЕДЕЛОМ.

Ставится ВМЕСТО `defer pool.Close()`:

pool, err := coredb.NewPool(ctx, dsn)
require.NoError(t, err)
pgtest.ClosePoolAtEnd(t, pool)

Почему `t.Cleanup`, а не `defer`: очистки исполняются ПОСЛЕ всех отложенных функций тела пробы, поэтому `defer w.Abort()` успевает вернуть соединение, и на исправной пробе не меняется ничего. А на неисправной — предел срабатывает и пакет живёт дальше, вместо того чтобы умереть целиком.

func Goose

func Goose(fsys fs.FS) func(context.Context, string) error

Goose returns a Migrate function that replays an embedded goose directory.

func NewDB

func NewDB(t testing.TB) string

NewDB returns a DSN for this test's own database, cloned from the migrated template, dropped when the test ends.

func NewEmptyDB

func NewEmptyDB(t testing.TB) string

NewEmptyDB returns a DSN for this test's own EMPTY database. It is for tests that must start before the current head of the migration chain and walk forward themselves; everything else wants NewDB.

func Run

func Run(m *testing.M, cfg Config) int

Run is what a package's TestMain calls:

func TestMain(m *testing.M) { os.Exit(pgtest.Run(m, pgtest.Config{...})) }

It does NOT start a container. The first NewDB / NewEmptyDB does; if none is reached — every test skipped under -short, or the package simply has no database test selected by -run — nothing is started and nothing is paid for.

func SQL

func SQL(stmts ...string) func(context.Context, string) error

SQL returns a Migrate function that executes one or more statements.

func WithSearchPath

func WithSearchPath(dsn, searchPath string) string

WithSearchPath дописывает к DSN клаузу `options` с приведением схемы.

Экспортирована ради пакетов, которые собирают DSN САМИ — своим контейнером либо своей раскладкой баз, — и потому не проходят через `Config`. Реализация у приведения одна на дерево: пока её не было, клаузу собирали 29 мест, и копии уже разошлись формой.

Собирается строкой, а не `url.Values.Encode()`, намеренно: `Encode` кодирует пробел как `+`, и выданный DSN перестал бы совпадать байт в байт с формой, которая уже проверена на живом сервере всеми прежними местами дерева. Экранируется при этом ЗНАЧЕНИЕ (`url.QueryEscape`), иначе запятая внутри `kaname,public` осталась бы голой и разделила параметры DSN.

Клауза, уже стоящая в DSN, не удваивается: двух `options` в одном DSN не бывает — вторая либо молча замещает первую, либо отвергается драйвером, и оба исхода хуже, чем оставить объявленное вызывающим.

Types

type Config

type Config struct {
	// Name is a short service identifier used to name the databases this package
	// creates (admin, template, and the per-test clones). It only has to be unique
	// inside the container, which is per-package, so the service name is enough.
	Name string

	// Migrate applies the schema to the TEMPLATE database, exactly once per test
	// binary. It is given a DSN pointing at the template. Use Goose for the usual
	// case of an embedded goose directory; supply a function directly when the
	// package builds its schema some other way (a raw CREATE TABLE, for instance).
	//
	// Nil means the template stays empty — the clones then differ from NewEmptyDB
	// only in name, which is a legitimate but unusual choice, so it is allowed
	// rather than rejected.
	Migrate func(ctx context.Context, dsn string) error

	// User and Password default to the Name when empty. They exist because the
	// helpers this replaces each picked their own, and a DSN that a test inspects
	// should not change under it.
	User, Password string

	// Image defaults to postgres:16-alpine, the image every replaced helper used.
	Image string

	// SearchPath — значение клаузы `search_path`, которое получает КАЖДЫЙ DSN,
	// выданный этим пакетом (`kaname,public`). Пусто — приведение не
	// дописывается, и поведение прежнее.
	//
	// Предмет принадлежит выдающему базу, а не спрашивающему её: схему создаёт
	// `Migrate` этого же `Config`, и кто её создал, тот и знает её имя. Пока
	// клаузу приписывал вызывающий, её приписывали 29 файлов в 25 пакетах —
	// каждый своей копией, и копии уже разошлись формой (`const`, `+=`,
	// вычисленный разделитель, проверка на удвоение — то есть, то нет).
	//
	// Забывший её получал `relation "roles" does not exist` — отказ, неотличимый
	// ни от непринятых миграций, ни от неверного имени таблицы в продукте, то
	// есть дефект ПРОБЫ, наказанный сообщением о дефекте ПРОДУКТА.
	SearchPath string
}

Config describes the one container a package wants.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL