Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ 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. The full order is `WithAdminDSN`, the provider's variable, `TEST_DATABASE_URL`, `DATABASE_URL`, then the default.

### Options

```go
Expand Down
31 changes: 23 additions & 8 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ type Config struct {
// 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)
// 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.
Expand Down Expand Up @@ -242,17 +243,27 @@ func discoverAdminDSN() string {
//
// Resolution order:
// 1. cfg.AdminDSNOverride (explicit user override via WithAdminDSN)
// 2. TEST_DATABASE_URL environment variable
// 3. DATABASE_URL environment variable
// 4. defaultDSN (database-specific default)
// 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")
func ResolveAdminDSN(cfg Config, defaultDSN string) string {
// adminDSN := testdb.ResolveAdminDSN(cfg,
// "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable",
// "TEST_POSTGRES_URL")
func ResolveAdminDSN(cfg Config, defaultDSN string, providerEnv ...string) string {
if cfg.AdminDSNOverride != "" {
return cfg.AdminDSNOverride
}
for _, name := range providerEnv {
if dsn := os.Getenv(name); dsn != "" {
return dsn
}
}
if discovered := discoverAdminDSN(); discovered != "" {
return discovered
}
Expand Down Expand Up @@ -306,6 +317,10 @@ var (
// 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")
)
Expand Down
54 changes: 54 additions & 0 deletions postgres/admin_dsn_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package postgres_test

import (
"context"
"testing"

"github.com/bashhack/testdb"
"github.com/bashhack/testdb/postgres"
)

// An address nothing listens on, so Initialize fails fast after resolving
// the DSN, which is the part under test.
const unreachableDSN = "postgres://u:p@127.0.0.1:1/postgres?sslmode=disable&connect_timeout=1"

func TestAdminDSNEnvPrecedence(t *testing.T) {
t.Setenv(postgres.AdminDSNEnv, unreachableDSN)
t.Setenv("TEST_DATABASE_URL", "postgres://shared/should-lose")
t.Setenv("DATABASE_URL", "postgres://generic/should-lose")

provider := &postgres.PostgresProvider{}
err := provider.Initialize(context.Background(), testdb.Config{})
if err == nil {
t.Cleanup(func() {
if err := provider.Cleanup(context.Background()); err != nil {
t.Error(err)
}
})
t.Fatal("expected the connection to the unreachable address to fail")
}
if got := provider.ResolvedAdminDSN(); got != unreachableDSN {
t.Errorf("resolved %q, want %s to win: %q", got, postgres.AdminDSNEnv, unreachableDSN)
}
}

func TestAdminDSNDefault(t *testing.T) {
t.Setenv(postgres.AdminDSNEnv, "")
t.Setenv("TEST_DATABASE_URL", "")
t.Setenv("DATABASE_URL", "")

provider := &postgres.PostgresProvider{}
err := provider.Initialize(context.Background(), testdb.Config{})
if err == nil {
t.Cleanup(func() {
if err := provider.Cleanup(context.Background()); err != nil {
t.Error(err)
}
})
}
// Whether or not a server answers at the default address, the DSN
// chosen must be the documented default
if got := provider.ResolvedAdminDSN(); got != postgres.DefaultAdminDSN {
t.Errorf("resolved %q, want the default %q", got, postgres.DefaultAdminDSN)
}
}
19 changes: 15 additions & 4 deletions postgres/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,20 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
)

// PostgresProvider implements testdb.Provider for PostgreSQL.
// It handles database creation, deletion, and connection management.
const (
// AdminDSNEnv is the environment variable this provider checks first for
// its admin connection string, before the shared TEST_DATABASE_URL and
// DATABASE_URL. Set it when a project tests against more than one
// database server.
AdminDSNEnv = "TEST_POSTGRES_URL"

// DefaultAdminDSN is used when no override or environment variable is set.
DefaultAdminDSN = "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"
)

// PostgresProvider implements testdb.Provider and testdb.TemplateProvider
// for PostgreSQL. It handles database creation, deletion, template cloning
// and connection management.
type PostgresProvider struct {
conn *pgx.Conn
adminDSN string // Store the admin DSN for use in migrations
Expand All @@ -130,8 +142,7 @@ type PoolInitializer struct {
// It establishes a connection to the admin database which will be used
// for creating and managing test databases.
func (p *PostgresProvider) Initialize(ctx context.Context, cfg testdb.Config) error {
const defaultPostgresDSN = "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"
adminDSN := testdb.ResolveAdminDSN(cfg, defaultPostgresDSN)
adminDSN := testdb.ResolveAdminDSN(cfg, DefaultAdminDSN, AdminDSNEnv)

// Store the admin DSN for later use (e.g., migrations)
p.adminDSN = adminDSN
Expand Down
134 changes: 134 additions & 0 deletions provider_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package testdb

import (
"context"
"errors"
"strings"
"testing"
)

// plainProvider implements Provider but not TemplateProvider, standing in
// for a database that cannot clone one database from another. It delegates
// to a mockProvider by hand so no template method gets promoted.
type plainProvider struct {
inner mockProvider
initialized bool
}

func (p *plainProvider) Initialize(ctx context.Context, cfg Config) error {
p.initialized = true
return p.inner.Initialize(ctx, cfg)
}
func (p *plainProvider) CreateDatabase(ctx context.Context, name string) error {
return p.inner.CreateDatabase(ctx, name)
}
func (p *plainProvider) DropDatabase(ctx context.Context, name string) error {
return p.inner.DropDatabase(ctx, name)
}
func (p *plainProvider) TerminateConnections(ctx context.Context, name string) error {
return p.inner.TerminateConnections(ctx, name)
}
func (p *plainProvider) BuildDSN(dbName string) (string, error) { return p.inner.BuildDSN(dbName) }
func (p *plainProvider) ResolvedAdminDSN() string { return p.inner.ResolvedAdminDSN() }
func (p *plainProvider) Cleanup(ctx context.Context) error { return p.inner.Cleanup(ctx) }

// Compile-time statements of which interfaces the test doubles satisfy.
var (
_ Provider = (*plainProvider)(nil)
_ TemplateProvider = (*mockProvider)(nil)
)

func TestNewWithTemplateNeedsTemplateProvider(t *testing.T) {
provider := &plainProvider{}

_, err := New(t, provider, nil, WithTemplate("us_region"))
if !errors.Is(err, ErrTemplatesNotSupported) {
t.Fatalf("expected ErrTemplatesNotSupported, got %v", err)
}
var testErr *Error
if !errors.As(err, &testErr) || testErr.Op != "testdb.New" {
t.Errorf("expected *Error with Op testdb.New, got %#v", err)
}
if !strings.Contains(err.Error(), "plainProvider") {
t.Errorf("error should name the provider type, got: %v", err)
}
if provider.initialized {
t.Error("New should refuse before initializing the provider")
}
}

func TestNewWithoutTemplateAcceptsPlainProvider(t *testing.T) {
provider := &plainProvider{}

db, err := New(t, provider, nil)
if err != nil {
t.Fatalf("plain provider without a template should work, got %v", err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
}

func TestResolveAdminDSN(t *testing.T) {
const (
override = "postgres://override/db"
specific = "postgres://specific/db"
specific2 = "postgres://specific2/db"
shared = "postgres://shared/db"
generic = "postgres://generic/db"
fallback = "postgres://default/db"
)

tests := map[string]struct {
cfg Config
env map[string]string
args []string
want string
}{
"override beats everything": {
cfg: Config{AdminDSNOverride: override},
env: map[string]string{"TEST_X_URL": specific, "TEST_DATABASE_URL": shared, "DATABASE_URL": generic},
args: []string{"TEST_X_URL"},
want: override,
},
"provider variable beats the shared ones": {
env: map[string]string{"TEST_X_URL": specific, "TEST_DATABASE_URL": shared, "DATABASE_URL": generic},
args: []string{"TEST_X_URL"},
want: specific,
},
"provider variables are tried in order": {
env: map[string]string{"TEST_Y_URL": specific2, "TEST_DATABASE_URL": shared},
args: []string{"TEST_X_URL", "TEST_Y_URL"},
want: specific2,
},
"TEST_DATABASE_URL beats DATABASE_URL": {
env: map[string]string{"TEST_DATABASE_URL": shared, "DATABASE_URL": generic},
args: []string{"TEST_X_URL"},
want: shared,
},
"DATABASE_URL when nothing else": {
env: map[string]string{"DATABASE_URL": generic},
args: []string{"TEST_X_URL"},
want: generic,
},
"default when no variable is set": {
args: []string{"TEST_X_URL"},
want: fallback,
},
"no provider variables given": {
env: map[string]string{"TEST_DATABASE_URL": shared},
want: shared,
},
}

for name, tc := range tests {
t.Run(name, func(t *testing.T) {
for _, v := range []string{"TEST_X_URL", "TEST_Y_URL", "TEST_DATABASE_URL", "DATABASE_URL"} {
t.Setenv(v, tc.env[v])
}
if got := ResolveAdminDSN(tc.cfg, fallback, tc.args...); got != tc.want {
t.Errorf("ResolveAdminDSN = %q, want %q", got, tc.want)
}
})
}
}
46 changes: 32 additions & 14 deletions testdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ package testdb
import (
"context"
"errors"
"fmt"
"testing"
)

Expand Down Expand Up @@ -114,22 +115,9 @@ type Provider interface {
// CreateDatabase creates a new database with the given name.
CreateDatabase(ctx context.Context, name string) error

// CreateDatabaseFromTemplate creates a new database by cloning a template.
// This is significantly faster than CreateDatabase for databases with large schemas
// or pre-seeded data. The template database must already exist.
//
// Providers that don't support templates should return an error.
CreateDatabaseFromTemplate(ctx context.Context, name, templateName string) error

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

// TemplateExists checks if a template database exists.
// This is useful for debugging and conditional template creation.
//
// Providers that don't support templates should return false, nil.
TemplateExists(ctx context.Context, name string) (bool, error)

// TerminateConnections forcefully closes all connections to a database.
// This is necessary before dropping a database.
TerminateConnections(ctx context.Context, name string) error
Expand All @@ -146,6 +134,24 @@ type Provider interface {
Cleanup(ctx context.Context) 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 TemplateProvider interface {
Provider

// CreateDatabaseFromTemplate creates a new database by cloning a template.
// This is significantly faster than CreateDatabase for databases with large schemas
// or pre-seeded data. The template database must already exist.
CreateDatabaseFromTemplate(ctx context.Context, name, templateName string) 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)
}

// DBInitializer defines the interface for custom database initialization in tests.
//
// # When You Need a Custom Initializer
Expand Down Expand Up @@ -300,6 +306,17 @@ func New(t testing.TB, provider Provider, initializer DBInitializer, opts ...Opt
}
}

// Decide this before touching the server: a template on a provider that
// cannot clone is a configuration error, not a connection problem.
if cfg.TemplateName != "" {
if _, ok := provider.(TemplateProvider); !ok {
return nil, &Error{
Op: "testdb.New",
Err: fmt.Errorf("%w by %T", ErrTemplatesNotSupported, provider),
}
}
}

ctx := ContextOf(t)
// t.Context() is already cancelled by the time t.Cleanup functions run,
// so cleanup must not use it.
Expand Down Expand Up @@ -329,7 +346,8 @@ func New(t testing.TB, provider Provider, initializer DBInitializer, opts ...Opt

// Use template if specified, otherwise create fresh database
if cfg.TemplateName != "" {
if err := provider.CreateDatabaseFromTemplate(ctx, dbName, cfg.TemplateName); err != nil {
// Checked above
if err := provider.(TemplateProvider).CreateDatabaseFromTemplate(ctx, dbName, cfg.TemplateName); err != nil {
return nil, &Error{
Op: "provider.CreateDatabaseFromTemplate",
Err: err,
Expand Down
Loading