From 22accc5c2beb31ca1d1c929ed53c83f93e07cf0e Mon Sep 17 00:00:00 2001 From: Marc Alvarez Date: Wed, 21 Oct 2026 23:28:55 -0600 Subject: [PATCH] Make template support an optional provider interface CreateDatabaseFromTemplate and TemplateExists move from Provider into TemplateProvider. PostgreSQL implements both; a provider for a database with no way to clone one database from another implements Provider alone, and New returns ErrTemplatesNotSupported, naming the provider, before touching the server if WithTemplate is used with it. ResolveAdminDSN takes the provider's own environment variables, tried before the shared TEST_DATABASE_URL and DATABASE_URL, so a project that tests against two database servers can point each provider at its own. PostgreSQL's is TEST_POSTGRES_URL. --- README.md | 2 + config.go | 31 ++++++--- postgres/admin_dsn_test.go | 54 +++++++++++++++ postgres/postgres.go | 19 ++++-- provider_test.go | 134 +++++++++++++++++++++++++++++++++++++ testdb.go | 46 +++++++++---- 6 files changed, 260 insertions(+), 26 deletions(-) create mode 100644 postgres/admin_dsn_test.go create mode 100644 provider_test.go diff --git a/README.md b/README.md index 914520c..00a0dc1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config.go b/config.go index f17ace1..cbd2e85 100644 --- a/config.go +++ b/config.go @@ -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. @@ -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 } @@ -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") ) diff --git a/postgres/admin_dsn_test.go b/postgres/admin_dsn_test.go new file mode 100644 index 0000000..a3b3c3a --- /dev/null +++ b/postgres/admin_dsn_test.go @@ -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) + } +} diff --git a/postgres/postgres.go b/postgres/postgres.go index d6b4d9f..f4d2972 100644 --- a/postgres/postgres.go +++ b/postgres/postgres.go @@ -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 @@ -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 diff --git a/provider_test.go b/provider_test.go new file mode 100644 index 0000000..a2b2c89 --- /dev/null +++ b/provider_test.go @@ -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) + } + }) + } +} diff --git a/testdb.go b/testdb.go index 6ee0b19..67692d9 100644 --- a/testdb.go +++ b/testdb.go @@ -48,6 +48,7 @@ package testdb import ( "context" "errors" + "fmt" "testing" ) @@ -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 @@ -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 @@ -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. @@ -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,