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/postgres"
)
func TestUsers(t *testing.T) {
pool := postgres.Setup(t,
testdb.WithMigrations("./migrations"),
testdb.WithMigrationTool(testdb.MigrationToolTern))
// 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.
package main
import (
"context"
"fmt"
"testing"
"github.com/bashhack/testdb"
"github.com/bashhack/testdb/postgres"
)
func main() {
t := &testing.T{}
provider := &postgres.PostgresProvider{}
initializer := &exampleInitializer{}
db, err := testdb.New(t, provider, initializer,
testdb.WithMigrations("./testdata/postgres/migrations_migrate"),
testdb.WithMigrationTool(testdb.MigrationToolMigrate),
)
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")
}
// exampleInitializer is a custom initializer for demonstration.
type exampleInitializer struct{}
func (e *exampleInitializer) InitializeTestDatabase(ctx context.Context, dsn string) (any, error) {
return nil, nil
}
Output: Custom initializer setup complete
Example (DsnOnly) ¶
Example_dsnOnly demonstrates using testdb to get just a DSN without automatic connection initialization.
package main
import (
"fmt"
"testing"
"github.com/bashhack/testdb"
"github.com/bashhack/testdb/postgres"
)
func main() {
t := &testing.T{}
provider := &postgres.PostgresProvider{}
// Pass nil initializer to skip connection initialization
db, err := testdb.New(t, provider, nil,
testdb.WithMigrations("./testdata/postgres/migrations_migrate"),
testdb.WithMigrationTool(testdb.MigrationToolMigrate),
)
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.
package main
import (
"fmt"
"testing"
"github.com/bashhack/testdb"
"github.com/bashhack/testdb/postgres"
)
func main() {
t := &testing.T{}
provider := &postgres.PostgresProvider{}
initializer := &postgres.PoolInitializer{}
db, err := testdb.New(t, provider, initializer,
testdb.WithMigrations("./testdata/postgres/migrations_migrate"),
testdb.WithMigrationTool(testdb.MigrationToolMigrate),
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(); err != nil {
fmt.Printf("Migration error: %v\n", err)
return
}
fmt.Println("Migrations completed")
}
Output: Database created successfully Migrations completed
Index ¶
Examples ¶
Constants ¶
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 ¶
var ( // ErrNilProvider is returned when a nil provider is passed to New(). ErrNilProvider = errors.New("provider cannot be nil") // ErrNoMigrationDir is returned when RunMigrations is called without a migration directory. ErrNoMigrationDir = errors.New("migration directory not set") // ErrUnknownMigrationTool is returned when an unknown migration tool is configured. ErrUnknownMigrationTool = errors.New("unknown migration tool") // ErrMigrationToolWithoutDir is returned when a migration tool is specified without a directory. ErrMigrationToolWithoutDir = errors.New("migration tool specified but migration directory not set") // ErrMigrationDirWithoutTool is returned when a migration directory is specified without a tool. ErrMigrationDirWithoutTool = errors.New("migration directory specified but migration tool not set") // 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 ResolveAdminDSN ¶
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:
- cfg.AdminDSNOverride (explicit user override via WithAdminDSN)
- TEST_DATABASE_URL environment variable
- DATABASE_URL environment variable
- defaultDSN (database-specific default)
Example usage in provider initialization:
adminDSN := testdb.ResolveAdminDSN(cfg, "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable")
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. TEST_DATABASE_URL environment variable
// 2. DATABASE_URL environment variable
// 3. 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
// MigrationDir is the absolute or relative path to migration files.
// If set, MigrationTool must also be set (and vice versa).
//
// Example: "./migrations" or "/path/to/project/migrations"
MigrationDir string
// MigrationTool specifies which migration tool to use.
// Supported: "tern", "goose", "migrate"
// If set, MigrationDir must also be set (and vice versa).
//
// Example: testdb.MigrationToolTern, testdb.MigrationToolGoose, testdb.MigrationToolMigrate
MigrationTool MigrationTool
// MigrationToolPath is the path to the migration tool binary.
// If empty, the tool is assumed to be in PATH.
//
// Example: "/usr/local/bin/tern"
MigrationToolPath string
// 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
// 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
}
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.
type MigrationTool ¶
type MigrationTool string
MigrationTool represents supported database migration tools.
const ( // MigrationToolTern represents the 'tern' migration tool. // External dependency: Must be installed separately and available in PATH. // See: https://github.com/jackc/tern // PostgreSQL only. MigrationToolTern MigrationTool = "tern" // MigrationToolGoose represents the 'goose' migration tool. // External dependency: Must be installed separately and available in PATH. // See: https://github.com/pressly/goose // Supports PostgreSQL, MySQL, SQLite. MigrationToolGoose MigrationTool = "goose" // MigrationToolMigrate represents the 'golang-migrate/migrate' migration tool. // External dependency: Must be installed separately and available in PATH. // See: https://github.com/golang-migrate/migrate // Supports PostgreSQL, MySQL, SQLite, MongoDB, and many others. MigrationToolMigrate MigrationTool = "migrate" )
type Option ¶
type Option func(*Config)
Option is a functional option for configuring test databases.
func WithAdminDSN ¶
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 ¶
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 WithMigrationTool ¶
func WithMigrationTool(tool MigrationTool) Option
WithMigrationTool sets the migration tool to use. You must also set WithMigrations() when using this option. Valid values: testdb.MigrationToolTern, testdb.MigrationToolGoose, testdb.MigrationToolMigrate
Example:
testdb.WithMigrationTool(testdb.MigrationToolGoose) testdb.WithMigrationTool(testdb.MigrationToolMigrate)
func WithMigrationToolPath ¶
WithMigrationToolPath sets the path to the migration tool binary. Use this if the tool is not in your PATH.
Example:
testdb.WithMigrationToolPath("/usr/local/bin/goose")
func WithMigrations ¶
WithMigrations sets the migration directory. The directory should contain your migration files. You must also set WithMigrationTool() when using this option.
Example:
testdb.WithMigrations("./migrations")
testdb.WithMigrations("../../db/migrations")
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 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.WithMigrations("./migrations"),
testdb.WithMigrationTool(testdb.MigrationToolTern),
)
if err != nil {
t.Fatal(err)
}
defer db.Close()
pool := db.Entity().(*pgxpool.Pool)
func (*TestDatabase) Close ¶
func (td *TestDatabase) Close() error
Close cleans up the test database and associated resources.
This method:
- Terminates all active connections to the database
- Drops the database
- 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) Name ¶
func (td *TestDatabase) Name() string
Name returns the unique database name for this test database.
func (*TestDatabase) RunMigrations ¶
func (td *TestDatabase) RunMigrations() error
RunMigrations executes database migrations using the configured migration tool. The migration directory and tool must both be set via WithMigrations() and WithMigrationTool() options.
Supported migration tools:
- Tern (github.com/jackc/tern) - PostgreSQL only
- Goose (github.com/pressly/goose) - PostgreSQL, MySQL, SQLite
- golang-migrate (github.com/golang-migrate/migrate) - PostgreSQL, MySQL, SQLite, MongoDB, and more
The migration tool binary must be available in PATH or specified via WithMigrationToolPath() option.
Returns an error if migrations fail. The database is NOT automatically cleaned up on migration failure - call Close() manually if needed.
Example:
db, err := testdb.New(t, provider, initializer,
testdb.WithMigrations("./migrations"),
testdb.WithMigrationTool(testdb.MigrationToolTern),
)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := db.RunMigrations(); err != nil {
t.Fatalf("migrations failed: %v", err)
}