Help us shape the future of Go! Take the 2026 Go Developer Survey

testdb

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 22, 2026 License: MIT Imports: 10 Imported by: 0

README

testdb

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.

Go Reference

Features

  • 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

Quick Start

Prerequisites

A running PostgreSQL or MySQL server (local installation, managed instance, or remote). testdb connects to it - it doesn't manage the server itself.

Install
go get github.com/bashhack/testdb/postgres   # PostgreSQL
go get github.com/bashhack/testdb/mysql      # MySQL
Basic Usage
package 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.

Why testdb?

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.

Compared with the alternatives
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
}

Configuration

Environment Variables

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.

Options
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 any func(ctx, dsn) error
  • WithTemplate(name) - Clone a template database instead of starting empty (see Templates)
  • WithAdminDSN(dsn) - Override admin connection string
  • WithDBPrefix(prefix) - Database name prefix (default: "test")
  • WithKeepOnFailure() - Keep a failed test's database for inspection (see below)
  • WithVerbose() - Enable verbose logging for debugging
Looking at a Failed Test's Database

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.

Advanced Usage

Built-in Initializers

testdb provides two built-in initializers for PostgreSQL, and mysql.SQLDBInitializer (a *sql.DB, what mysql.Setup uses) for MySQL:

PoolInitializer (Default)

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)
SqlDbInitializer

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
Custom Initializer

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
}
Using Just the DSN

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()
Helper Function Pattern
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...
}
Testing Without Migrations

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.

Supported Databases

PostgreSQL
import "github.com/bashhack/testdb/postgres"

pool := postgres.Setup(t,
    testdb.WithMigrator(goose.Dir("./migrations")),
)
MySQL
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.

Migrations

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.

Plain SQL files

No tool needed. SQLFiles runs every .sql file in a directory in lexical order:

pool := postgres.Setup(t, testdb.WithMigrator(postgres.SQLFiles("./schema")))
goose, tern, golang-migrate

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/migrate
pool := 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")))
Your own code

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).

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.

Basic Template Usage

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
}
Safe to Share Between Packages

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.
  • Release drops 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 stray DROP DATABASE is 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.

Keeping Templates Between Runs

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.

Template with SQL Files

Load SQL dumps or fixture data:

postgres.EnsureTemplate(ctx, "with_fixtures",
    postgres.LoadSQLFile("fixtures/seed_data.sql"))
Template with Custom Setup

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)
Composite Templates

Combine migrations and fixtures with Compose:

postgres.EnsureTemplate(ctx, "full",
    postgres.Compose(
        goose.Dir("./migrations").Migrate,
        postgres.LoadSQLFile("fixtures/data.sql"),
    ))
Templates on MySQL

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_INCREMENT positions), 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). DEFINER clauses 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) (default GOMAXPROCS) keeps n databases cloned and ready, refilled in the background by WithReadyWorkers parallel workers, and a test takes one instead of waiting. EnsureTemplate fills 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.

Multiple Templates

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
}

How It Works

For each test:

  1. A unique name - prefix, nanosecond timestamp and a random suffix: test_1699564231_a1b2c3d4
  2. A database - CREATE DATABASE, or a clone of a template: PostgreSQL's CREATE DATABASE … TEMPLATE, or on MySQL a clone the template prepared ahead
  3. Migrations - the configured migrator runs against the new database, under the test's context
  4. A connection - the initializer opens one: *pgxpool.Pool or *sql.DB by default, or your own type
  5. Cleanup registered - t.Cleanup() runs it even if the test panics
  6. Other sessions ended - pg_terminate_backend on PostgreSQL, KILL CONNECTION on MySQL, so the drop cannot be blocked
  7. The database dropped - unless the test failed and you asked to keep it

Examples

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.

Requirements

  • A PostgreSQL or MySQL server, local or reachable
  • Go 1.26 or later

License

MIT License - see LICENSE

Documentation

Overview

Package testdb provides isolated test database instances for Go tests. It enables true parallel testing by creating a unique database for each test, with migration support and optional automatic cleanup.

Supported databases:

  • PostgreSQL (github.com/bashhack/testdb/postgres)

Basic usage with PostgreSQL (automatic cleanup via postgres.Setup):

import (
    "github.com/bashhack/testdb"
    "github.com/bashhack/testdb/migrate/goose"
    "github.com/bashhack/testdb/postgres"
)

func TestUsers(t *testing.T) {
    pool := postgres.Setup(t,
        testdb.WithMigrator(goose.Dir("./migrations")))
    // Use pool for testing...
    // Cleanup is automatic via t.Cleanup()
}

API Levels:

The library provides three levels of API with different use cases and cleanup behavior:

Level 1 - postgres.Setup() [Recommended for most users]:

  • Use when: Working with pgx/pgxpool directly
  • Returns: *pgxpool.Pool (ready to use)
  • Cleanup: Automatic via t.Cleanup() - DO NOT call Close() manually
  • Best for: Standard PostgreSQL testing with pgx

Level 2 - postgres.New() [For custom database wrappers]:

  • Use when: Using GORM, sqlx, ent, or custom initialization
  • Returns: *testdb.TestDatabase with custom entity
  • Cleanup: Automatic via t.Cleanup() - DO NOT call db.Close() manually
  • Best for: Custom ORMs, connection wrappers, or when you need the TestDatabase

Level 3 - testdb.New() [Low-level API]:

  • Use when: Need manual cleanup control or implementing custom providers
  • Returns: *testdb.TestDatabase
  • Cleanup: Manual - YOU MUST call defer db.Close()
  • Best for: Advanced use cases requiring cleanup timing control

See the postgres.Setup(), postgres.New(), and Close() documentation for details.

Example (CustomInitializer)

Example_customInitializer shows implementing a custom initializer for integration with ORMs or custom database types.

t := newExampleT()
defer t.done()

provider := &postgres.PostgresProvider{}
initializer := &exampleInitializer{}

db, err := testdb.New(t, provider, initializer,
	testdb.WithMigrator(postgres.SQLFiles("./testdata/postgres/schema")),
)
if err != nil {
	fmt.Printf("Error: %v\n", err)
	return
}
defer func() {
	if err := db.Close(); err != nil {
		fmt.Printf("Failed to close database: %v\n", err)
	}
}()

// Type assert to your custom type
// myDB := db.Entity().(*myapp.DB)
fmt.Println("Custom initializer setup complete")
Output:
Custom initializer setup complete
Example (DsnOnly)

Example_dsnOnly demonstrates using testdb to get just a DSN without automatic connection initialization.

t := newExampleT()
defer t.done()

provider := &postgres.PostgresProvider{}

// Pass nil initializer to skip connection initialization
db, err := testdb.New(t, provider, nil,
	testdb.WithMigrator(postgres.SQLFiles("./testdata/postgres/schema")),
)
if err != nil {
	fmt.Printf("Error: %v\n", err)
	return
}
defer func() {
	if err := db.Close(); err != nil {
		fmt.Printf("Failed to close database: %v\n", err)
	}
}()

// Use the DSN with your own connection logic
dsn := db.DSN
_ = dsn // Connect with your preferred client
fmt.Println("DSN created successfully")
Output:
DSN created successfully
Example (New)

Example_new demonstrates using testdb.New with a custom initializer for full control over database initialization.

t := newExampleT()
defer t.done()

provider := &postgres.PostgresProvider{}
initializer := &postgres.PoolInitializer{}

db, err := testdb.New(t, provider, initializer,
	testdb.WithMigrator(postgres.SQLFiles("./testdata/postgres/schema")),
	testdb.WithDBPrefix("myapp_test"),
)
if err != nil {
	fmt.Printf("Error: %v\n", err)
	return
}
defer func() {
	if err := db.Close(); err != nil {
		fmt.Printf("Failed to close database: %v\n", err)
	}
}()

// DSN is always available
fmt.Println("Database created successfully")

if err := db.RunMigrations(context.Background()); err != nil {
	fmt.Printf("Migration error: %v\n", err)
	return
}
fmt.Println("Migrations completed")
Output:
Database created successfully
Migrations completed

Index

Examples

Constants

View Source
const KeepOnFailureEnv = "TESTDB_KEEP_ON_FAILURE"

KeepOnFailureEnv is the environment variable that turns on KeepOnFailure for a run.

View Source
const (
	// MaxDBPrefixLength is the maximum recommended length for database name prefixes.
	//
	// This limit is intentionally based on the most restrictive database to ensure
	// consistent behavior across all supported databases:
	//   - PostgreSQL: 63 bytes (most restrictive)
	//   - MySQL: 64 characters
	//   - SQLite: effectively unlimited
	//
	// Database name format: prefix_timestamp_random (prefix + 29 chars)
	// To avoid truncation: prefix + 29 <= 63, therefore prefix <= 34
	//
	// Design decision: I'm using the most restrictive limit (PostgreSQL's 63-byte limit)
	// for all databases rather than implementing database-specific validation. This
	// provides a consistent, safe experience and simplifies the API. A 34-character
	// prefix is sufficient for all practical use cases.
	MaxDBPrefixLength = 34
)

Variables

View Source
var (
	// ErrNilProvider is returned when a nil provider is passed to New().
	ErrNilProvider = errors.New("provider cannot be nil")

	// ErrNoMigrator is returned when RunMigrations is called without a Migrator configured.
	ErrNoMigrator = errors.New("no migrator configured")

	// ErrTemplatesNotSupported is returned by New when WithTemplate is used
	// with a provider that does not implement TemplateProvider.
	ErrTemplatesNotSupported = errors.New("templates are not supported")

	// ErrPrefixTooLong is returned when the database prefix would cause identifier truncation.
	ErrPrefixTooLong = errors.New("database prefix too long: would exceed database identifier limit")
)

Functions

func ContextOf

func ContextOf(t testing.TB) context.Context

ContextOf returns t.Context(), which is cancelled when the test ends, so database setup and migrations stop when a test times out. A zero testing.T, as runnable examples use, has no context; then it returns context.Background(). Database-specific packages use it when calling RunMigrations on a test's behalf.

func NewDatabaseName

func NewDatabaseName(prefix string) (string, error)

NewDatabaseName returns a unique database name with the given prefix, in the form every test database gets: {prefix}_{timestamp}_{random}, for example test_1699564231_a1b2c3d4. Providers that prepare databases ahead of a test use it so those look like any other test database.

func RegisterCleanup

func RegisterCleanup(t testing.TB, db *TestDatabase)

RegisterCleanup arranges for the database to go away when the test ends: the entity is closed first if it implements io.Closer, then Close drops the database. With KeepOnFailure, a failed test's database is kept instead (see Keep).

func ResolveAdminDSN

func ResolveAdminDSN(cfg Config, defaultDSN string, providerEnv ...string) string

ResolveAdminDSN resolves the admin DSN using a consistent priority order. This helper consolidates the DSN resolution logic to avoid duplication across database-specific providers (PostgreSQL, MySQL, SQLite, etc.).

The library automatically discovers the admin DSN so users don't need to provide it unless they have custom connection requirements.

Resolution order:

  1. cfg.AdminDSNOverride (explicit user override via WithAdminDSN)
  2. The provider's own environment variables, in the order given (e.g. TEST_POSTGRES_URL), so a project that tests against two databases can point each provider at its own server
  3. TEST_DATABASE_URL environment variable
  4. DATABASE_URL environment variable
  5. defaultDSN (database-specific default)

Example usage in provider initialization:

adminDSN := testdb.ResolveAdminDSN(cfg,
    "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable",
    "TEST_POSTGRES_URL")

func RunConfiguredMigrations

func RunConfiguredMigrations(t testing.TB, db *TestDatabase, callerName string)

RunConfiguredMigrations runs the Migrator set with WithMigrator, if any. On failure it drops the database (or keeps it, under KeepOnFailure, since a half-applied migration is exactly what one wants to look at) and calls t.Fatalf, so it does not return. callerName is the function the test called, for the message.

Types

type Config

type Config struct {
	// AdminDSNOverride is an optional connection string override for creating/dropping test databases.
	// The user specified here must have privileges to create and drop databases.
	//
	// For PostgreSQL, this is typically the 'postgres' database.
	//
	// If not specified, the library automatically discovers the admin DSN from:
	//   1. A database-specific environment variable (e.g., TEST_POSTGRES_URL)
	//   2. TEST_DATABASE_URL environment variable
	//   3. DATABASE_URL environment variable
	//   4. Database-specific defaults (e.g., postgres://postgres:postgres@localhost:5432/postgres)
	//
	// Most users don't need to set this - automatic discovery works in most cases.
	// Use this when you need custom admin credentials or connection settings.
	AdminDSNOverride string

	// Migrator prepares each new test database, typically by running the
	// project's migrations. nil (the default) leaves the database empty.
	// See WithMigrator.
	Migrator Migrator

	// DBPrefix is prepended to test database names.
	// Useful for identifying test databases in a shared environment.
	//
	// Default: "test"
	// Example database name: "test_1699564231_a1b2c3d4"
	DBPrefix string

	// TemplateName specifies a template database to clone instead of creating a fresh database.
	// Templates are significantly faster than fresh database creation, especially for large schemas
	// or databases with pre-seeded data.
	//
	// If empty (default), a fresh database is created.
	// If specified, the template database must already exist (see postgres.EnsureTemplate).
	//
	// Example: "us_region" or "seeded_schema"
	//
	// Performance impact:
	//   - Fresh database: ~12-15ms (PostgreSQL CREATE DATABASE)
	//   - Template clone: ~3-5ms (PostgreSQL CREATE DATABASE WITH TEMPLATE)
	TemplateName string

	// Verbose enables logging of database operations.
	// When false (default), testdb operates silently.
	// When true, logs database creation, cleanup, and migration completion.
	//
	// Default: false
	Verbose bool

	// KeepOnFailure leaves a test's database in place when the test fails,
	// and logs a connection string for it, so the data can be inspected.
	// See WithKeepOnFailure.
	//
	// Default: false, or true when the TESTDB_KEEP_ON_FAILURE environment
	// variable is set to 1, true, yes or on.
	KeepOnFailure bool
}

Config holds the configuration for test database creation and management.

func DefaultConfig

func DefaultConfig() Config

DefaultConfig returns a Config with reasonable defaults.

type DBInitializer

type DBInitializer interface {
	// InitializeTestDatabase creates and initializes a database connection/pool.
	// It receives a DSN (Data Source Name) for connecting to the test database
	// and returns an any that should be type-asserted by the caller to
	// their specific database entity type.
	//
	// Parameters:
	//   - ctx: Context for cancellation and timeouts
	//   - dsn: Connection string for the test database
	//
	// Returns:
	//   - any: Database entity (type assert to your specific type)
	//   - error: Any initialization errors
	InitializeTestDatabase(ctx context.Context, dsn string) (any, error)
}

DBInitializer defines the interface for custom database initialization in tests.

When You Need a Custom Initializer

Most users should use database-specific convenience functions (e.g., postgres.Setup) and don't need to implement this interface. Implement DBInitializer when:

1. Using an ORM (GORM, ent, SQLBoiler):

type GormInitializer struct{}
func (g *GormInitializer) InitializeTestDatabase(ctx context.Context, dsn string) (any, error) {
    return gorm.Open(postgres.Open(dsn), &gorm.Config{})
}

2. Using sqlx for struct scanning:

type SqlxInitializer struct{}
func (s *SqlxInitializer) InitializeTestDatabase(ctx context.Context, dsn string) (any, error) {
    return sqlx.Connect("postgres", dsn)
}

3. Wrapping connections in your application's custom type:

type AppDB struct {
    Pool    *pgxpool.Pool
    Timeout time.Duration
}
type AppDBInitializer struct{}
func (a *AppDBInitializer) InitializeTestDatabase(ctx context.Context, dsn string) (any, error) {
    pool, err := pgxpool.New(ctx, dsn)
    if err != nil {
        return nil, err
    }
    return &AppDB{Pool: pool, Timeout: 30 * time.Second}, nil
}

4. Custom connection pooling settings:

type CustomPoolInitializer struct {
    MaxConns int32
}
func (c *CustomPoolInitializer) InitializeTestDatabase(ctx context.Context, dsn string) (any, error) {
    config, _ := pgxpool.ParseConfig(dsn)
    config.MaxConns = c.MaxConns
    config.MinConns = 2
    config.MaxConnLifetime = 1 * time.Hour
    return pgxpool.NewWithConfig(ctx, config)
}

5. Adding tracing/logging/instrumentation:

type TracedInitializer struct{}
func (t *TracedInitializer) InitializeTestDatabase(ctx context.Context, dsn string) (any, error) {
    config, _ := pgxpool.ParseConfig(dsn)
    config.ConnConfig.Tracer = &MyQueryTracer{}
    return pgxpool.NewWithConfig(ctx, config)
}

Why Use a Custom Initializer?

The key benefit is that your tests use the SAME database type as your application code. If your app functions expect *gorm.DB, your tests should use *gorm.DB too. If your app uses a custom AppDB wrapper, your tests should use that wrapper. This ensures your tests accurately reflect real-world usage.

type Error

type Error struct {
	// Op is the operation that failed (e.g., "provider.Initialize").
	Op string

	// Err is the underlying error.
	Err error
}

Error represents a testdb error with operation context.

func (*Error) Error

func (e *Error) Error() string

func (*Error) Unwrap

func (e *Error) Unwrap() error

type InspectCommander

type InspectCommander interface {
	InspectCommand(dsn string) string
}

InspectCommander is implemented by providers that can say how to open a database in that database's own command-line client (psql, mysql, ...). The kept-database log line (see Keep and WithKeepOnFailure) shows that command; without it, it shows the DSN.

type Migrator

type Migrator interface {
	Migrate(ctx context.Context, dsn string) error
}

Migrator prepares a freshly created, empty database: it runs migrations, loads fixtures, or does whatever the tests need to find in place. It receives the DSN of the database to prepare.

Adapters for the common migration tools live in their own modules, so a project only depends on the tool it uses:

  • github.com/bashhack/testdb/migrate/goose
  • github.com/bashhack/testdb/migrate/tern
  • github.com/bashhack/testdb/migrate/golangmigrate

For plain SQL without a tool, see postgres.SQLFiles and postgres.LoadSQLFile. Template setup functions have the same shape (postgres.SetupFunc is a MigratorFunc), so one Migrator serves both per-test databases and templates.

type MigratorFunc

type MigratorFunc func(ctx context.Context, dsn string) error

MigratorFunc adapts a function to the Migrator interface.

func (MigratorFunc) Migrate

func (f MigratorFunc) Migrate(ctx context.Context, dsn string) error

Migrate calls f.

type Option

type Option func(*Config)

Option is a functional option for configuring test databases.

func WithAdminDSN

func WithAdminDSN(dsn string) Option

WithAdminDSN overrides the admin connection string. Use this when your database is not on localhost or uses non-default credentials.

Most users don't need this - the library automatically discovers the admin DSN from environment variables (TEST_DATABASE_URL, DATABASE_URL) or uses sensible defaults.

Example:

testdb.WithAdminDSN("postgres://user:pass@db.example.com:5432/postgres")

func WithDBPrefix

func WithDBPrefix(prefix string) Option

WithDBPrefix sets the database name prefix. Useful for identifying test databases in a shared environment.

Example:

testdb.WithDBPrefix("myapp_test")
// Results in database names like: myapp_test_1699564231_a1b2c3d4

func WithKeepOnFailure

func WithKeepOnFailure() Option

WithKeepOnFailure keeps a test's database when the test fails, instead of dropping it, and logs a psql command for it. Passing tests still have their databases dropped. Use it to look at the data a failing test left behind.

The TESTDB_KEEP_ON_FAILURE environment variable (1, true, yes or on) turns this on for a whole run without changing code:

TESTDB_KEEP_ON_FAILURE=1 go test ./... -run TestCheckout

Kept databases are yours to drop afterwards; WithDBPrefix makes them easy to spot. Leave this off in CI, where nobody is going to look and the databases would only pile up.

func WithMigrator

func WithMigrator(m Migrator) Option

WithMigrator sets how each new test database is prepared before the test gets it. Setup and New run the migrator right after creating the database.

Example:

pool := postgres.Setup(t, testdb.WithMigrator(goose.Dir("./migrations")))
pool := postgres.Setup(t, testdb.WithMigrator(postgres.SQLFiles("./schema")))

func WithTemplate

func WithTemplate(name string) Option

WithTemplate configures the test database to be created from a template database. This is significantly faster than creating a fresh database, especially for large schemas or databases with pre-seeded data.

The template database must already exist (see postgres.EnsureTemplate). If the template doesn't exist, database creation will fail.

Performance impact:

  • Fresh database: ~12-15ms
  • Template clone: ~3-5ms (3-5x faster)

Common use cases:

  • Large SQL dumps (fixtures) loaded once, cloned per test
  • Different fixture sets for different test suites (us_region, eu_region)
  • Testing migrations from specific schema versions

Example:

db := postgres.New(t, &postgres.PoolInitializer{},
    testdb.WithTemplate("us_region"))

func WithVerbose

func WithVerbose() Option

WithVerbose enables verbose logging of database operations. By default, testdb operates silently. Enable this for debugging.

Example:

testdb.WithVerbose()

type Provider

type Provider interface {
	// Initialize sets up the provider with admin credentials.
	// This establishes a connection to the admin/system database.
	Initialize(ctx context.Context, cfg Config) error

	// CreateDatabase creates a new database with the given name.
	CreateDatabase(ctx context.Context, name string) error

	// DropDatabase drops an existing database.
	DropDatabase(ctx context.Context, name string) error

	// TerminateConnections forcefully closes all connections to a database.
	// This is necessary before dropping a database.
	TerminateConnections(ctx context.Context, name string) error

	// BuildDSN constructs a connection string for the given database name.
	BuildDSN(dbName string) (string, error)

	// ResolvedAdminDSN returns the resolved admin DSN being used by this provider.
	// This is the actual DSN after resolving user overrides, environment variables, and defaults.
	// Useful for migrations and other operations that need admin credentials.
	ResolvedAdminDSN() string

	// Cleanup performs any necessary provider cleanup (e.g., closing admin connections).
	Cleanup(ctx context.Context) error
}

Provider defines database-specific operations that must be implemented for each supported database system (PostgreSQL, MySQL, SQLite, MongoDB).

This interface is typically implemented by database-specific packages and is not usually used directly by end users.

type TemplateProvider

type TemplateProvider interface {
	Provider

	// CreateDatabaseFromTemplate makes a database cloned from the template
	// and returns its name: requestedName, or a different one when the
	// provider had a clone ready in advance and cannot rename it. The
	// template database must already exist.
	CreateDatabaseFromTemplate(ctx context.Context, requestedName, templateName string) (actualName string, err error)

	// TemplateExists checks if a template database exists.
	// This is useful for debugging and conditional template creation.
	TemplateExists(ctx context.Context, name string) (bool, error)
}

TemplateProvider is implemented by providers whose database can clone one database from another, which is what templates (see WithTemplate) rely on. PostgreSQL has CREATE DATABASE ... TEMPLATE and implements it. A provider for a database without an equivalent implements only Provider, and New returns ErrTemplatesNotSupported if a template is requested with it.

type TestDatabase

type TestDatabase struct {
	// contains filtered or unexported fields
}

TestDatabase represents an isolated test database instance. It manages the complete lifecycle of a test database, including:

  • Creation and initialization
  • Migration management
  • Connection management
  • Cleanup and resource disposal

func New

func New(t testing.TB, provider Provider, initializer DBInitializer, opts ...Option) (*TestDatabase, error)

New creates a test database using the provided provider and optional initializer.

This is the low-level API for creating test databases. Most users should use the database-specific convenience functions instead (e.g., postgres.Setup()).

If initializer is nil, no automatic connection initialization is performed. The DSN field will still be available for manual connection setup.

Parameters:

  • t: Testing context for logging and cleanup
  • provider: Database-specific provider implementation
  • initializer: Optional custom initializer (can be nil)
  • opts: Configuration options

Returns:

  • *TestDatabase: Handle to manage the test database
  • error: Any errors during creation

Example:

provider := &postgres.PostgresProvider{}
initializer := &postgres.PoolInitializer{}
db, err := testdb.New(t, provider, initializer,
    testdb.WithMigrator(goose.Dir("./migrations")),
)
if err != nil {
    t.Fatal(err)
}
defer db.Close()

pool := db.Entity().(*pgxpool.Pool)

Creation uses t.Context(), so it is cancelled when the test times out.

func (*TestDatabase) Close

func (td *TestDatabase) Close() error

Close cleans up the test database and associated resources.

This method:

  1. Terminates all active connections to the database
  2. Drops the database
  3. Cleans up provider resources

When to call Close():

  • Manual cleanup is required when using the low-level testdb.New() API directly
  • Cleanup is AUTOMATIC when using database-specific Setup/New functions (e.g., postgres.Setup(), postgres.New()) - they register cleanup via t.Cleanup()

Example with low-level API (manual cleanup required):

db, err := testdb.New(t, provider, initializer)
if err != nil {
    t.Fatal(err)
}
defer db.Close()

Or with t.Cleanup:

db, err := testdb.New(t, provider, initializer)
if err != nil {
    t.Fatal(err)
}
t.Cleanup(func() { db.Close() })

Example with high-level API (automatic cleanup):

pool := postgres.Setup(t)  // Cleanup registered automatically
// No need to call Close() - handled by t.Cleanup()

func (*TestDatabase) Config

func (td *TestDatabase) Config() Config

Config returns the configuration used to create this database.

func (*TestDatabase) DSN

func (td *TestDatabase) DSN() string

DSN returns the connection string for this test database.

func (*TestDatabase) Entity

func (td *TestDatabase) Entity() any

Entity returns the initialized database entity. This is only available if a DBInitializer was provided to New().

Type assertions are required since the entity type depends on your DBInitializer.

Common usage (direct assertion - panics on type mismatch):

pool := db.Entity().(*pgxpool.Pool)
gormDB := db.Entity().(*gorm.DB)
sqlxDB := db.Entity().(*sqlx.DB)

Safe assertion (checks type before asserting):

entity := db.Entity()
pool, ok := entity.(*pgxpool.Pool)
if !ok {
    t.Fatalf("expected *pgxpool.Pool, got %T", entity)
}

Note: Since you control the DBInitializer, direct assertions are usually safe. Panics during test setup help catch initialization bugs early.

func (*TestDatabase) Keep

func (td *TestDatabase) Keep()

Keep makes Close leave the database in place instead of dropping it, and log a connection string for it. The provider's own resources are still released. postgres.Setup and postgres.New call this for a failed test when KeepOnFailure is set; with the low-level API you decide:

db, err := testdb.New(t, provider, nil)
...
if t.Failed() {
    db.Keep()
}
db.Close()

func (*TestDatabase) Name

func (td *TestDatabase) Name() string

Name returns the unique database name for this test database.

func (*TestDatabase) RunMigrations

func (td *TestDatabase) RunMigrations(ctx context.Context) error

RunMigrations runs the configured Migrator (see WithMigrator) against this database. postgres.Setup and postgres.New call it for you right after creating the database; it is exposed for the low-level testdb.New path, where you choose when it runs.

It returns ErrNoMigrator (wrapped) if no Migrator was configured. The database is not dropped on failure; call Close if you want that.

Example:

db, err := testdb.New(t, provider, initializer,
    testdb.WithMigrator(goose.Dir("./migrations")))
if err != nil {
    t.Fatal(err)
}
defer db.Close()

if err := db.RunMigrations(t.Context()); err != nil {
    t.Fatalf("migrations failed: %v", err)
}

Directories

Path Synopsis
migrate
tern module
Package mysql provides isolated MySQL databases for tests: each test gets its own database, created with CREATE DATABASE and dropped when the test ends, so tests can run in parallel against one server.
Package mysql provides isolated MySQL databases for tests: each test gets its own database, created with CREATE DATABASE and dropped when the test ends, so tests can run in parallel against one server.
internal/concurrencytest
Package concurrencytest is a test package that mysql's multi-process template tests compile and run as separate processes.
Package concurrencytest is a test package that mysql's multi-process template tests compile and run as separate processes.
Package postgres provides PostgreSQL test database support for testdb.
Package postgres provides PostgreSQL test database support for testdb.
internal/concurrencytest
Package concurrencytest is a test package that postgres's multi-process template tests compile and run as separate processes.
Package concurrencytest is a test package that postgres's multi-process template tests compile and run as separate processes.

Jump to

Keyboard shortcuts

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