Real database isolation for Go tests, fast enough to use for every test. One database per test on PostgreSQL or MySQL, cloned from a template in milliseconds. No Docker required.
- True Isolation - Each test gets its own database
- Parallel Tests - Run tests concurrently with
t.Parallel() - Zero Docker - Connects to a PostgreSQL or MySQL server you already run
- Templates - Build the schema once per run, clone it per test; safe to share between test packages and CI jobs
- Auto Cleanup - Databases are dropped automatically; keep a failed test's on request
- Migration Support - goose, tern and golang-migrate through their Go libraries, or plain SQL files; nothing to install
- PostgreSQL and MySQL - The same API and the same guarantees on both; pgxpool for PostgreSQL, database/sql for MySQL
A running PostgreSQL or MySQL server (local installation, managed instance, or remote). testdb connects to it - it doesn't manage the server itself.
go get github.com/bashhack/testdb/postgres # PostgreSQL
go get github.com/bashhack/testdb/mysql # MySQLpackage myapp_test
import (
"context"
"testing"
"github.com/bashhack/testdb"
"github.com/bashhack/testdb/migrate/goose"
"github.com/bashhack/testdb/postgres"
)
func TestUsers(t *testing.T) {
// Create isolated test database with your migrations
pool := postgres.Setup(t,
testdb.WithMigrator(goose.Dir("./migrations")),
)
// Use the database - tables from migrations are ready
_, err := pool.Exec(context.Background(),
"INSERT INTO users (email) VALUES ($1)", "test@example.com")
if err != nil {
t.Fatalf("Insert failed: %v", err)
}
// Cleanup is automatic via t.Cleanup()
}That's it! Each test gets an isolated database with your schema ready.
Two things, together: every test gets a real database of its own, and getting one is cheap enough to do for every test.
Real isolation. Each test runs against its own database, not a shared one wrapped in a transaction or scrubbed between tests. Commits, DDL, concurrent connections, locks, triggers: whatever your application does in production, the test does for real, and t.Parallel() is safe because there is nothing to share.
At clone speed. A template holds your schema and fixtures, built once per run; each test gets a clone. On PostgreSQL that is ~15 ms whatever the template holds. On MySQL, which has no way to clone a database, clones are prepared ahead of demand and a test takes one in ~6 ms. Without a template, a fresh database costs tens of milliseconds plus whatever your migrations cost.
Safe to share. go test ./... runs packages as separate processes, and CI runs jobs side by side. A template is built once, reused by every process, dropped by the last one out, and never left half-built or stranded by a crash. Multi-process tests, including a process killed mid-run, prove it on both databases.
Nothing to install. Migrations run through goose, tern or golang-migrate's own Go libraries, or from plain SQL files. No binaries on PATH, no version drift between CI and laptops, no Docker daemon: it connects to a PostgreSQL or MySQL server you already run.
The same on every database it supports. A database is listed here only when it meets the whole bar: isolation, templates with the same API and guarantees, migrators, keep-on-failure, and its own measured numbers.
| isolation | cost per test | t.Parallel() |
|
|---|---|---|---|
| Shared database, truncate between tests | none: state, DDL and commits are shared | low | unsafe |
| Transaction rolled back per test | partial: no commits, no second connection, no DDL | low | unsafe against one database |
| Fresh database + migrations per test | full | your migrations, every test | safe |
| testdb | full | ~15 ms PostgreSQL, ~6 ms MySQL (prepared) | safe, across processes too |
Shared database approach:
func TestUsers(t *testing.T) {
// Share a database with all other tests
db := getSharedTestDB()
// Manually clean up between tests
defer truncateTables(db, "users", "orders")
// Can't use t.Parallel() safely
// State from other tests can leak
}testdb approach:
func TestUsers(t *testing.T) {
t.Parallel() // Safe - each test has its own database
pool := postgres.Setup(t,
testdb.WithMigrator(goose.Dir("./migrations")),
)
// Complete isolation - no state leakage possible
}Set TEST_DATABASE_URL or DATABASE_URL:
export TEST_DATABASE_URL="postgres://user:pass@localhost:5432/postgres"Or use the default: postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable
Each database provider also has a variable of its own that wins over the shared ones, so a project that tests against more than one database server can point each provider at the right one: TEST_POSTGRES_URL for PostgreSQL, TEST_MYSQL_URL for MySQL. The full order is WithAdminDSN, the provider's variable, TEST_DATABASE_URL, DATABASE_URL, then the default.
pool := postgres.Setup(t,
testdb.WithMigrator(goose.Dir("./migrations")),
testdb.WithAdminDSN("postgres://custom:5432/postgres"),
testdb.WithDBPrefix("myapp_test"),
)Available options:
WithMigrator(m)- How each new database is prepared: a migration-tool adapter,postgres.SQLFiles(dir)/mysql.SQLFiles(dir), or anyfunc(ctx, dsn) errorWithTemplate(name)- Clone a template database instead of starting empty (see Templates)WithAdminDSN(dsn)- Override admin connection stringWithDBPrefix(prefix)- Database name prefix (default: "test")WithKeepOnFailure()- Keep a failed test's database for inspection (see below)WithVerbose()- Enable verbose logging for debugging
By default every test database is dropped, pass or fail. When a test fails and you want to see what it left behind, keep its database:
TESTDB_KEEP_ON_FAILURE=1 go test ./... -run TestCheckout checkout_test.go:42: expected 2 orders, got 1
testdb: kept database test_1761100000_a1b2c3d4 for inspection:
psql "postgres://postgres:postgres@localhost:5432/test_1761100000_a1b2c3d4?sslmode=disable"
Passing tests still clean up after themselves. The same switch exists in code as testdb.WithKeepOnFailure(), and with the low-level testdb.New you call db.Keep() yourself. On MySQL the log line gives a mysql command line instead. Kept databases are yours to drop afterwards; a WithDBPrefix makes them easy to find. Leave this off in CI, where nobody will look and the databases would only pile up.
testdb provides two built-in initializers for PostgreSQL, and mysql.SQLDBInitializer (a *sql.DB, what mysql.Setup uses) for MySQL:
Creates *pgxpool.Pool - recommended for most PostgreSQL applications. Provides full access to PostgreSQL-specific features:
// postgres.Setup() uses PoolInitializer automatically
pool := postgres.Setup(t)
// Or explicitly with postgres.New()
db := postgres.New(t, &postgres.PoolInitializer{})
pool := db.Entity().(*pgxpool.Pool)
// Full pgx capabilities: arrays, JSON, COPY, LISTEN/NOTIFY
pool.QueryRow(ctx, "SELECT ARRAY[1,2,3]").Scan(&arr)Creates *sql.DB - use when your application code or dependencies expect database/sql interfaces:
db := postgres.New(t, &postgres.SqlDbInitializer{})
sqlDB := db.Entity().(*sql.DB)
// Standard database/sql operations
sqlDB.QueryRow("SELECT * FROM users WHERE id = $1", 1).Scan(&name)
// Works with libraries that expect *sql.DB
repo := myorm.NewRepository(sqlDB)SqlDbInitializer uses pgx/v5/stdlib under the hood, so you get pgx's PostgreSQL support with database/sql compatibility.
When to use SqlDbInitializer:
- Your application uses
*sql.DB,*sql.Tx, or*sql.Rows - You're working with ORMs or libraries that expect
*sql.DB - You need database/sql semantics (standard connection pooling, transactions)
When to use PoolInitializer:
- You're using pgx directly (recommended for new PostgreSQL projects)
- You need PostgreSQL-specific features (arrays, JSON types, COPY, LISTEN/NOTIFY)
- You want the best performance and feature set
If you need custom database initialization (e.g., using GORM, sqlx):
type MyInitializer struct{}
func (m *MyInitializer) InitializeTestDatabase(ctx context.Context, dsn string) (interface{}, error) {
// Your custom initialization
return myapp.InitDB(dsn)
}
func TestAdvanced(t *testing.T) {
db := postgres.New(t, &MyInitializer{},
testdb.WithMigrator(goose.Dir("./migrations")),
)
myDB := db.Entity().(*myapp.DB)
// Use your custom DB type
}If you want full control over connections without an initializer:
provider := &postgres.PostgresProvider{}
db, err := testdb.New(t, provider, nil,
testdb.WithMigrator(goose.Dir("./migrations")),
)
if err != nil {
t.Fatalf("Failed to create database: %v", err)
}
// With testdb.New you decide when migrations run
if err := db.RunMigrations(t.Context()); err != nil {
t.Fatalf("Migrations failed: %v", err)
}
// Use the DSN to create your own connection
myCustomPool := myapp.ConnectDB(db.DSN())
defer myCustomPool.Close()
// Don't forget cleanup
defer db.Close()func setupDB(t *testing.T) *pgxpool.Pool {
t.Helper()
return postgres.Setup(t,
testdb.WithMigrator(goose.Dir("./migrations")),
)
}
func TestSomething(t *testing.T) {
pool := setupDB(t)
// Use pool...
}For demonstrating isolation mechanics or simple tests:
func TestIsolation(t *testing.T) {
// Create database without migrations
pool := postgres.Setup(t)
// Use for simple queries
var result int
pool.QueryRow(context.Background(), "SELECT 1").Scan(&result)
}This is useful for testing the library itself, but production applications should use migrations.
import "github.com/bashhack/testdb/postgres"
pool := postgres.Setup(t,
testdb.WithMigrator(goose.Dir("./migrations")),
)import "github.com/bashhack/testdb/mysql"
db := mysql.Setup(t, // *sql.DB
testdb.WithMigrator(goose.Dir("./migrations")),
)The admin connection string may be in go-sql-driver's native form (user:pass@tcp(host:port)/?parseTime=true) or a mysql:// URL, from TEST_MYSQL_URL or the shared variables; the default is root:root@tcp(localhost:3306)/?parseTime=true. The user needs CREATE/DROP DATABASE and the right to kill other sessions (CONNECTION_ADMIN), which root has.
Two things about MySQL that testdb handles for you. DROP DATABASE does not refuse while other sessions are connected, it waits for the locks they hold (by default, for a year); cleanup kills the test's sessions first and bounds the wait at five seconds, so a leaked transaction can never hang a test run. And a MySQL database with 50 tables takes about half a second to create however you do it, so for speed see Templates on MySQL.
For the fastest possible test server, start it without durability, as docker-compose.test.yml does: --innodb-flush-log-at-trx-commit=0 --sync-binlog=0 --skip-log-bin --innodb-doublewrite=0. Tested in CI against MySQL 8.4 and MariaDB 11.4.
Other databases can be added by implementing testdb.Provider, plus testdb.TemplateProvider for templates.
testdb.WithMigrator takes anything that can prepare a database from a DSN:
type Migrator interface {
Migrate(ctx context.Context, dsn string) error
}The migrations run inside your test process, under the test's context, so a hung migration stops when the test times out and there is no binary to install or keep in sync.
No tool needed. SQLFiles runs every .sql file in a directory in lexical order:
pool := postgres.Setup(t, testdb.WithMigrator(postgres.SQLFiles("./schema")))Each adapter is its own Go module, so your project only depends on the tool it uses. goose and golang-migrate work with PostgreSQL and MySQL; tern is PostgreSQL-only.
go get github.com/bashhack/testdb/migrate/goose # github.com/pressly/goose
go get github.com/bashhack/testdb/migrate/tern # github.com/jackc/tern
go get github.com/bashhack/testdb/migrate/golangmigrate # github.com/golang-migrate/migratepool := postgres.Setup(t, testdb.WithMigrator(goose.Dir("./migrations")))
pool := postgres.Setup(t, testdb.WithMigrator(tern.Dir("./migrations")))
pool := postgres.Setup(t, testdb.WithMigrator(golangmigrate.Dir("./migrations")))All three also accept an fs.FS, so migrations can be embedded with embed.FS:
//go:embed migrations/*.sql
var migrations embed.FS
pool := postgres.Setup(t, testdb.WithMigrator(goose.FS(migrations, "migrations")))Any function with the right shape is a migrator:
pool := postgres.Setup(t, testdb.WithMigrator(testdb.MigratorFunc(
func(ctx context.Context, dsn string) error {
return myapp.Migrate(ctx, dsn)
})))A migrator's Migrate method is also a template setup function, so the same value builds a template once instead of migrating every test (see Templates).
A template is a database you build once, from migrations, a SQL dump or any Go code, that each test then clones in a few milliseconds. Use one when migrations are slow, when you load large fixture files, or when many tests need the same starting data.
Ensure the template in TestMain, use it in tests, release it when the run ends:
func TestMain(m *testing.M) {
ctx := context.Background()
tpl, err := postgres.EnsureTemplate(ctx, "with_schema",
goose.Dir("./migrations").Migrate)
if err != nil {
log.Fatal(err)
}
code := m.Run()
if err := tpl.Release(ctx); err != nil {
log.Print(err)
}
os.Exit(code)
}
func TestUsers(t *testing.T) {
// A clone of the template: schema already applied
pool := postgres.Setup(t, testdb.WithTemplate("with_schema"))
// Start testing immediately
}go test ./... runs every package as its own process, and CI often runs several jobs against one server. EnsureTemplate is built for that:
- The first process to arrive builds the template; the others wait for it and reuse it. Setup runs once, no matter how many packages share the name.
- A template is built under a temporary name and renamed into place, so no process ever sees a half-built one.
Releasedrops the template only when the last process using it is done. A process that crashes drops out of the count on its own, because its claim on the template dies with its connection.- The template is a real PostgreSQL template (
IS_TEMPLATE true), so a strayDROP DATABASEis refused.
Everything sharing a template must use the same admin database; that is where the coordination happens. These are the PostgreSQL mechanics; MySQL gives the same guarantees by other means, see Templates on MySQL.
By default a template lives for one run. To keep it, give it a fingerprint so it is rebuilt when its inputs change and reused when they don't:
fp, err := postgres.HashFiles("./migrations", "fixtures/us.sql")
if err != nil {
log.Fatal(err)
}
tpl, err := postgres.EnsureTemplate(ctx, "us_region", setup,
postgres.WithFingerprint(fp),
postgres.WithKeepTemplate())If the fingerprint changed but another process is still using the old template, EnsureTemplate returns an error rather than pulling the template out from under it.
Load SQL dumps or fixture data:
postgres.EnsureTemplate(ctx, "with_fixtures",
postgres.LoadSQLFile("fixtures/seed_data.sql"))Any func(ctx context.Context, dsn string) error can build a template:
setup := func(ctx context.Context, dsn string) error {
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return err
}
defer pool.Close()
_, err = pool.Exec(ctx, `
CREATE TABLE test_data (id SERIAL PRIMARY KEY, value INT);
`)
return err
}
postgres.EnsureTemplate(ctx, "custom", setup)Combine migrations and fixtures with Compose:
postgres.EnsureTemplate(ctx, "full",
postgres.Compose(
goose.Dir("./migrations").Migrate,
postgres.LoadSQLFile("fixtures/data.sql"),
))mysql.EnsureTemplate, Release, WithFingerprint, WithKeepTemplate and testdb.WithTemplate work exactly as above, with the same guarantees across processes. What differs is underneath, because MySQL has no way to clone a database:
- A clone is made object by object: tables (indexes, foreign keys, checks,
AUTO_INCREMENTpositions), rows, views, triggers, procedures and functions. Deliberately not copied: events (a scheduler job firing inside a test's clone would make the test nondeterministic) and grants (tests connect as the admin user).DEFINERclauses are dropped, so objects belong to whoever runs the clone, and a foreign key to a table in another database keeps pointing there. - That clone costs the server's per-table DDL, about 0.5–0.8 s for a 50-table schema on an M5 MacBook Air, no faster than running the migrations. So a MySQL template prepares clones ahead of demand:
WithReadyDatabases(n)(defaultGOMAXPROCS) keepsndatabases cloned and ready, refilled in the background byWithReadyWorkersparallel workers, and a test takes one instead of waiting.EnsureTemplatefills the pool once before it returns, so the first test already finds it full (WithReadyWarmup(false)to skip that). When the pool runs dry, the test clones on demand rather than wait; the pool size is also how many back-to-back quick tests it absorbs.
Measured on MySQL 8.4 in Docker on an M5 MacBook Air (durability off, as in docker-compose.test.yml), 50 tables with an index each, 5,000 rows:
| per test | |
|---|---|
| empty database | 8 ms |
| fresh database + migrations | 498 ms |
| clone on demand | 768 ms |
| prepared clone, tests of 200 ms or longer | 6 ms, 100% from the pool |
| prepared clone, 50 ms tests back to back | 130–200 ms, 83–92% from the pool |
The pool keeps up as long as tests take clones no faster than the server makes them. For integration tests, which do real work, that holds comfortably and every test pays 6 ms. In a tight loop of trivial tests the floor is your server's clone throughput; more workers help until the server saturates.
Bookkeeping lives in _testdb_* tables inside the template (never copied into clones). A process that dies leaves nothing permanent: its hold on the template and its prepared databases are swept by the next EnsureTemplate or Release on that template.
Different templates for different scenarios:
func TestMain(m *testing.M) {
ctx := context.Background()
us, err := postgres.EnsureTemplate(ctx, "us_region", postgres.LoadSQLFile("fixtures/us.sql"))
if err != nil {
log.Fatal(err)
}
eu, err := postgres.EnsureTemplate(ctx, "eu_region", postgres.LoadSQLFile("fixtures/eu.sql"))
if err != nil {
log.Fatal(err)
}
code := m.Run()
for _, tpl := range []*postgres.Template{us, eu} {
if err := tpl.Release(ctx); err != nil {
log.Print(err)
}
}
os.Exit(code)
}
func TestUSCheckout(t *testing.T) {
pool := postgres.Setup(t, testdb.WithTemplate("us_region"))
// Test with US data
}
func TestEUVAT(t *testing.T) {
pool := postgres.Setup(t, testdb.WithTemplate("eu_region"))
// Test with EU data
}For each test:
- A unique name - prefix, nanosecond timestamp and a random suffix:
test_1699564231_a1b2c3d4 - A database -
CREATE DATABASE, or a clone of a template: PostgreSQL'sCREATE DATABASE … TEMPLATE, or on MySQL a clone the template prepared ahead - Migrations - the configured migrator runs against the new database, under the test's context
- A connection - the initializer opens one:
*pgxpool.Poolor*sql.DBby default, or your own type - Cleanup registered -
t.Cleanup()runs it even if the test panics - Other sessions ended -
pg_terminate_backendon PostgreSQL,KILL CONNECTIONon MySQL, so the drop cannot be blocked - The database dropped - unless the test failed and you asked to keep it
See postgres/example_test.go for runnable examples demonstrating:
- Basic database creation and isolation
- Applying a schema from SQL files or your own migrator
- Running tests concurrently with
t.Parallel() - Custom prefixes and configuration options
- Using GORM and other ORMs
- Working with database/sql
All examples are also visible in the package documentation. The MySQL package's examples, in mysql/example_test.go, are compile-checked rather than run, since they cannot skip when no MySQL server is reachable.
- A PostgreSQL or MySQL server, local or reachable
- Go 1.26 or later
MIT License - see LICENSE