// Package pgtest runs the Postgres the test suite needs: one throwaway // container per test binary, one fresh database per test. Docker is therefore // a hard prerequisite for `go test ./...`. // // Rolled by hand rather than pulled in as a dependency — it is one `docker // run`, one `docker port` and a ping loop, against a module list that is // otherwise stdlib plus what the poller genuinely needs. package pgtest import ( "database/sql" "fmt" "os/exec" "strconv" "strings" "sync/atomic" "testing" "time" _ "github.com/jackc/pgx/v5/stdlib" ) const ( image = "postgres:17-alpine" readyLimit = 60 * time.Second ) var ( adminURL string dbSeq atomic.Int64 ) // Main starts the container, runs the package's tests and tears the container // down. Every test package that touches the store calls it from TestMain: // // func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) } func Main(m *testing.M) int { id, url, err := start() if err != nil { fmt.Println("pgtest:", err) return 1 } // -v: the postgres image declares a VOLUME, so an explicit rm without it // leaves the anonymous data volume behind on every test run. defer exec.Command("docker", "rm", "-f", "-v", id).Run() adminURL = url return m.Run() } // URL creates a database of its own for t and returns a connection URL for it. // Nothing drops it again: the container goes away wholesale when Main returns. func URL(t testing.TB) string { t.Helper() if adminURL == "" { t.Fatal("pgtest: no container; this package needs TestMain to call pgtest.Main") } // Generated, never derived from the test name, so it needs no quoting and // cannot collide when tests run in parallel. name := "test_" + strconv.FormatInt(dbSeq.Add(1), 10) admin, err := sql.Open("pgx", adminURL) if err != nil { t.Fatalf("pgtest: open admin connection: %v", err) } defer admin.Close() if _, err := admin.Exec(`CREATE DATABASE ` + name); err != nil { t.Fatalf("pgtest: create database %s: %v", name, err) } return strings.Replace(adminURL, "/postgres?", "/"+name+"?", 1) } // start launches the container and waits for it to accept queries, returning // its id and a connection URL for the default database. func start() (id, url string, err error) { out, err := exec.Command("docker", "run", "-d", "--rm", "-e", "POSTGRES_PASSWORD=pgtest", "-P", image, // Durability buys nothing for a database that dies with the test // binary, and turning it off is most of the container's start-up cost. "-c", "fsync=off", "-c", "full_page_writes=off", ).Output() if err != nil { return "", "", fmt.Errorf("docker run %s: %w", image, err) } id = strings.TrimSpace(string(out)) port, err := exec.Command("docker", "port", id, "5432/tcp").Output() if err != nil { exec.Command("docker", "rm", "-f", "-v", id).Run() return "", "", fmt.Errorf("docker port: %w", err) } // "0.0.0.0:32768" (and possibly a second, IPv6 line); the port is all we want. first, _, _ := strings.Cut(strings.TrimSpace(string(port)), "\n") url = fmt.Sprintf("postgres://postgres:pgtest@127.0.0.1:%s/postgres?sslmode=disable", first[strings.LastIndex(first, ":")+1:]) if err := waitReady(url); err != nil { exec.Command("docker", "rm", "-f", "-v", id).Run() return "", "", err } return id, url, nil } func waitReady(url string) error { db, err := sql.Open("pgx", url) if err != nil { return err } defer db.Close() deadline := time.Now().Add(readyLimit) for { if err = db.Ping(); err == nil { return nil } if time.Now().After(deadline) { return fmt.Errorf("postgres not ready after %s: %w", readyLimit, err) } time.Sleep(200 * time.Millisecond) } }