From 6bf438d30f7dd656475f7bd87a8b85638eafdd38 Mon Sep 17 00:00:00 2001 From: Marc Alvarez Date: Wed, 21 Oct 2026 23:04:31 -0600 Subject: [PATCH] Keep a failed test's database on request Off by default, every test database is dropped. With testdb.WithKeepOnFailure(), or TESTDB_KEEP_ON_FAILURE=1 for a whole run, a failed test's database is left in place and the test log gives a psql command for it; passing tests still clean up. TestDatabase.Keep exposes the mechanism for the low-level API. Kept in the test's log rather than defaulting to keep (as pgtestdb does) so CI never accumulates databases nobody will look at. --- README.md | 17 +++++ config.go | 44 +++++++++++- keep_test.go | 115 ++++++++++++++++++++++++++++++++ postgres/keep_test.go | 137 ++++++++++++++++++++++++++++++++++++++ postgres/postgres.go | 5 ++ postgres/postgres_test.go | 3 + testdb.go | 43 +++++++++--- 7 files changed, 353 insertions(+), 11 deletions(-) create mode 100644 keep_test.go create mode 100644 postgres/keep_test.go diff --git a/README.md b/README.md index 4e67004..914520c 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,25 @@ Available options: - `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: + +```bash +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. 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 diff --git a/config.go b/config.go index b62353d..f17ace1 100644 --- a/config.go +++ b/config.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "os" + "strings" "time" ) @@ -58,6 +59,14 @@ type Config struct { // // 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 } // Migrator prepares a freshly created, empty database: it runs migrations, @@ -167,10 +176,43 @@ func WithVerbose() 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 WithKeepOnFailure() Option { + return func(c *Config) { + c.KeepOnFailure = true + } +} + +// KeepOnFailureEnv is the environment variable that turns on KeepOnFailure +// for a run. +const KeepOnFailureEnv = "TESTDB_KEEP_ON_FAILURE" + // DefaultConfig returns a Config with reasonable defaults. func DefaultConfig() Config { return Config{ - DBPrefix: "test", + DBPrefix: "test", + KeepOnFailure: envBool(KeepOnFailureEnv), + } +} + +// envBool reports whether an environment variable is set to a true-ish value. +func envBool(name string) bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(name))) { + case "1", "true", "yes", "on": + return true + default: + return false } } diff --git a/keep_test.go b/keep_test.go new file mode 100644 index 0000000..8b10c32 --- /dev/null +++ b/keep_test.go @@ -0,0 +1,115 @@ +package testdb + +import ( + "context" + "strings" + "testing" +) + +// countingProvider records drops so tests can tell whether Close dropped +// the database. +type countingProvider struct { + mockProvider + drops int + terminates int + cleanups int +} + +func (p *countingProvider) DropDatabase(ctx context.Context, name string) error { + p.drops++ + return p.mockProvider.DropDatabase(ctx, name) +} + +func (p *countingProvider) TerminateConnections(ctx context.Context, name string) error { + p.terminates++ + return p.mockProvider.TerminateConnections(ctx, name) +} + +func (p *countingProvider) Cleanup(ctx context.Context) error { + p.cleanups++ + return p.mockProvider.Cleanup(ctx) +} + +func TestCloseDropsByDefault(t *testing.T) { + provider := &countingProvider{} + db, err := New(t, provider, nil) + if err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + if provider.drops != 1 || provider.terminates != 1 || provider.cleanups != 1 { + t.Errorf("drops=%d terminates=%d cleanups=%d, want 1 each", + provider.drops, provider.terminates, provider.cleanups) + } +} + +func TestKeepLeavesDatabaseAndLogsHowToConnect(t *testing.T) { + provider := &countingProvider{} + spy := &verboseSpyTB{TB: t} + db, err := New(spy, provider, nil) + if err != nil { + t.Fatal(err) + } + + db.Keep() + if err := db.Close(); err != nil { + t.Fatal(err) + } + + if provider.drops != 0 || provider.terminates != 0 { + t.Errorf("kept database was dropped: drops=%d terminates=%d", provider.drops, provider.terminates) + } + if provider.cleanups != 1 { + t.Errorf("provider resources not released: cleanups=%d, want 1", provider.cleanups) + } + + var kept string + for _, msg := range spy.logs { + if strings.Contains(msg, "kept database") { + kept = msg + } + } + if kept == "" { + t.Fatalf("expected a 'kept database' log line, got %v", spy.logs) + } + if !strings.Contains(kept, db.Name()) || !strings.Contains(kept, "psql") || !strings.Contains(kept, db.DSN()) { + t.Errorf("kept-database log should name the database and give a psql command with its DSN, got: %s", kept) + } +} + +func TestDefaultConfigKeepOnFailureFromEnv(t *testing.T) { + tests := map[string]bool{ + "": false, + "0": false, + "false": false, + "no": false, + "1": true, + "true": true, + "TRUE": true, + "yes": true, + "on": true, + " 1 ": true, + } + for value, want := range tests { + t.Run("value="+strings.TrimSpace(value), func(t *testing.T) { + t.Setenv(KeepOnFailureEnv, value) + if got := DefaultConfig().KeepOnFailure; got != want { + t.Errorf("%s=%q: KeepOnFailure = %v, want %v", KeepOnFailureEnv, value, got, want) + } + }) + } +} + +func TestWithKeepOnFailure(t *testing.T) { + t.Setenv(KeepOnFailureEnv, "") + cfg := DefaultConfig() + if cfg.KeepOnFailure { + t.Fatal("KeepOnFailure should be off by default") + } + WithKeepOnFailure()(&cfg) + if !cfg.KeepOnFailure { + t.Fatal("WithKeepOnFailure should turn KeepOnFailure on") + } +} diff --git a/postgres/keep_test.go b/postgres/keep_test.go new file mode 100644 index 0000000..21e3896 --- /dev/null +++ b/postgres/keep_test.go @@ -0,0 +1,137 @@ +package postgres_test + +import ( + "context" + "strings" + "testing" + + "github.com/bashhack/testdb" + "github.com/bashhack/testdb/postgres" + "github.com/jackc/pgx/v5/pgxpool" +) + +// currentDatabase returns the name of the database a pool is connected to. +func currentDatabase(t *testing.T, pool *pgxpool.Pool) string { + t.Helper() + var name string + if err := pool.QueryRow(context.Background(), "SELECT current_database()").Scan(&name); err != nil { + t.Fatalf("current_database: %v", err) + } + return name +} + +// dropDatabase removes a database a test deliberately left behind. +func dropDatabase(t *testing.T, name string) { + t.Helper() + ctx := context.Background() + provider := &postgres.PostgresProvider{} + if err := provider.Initialize(ctx, testdb.Config{}); err != nil { + t.Fatalf("initialize provider: %v", err) + } + defer func() { + if err := provider.Cleanup(ctx); err != nil { + t.Errorf("provider cleanup: %v", err) + } + }() + if err := provider.TerminateConnections(ctx, name); err != nil { + t.Errorf("terminate connections to %s: %v", name, err) + } + if err := provider.DropDatabase(ctx, name); err != nil { + t.Errorf("drop %s: %v", name, err) + } +} + +func TestKeepOnFailureKeepsFailedTestDatabase(t *testing.T) { + spy := &spyTB{TB: t} + pool := postgres.Setup(spy, testdb.WithKeepOnFailure(), testdb.WithDBPrefix("keep_test")) + name := currentDatabase(t, pool) + // Make sure the database is gone even if an assertion below fails + t.Cleanup(func() { + if exists, _ := postgres.TemplateExists(context.Background(), name); exists { + dropDatabase(t, name) + } + }) + + spy.failed = true // the test under observation fails + spy.runCleanups() + + exists, err := postgres.TemplateExists(context.Background(), name) + if err != nil { + t.Fatal(err) + } + if !exists { + t.Fatalf("database %s was dropped although the test failed with KeepOnFailure", name) + } + + var kept string + for _, msg := range spy.logMessages { + if strings.Contains(msg, "kept database") { + kept = msg + } + } + if kept == "" { + t.Fatalf("expected a 'kept database' log line, got %v", spy.logMessages) + } + if !strings.Contains(kept, name) || !strings.Contains(kept, "psql") { + t.Errorf("log should name the database and give a psql command, got: %s", kept) + } +} + +func TestKeepOnFailureDropsPassingTestDatabase(t *testing.T) { + spy := &spyTB{TB: t} + pool := postgres.Setup(spy, testdb.WithKeepOnFailure()) + name := currentDatabase(t, pool) + + spy.runCleanups() // the test under observation passed + + exists, err := postgres.TemplateExists(context.Background(), name) + if err != nil { + t.Fatal(err) + } + if exists { + dropDatabase(t, name) + t.Fatalf("database %s of a passing test was kept", name) + } +} + +func TestFailedTestDatabaseIsDroppedByDefault(t *testing.T) { + t.Setenv(testdb.KeepOnFailureEnv, "") + spy := &spyTB{TB: t} + pool := postgres.Setup(spy) + name := currentDatabase(t, pool) + + spy.failed = true + spy.runCleanups() + + exists, err := postgres.TemplateExists(context.Background(), name) + if err != nil { + t.Fatal(err) + } + if exists { + dropDatabase(t, name) + t.Fatalf("database %s was kept without KeepOnFailure", name) + } +} + +func TestKeepOnFailureFromEnvironment(t *testing.T) { + t.Setenv(testdb.KeepOnFailureEnv, "1") + spy := &spyTB{TB: t} + pool := postgres.Setup(spy, testdb.WithDBPrefix("keep_env_test")) + name := currentDatabase(t, pool) + t.Cleanup(func() { + if exists, _ := postgres.TemplateExists(context.Background(), name); exists { + dropDatabase(t, name) + } + }) + + spy.failed = true + spy.runCleanups() + + exists, err := postgres.TemplateExists(context.Background(), name) + if err != nil { + t.Fatal(err) + } + if !exists { + t.Fatalf("database %s was dropped although %s=1 and the test failed", name, testdb.KeepOnFailureEnv) + } +} diff --git a/postgres/postgres.go b/postgres/postgres.go index 1e272b4..d6b4d9f 100644 --- a/postgres/postgres.go +++ b/postgres/postgres.go @@ -330,6 +330,7 @@ func runMigrationsIfConfigured(t testing.TB, db *testdb.TestDatabase, callerName } // registerCleanup registers cleanup that closes the connection pool before dropping the database. +// With KeepOnFailure, a failed test's database is kept for inspection instead. func registerCleanup(t testing.TB, db *testdb.TestDatabase) { t.Cleanup(func() { // Close the pool/connection if it implements io.Closer @@ -341,6 +342,10 @@ func registerCleanup(t testing.TB, db *testdb.TestDatabase) { } } + if db.Config().KeepOnFailure && t.Failed() { + db.Keep() + } + // Drop database (TerminateConnections handles any remaining connections) if err := db.Close(); err != nil { t.Errorf("testdb cleanup failed: %v", err) diff --git a/postgres/postgres_test.go b/postgres/postgres_test.go index 403e5fe..383a5c1 100644 --- a/postgres/postgres_test.go +++ b/postgres/postgres_test.go @@ -1447,6 +1447,9 @@ func (s *spyTB) Logf(format string, args ...any) { func (s *spyTB) Helper() {} +// Failed reports the spy's own failure state, not the enclosing test's +func (s *spyTB) Failed() bool { return s.failed } + func (s *spyTB) Cleanup(f func()) { s.cleanups = append(s.cleanups, f) } diff --git a/testdb.go b/testdb.go index fbd76de..6ee0b19 100644 --- a/testdb.go +++ b/testdb.go @@ -72,6 +72,9 @@ type TestDatabase struct { // cleanup is the function called by Close() to clean up resources. cleanup func() error + // keep makes Close leave the database in place; see Keep. + keep bool + // t is the testing context for logging. t testingHelper @@ -358,17 +361,22 @@ func New(t testing.TB, provider Provider, initializer DBInitializer, opts ...Opt } td.cleanup = func() error { - if err := provider.TerminateConnections(cleanupCtx, dbName); err != nil { - return &Error{ - Op: "provider.TerminateConnections", - Err: err, + if td.keep { + // Always worth saying, since someone asked to look at it + t.Logf("testdb: kept database %s for inspection:\n psql %q", dbName, testDSN) + } else { + if err := provider.TerminateConnections(cleanupCtx, dbName); err != nil { + return &Error{ + Op: "provider.TerminateConnections", + Err: err, + } } - } - if err := provider.DropDatabase(cleanupCtx, dbName); err != nil { - return &Error{ - Op: "provider.DropDatabase", - Err: err, + if err := provider.DropDatabase(cleanupCtx, dbName); err != nil { + return &Error{ + Op: "provider.DropDatabase", + Err: err, + } } } @@ -379,7 +387,7 @@ func New(t testing.TB, provider Provider, initializer DBInitializer, opts ...Opt } } - if cfg.Verbose { + if cfg.Verbose && !td.keep { t.Logf("testdb: dropped database %s", dbName) } return nil @@ -431,6 +439,21 @@ func (td *TestDatabase) logf(format string, args ...any) { } } +// 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 (td *TestDatabase) Keep() { + td.keep = true +} + // Close cleans up the test database and associated resources. // // This method: