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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 43 additions & 1 deletion config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"os"
"strings"
"time"
)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
}

Expand Down
115 changes: 115 additions & 0 deletions keep_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
137 changes: 137 additions & 0 deletions postgres/keep_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 5 additions & 0 deletions postgres/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions postgres/postgres_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading