Builds on example 20 and demonstrates the test runner's reuse and isolation features working together:
- Named steps (
TestRunner.Steps) — define create/migrate/drop operations once, reference them by name from the globalSetup/Teardownand from individual test files (mixed freely with inline step objects, including a shellCommandstep). - Per-file setup/teardown/connection — header annotations give a single test file its own database,
cloned from a template, for perfect isolation. Two isolated tests run in parallel, each in its own clone,
named apart by the indexed random tokens
{rnd5_1}/{rnd5_2}. - Script includes (
\ir) — reusable fixture SQL spliced into a test inside its transaction, and a shared annotation profile attached with one include line.
bun run test # template → clones → run tests (two tests in their own clones) → drop everything
bun run local-test # same, with the local NpgsqlRest build
Transaction rollback is the runner's default isolation — and it is almost always enough. But sequences
are non-transactional: every nextval() sticks, even when the transaction rolls back. On a shared test
database, "what id will the next insert get?" is unanswerable — it depends on every test that ever inserted,
including rolled-back ones. The same applies to anything else outside transactional semantics.
The fix is a fresh database cloned from a template for exactly the tests that need it:
tests/create_user_returns_the_new_user.test.sqlruns on the shared test database and deliberately does not assert the generated id (the comment explains why).tests/create_user_ids_are_deterministic.test.sqlruns in its own clone and proves the ids are exactly 4 and 5 (template seeds users 1–3 and sets the sequence to 3).tests/clones_are_independent.test.sqlruns in a second clone and also gets id 4 — possible only because each isolated test has its own database.
The migration is applied once to a template database; everything else is a near-instant file-level
clone (create database … template …). All of it is plain configuration — the runner issues no DDL:
Three things this config demonstrates beyond the clone workflow itself:
- A
Commandstep — the first Setup entry runs via the OS shell, with{rnd}placeholders substituted in the command text too. Here it just echoes the run's database names; in a real project this slot is wheredocker run+ apg_isreadywait, or an external migrator (EF Core, Django, Flyway) would go. Command output goes to theNpgsqlRestTestlog channel atDebug— this example'stest-config.jsonsets that channel toDebugso you can watch the run's lifecycle (steps, clones, outcomes); the default level (Information) keeps runs quiet. - Name references and inline step objects mix freely in the same
Setuparray. - Indexed random tokens —
{rnd5},{rnd5_1}and{rnd5_2}are three independent 5-character tokens ({rndN_1}–{rndN_9}exist for every length), each stable for the whole run. The two isolated tests get different clone names from the same naming pattern, so both databases coexist in one run.
One rule to know: a template must have no active connections while it is being cloned. That is why the template is migrated over its own short-lived connection in Setup and never touched again — Setup/Teardown step connections are non-pooled and close as soon as the step finishes. (Two clones can be created from the same template concurrently — the parallel isolated tests in this example do exactly that.)
An isolated test opts in with three header annotations (leading -- comments, before any statement):
-- @setup CreateIsolatedDb1
-- @teardown DropIsolatedDb1
-- @connection Isolated1
/*
POST /api/create-user
Content-Type: application/json
{"name": "First In Clone", "email": "first@example.com"}
*/
select (body::jsonb ->> 'id')::int = 4,
'first created user gets id 4 (seeded 1..3, sequence at 3)'
from _response_1;-- @setup CreateIsolatedDb1— runs the named step before this file (clones the template).-- @connection Isolated1— runs this file, including its in-process endpoint calls, on theIsolated1connection (the clone). The endpoints were type-checked against the shared test database, and the clone has the identical schema, so everything just works.-- @teardown DropIsolatedDb1— drops the clone after this file, always (even on failure), after the file's connection is closed.
And since includes behave as if pasted, the whole set can live in a shared profile file —
tests/shared/isolated_database.sql holds exactly those three annotation lines — and a test attaches it
with a single include, which is what create_user_ids_are_deterministic.test.sql actually does:
\ir shared/isolated_database.sqlNo begin/rollback in this file — the whole database is disposable, which is the strongest isolation
there is.
Two clones, proven independent. A second isolated test, clones_are_independent.test.sql, declares its
three annotations inline (both styles work) and uses the second clone (Isolated2 /
example_21_iso_{rnd5_2}). Both isolated tests assert the very same generated id — 4 — which can only
hold if each runs against its own fresh clone; on any shared database one of them would observe the other's
sequence consumption. They run in parallel, cloning the same template concurrently.
tests/get_users_lists_seed_and_fixture.test.sql splices a shared fixture in place:
begin;
\ir fixtures/extra_users.sql
/*
GET /api/get-users
*/
select jsonb_array_length(body::jsonb) = 5,
'three seeded + two fixture users are listed'
from _response;
rollback;\ir (relative to the including file; \i is cwd-relative — psql semantics) replaces the line with the
included file's statements, executed on the test's connection, inside its transaction — so the fixture
rolls back with the test, and there is no copy-paste. Failures inside an included file are reported with
the included file's name and line.
- Rollback is the default isolation; clones are the escalation. Use
begin … rollback(plus\irfixtures) for everything transactional; reach for a per-test clone only when non-transactional state (sequences, committed side effects) must be exact. - Named steps make isolation cheap to express. Define the clone/drop pair once; any test file can opt in with two header lines.
- Everything is disposable and self-cleaning. All databases carry per-run random tokens (
{rnd5}for the template and shared test database, the independent{rnd5_1}/{rnd5_2}for the two clones), created in Setup or per-file setup, dropped in teardown — a run leaves nothing behind. - Tags ride along with the profile. The shared-DB tests are tagged
smoke; the clone tests are taggedisolation, slow— and the profile-based test carries those tags through the include (the profile file declares-- @tag isolation, slow; includes behave as if pasted). The everyday dev loop skips the slower clone tests withbun run local-test -- --testrunner:excludetag=slow; run only the isolation proofs with--testrunner:tag=isolation.