From 233b68375f8d92430191e7272d1480220639dc8a Mon Sep 17 00:00:00 2001 From: Marc Alvarez Date: Thu, 22 Oct 2026 00:43:19 -0600 Subject: [PATCH 1/2] Refresh action pins; add MySQL examples checkout, setup-go, cache and upload-artifact move from their May 2025 commits to the current releases (v7.0.1, v7.0.0, v6.1.0, v7.0.2). mysql/example_test.go shows the MySQL API: Setup, a custom initializer, a template in TestMain, keep-on-failure. The examples are compile-checked rather than run, since a runnable example cannot skip when no MySQL server is reachable, and this module's MySQL tests skip rather than fail on a machine that only runs PostgreSQL. --- .github/workflows/ci.yml | 8 ++-- mysql/example_test.go | 101 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 mysql/example_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5037f2b..eb28da6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,18 +48,18 @@ jobs: - 3306:3306 steps: - - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e with: go-version: ${{ matrix.go-version }} cache: true - name: Cache Go tools - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: path: ~/go/bin key: ${{ runner.os }}-go-tools-${{ hashFiles('.github/workflows/ci.yml') }} @@ -93,7 +93,7 @@ jobs: GOMAXPROCS=$RACE_CORES go test -race github.com/bashhack/testdb/... - name: Upload coverage report - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 with: name: go-coverage-report-${{ matrix.os }} path: ./coverage.html diff --git a/mysql/example_test.go b/mysql/example_test.go new file mode 100644 index 0000000..60476f4 --- /dev/null +++ b/mysql/example_test.go @@ -0,0 +1,101 @@ +package mysql_test + +import ( + "context" + "database/sql" + "fmt" + "log" + "os" + "testing" + + "github.com/bashhack/testdb" + "github.com/bashhack/testdb/mysql" +) + +// These examples show the MySQL API, which mirrors postgres's. They are +// compile-checked but not run: a runnable example cannot skip when no +// MySQL server is reachable, and the MySQL tests in this module skip +// rather than fail on a machine that only runs PostgreSQL. + +// Example_setup is the common case: an isolated database with the schema +// applied, cleaned up when the test ends. +func Example_setup() { + var t testing.TB // in a test this is your *testing.T + + db := mysql.Setup(t, testdb.WithMigrator(mysql.SQLFiles("./schema"))) + + _, err := db.ExecContext(context.Background(), + "INSERT INTO users (email) VALUES (?)", "test@example.com") + if err != nil { + t.Fatalf("Insert failed: %v", err) + } + // No cleanup to write: the database is dropped via t.Cleanup +} + +// Example_customInitializer hands the database to your own type, for +// sqlx, GORM, or an application wrapper. +func Example_customInitializer() { + var t testing.TB + + db := mysql.New(t, &appDBInitializer{}, + testdb.WithMigrator(mysql.LoadSQLFile("./schema.sql"))) + + appDB := db.Entity().(*appDB) + fmt.Println(appDB.Name) +} + +// appDB stands in for an application's own database type. +type appDB struct { + *sql.DB + Name string +} + +type appDBInitializer struct{} + +func (appDBInitializer) InitializeTestDatabase(ctx context.Context, dsn string) (any, error) { + db, err := sql.Open("mysql", dsn) + if err != nil { + return nil, err + } + return &appDB{DB: db, Name: "app"}, nil +} + +// Example_template builds the schema once in TestMain and gives every +// test a prepared clone, which is where MySQL's speed comes from. +func Example_template() { + // func TestMain(m *testing.M) { + ctx := context.Background() + + tpl, err := mysql.EnsureTemplate(ctx, "app_schema", + mysql.SQLFiles("./schema"), + mysql.WithReadyDatabases(8)) // clones kept prepared ahead of tests + if err != nil { + log.Fatal(err) + } + + var m *testing.M + code := m.Run() + + if err := tpl.Release(ctx); err != nil { + log.Print(err) + } + os.Exit(code) + // } + // + // func TestOrders(t *testing.T) { + // db := mysql.Setup(t, testdb.WithTemplate("app_schema")) // ~6 ms + // } +} + +// Example_keepOnFailure keeps a failed test's database and logs a mysql +// command line to open it. +func Example_keepOnFailure() { + var t testing.TB + + db := mysql.Setup(t, testdb.WithKeepOnFailure()) + _ = db + // On failure the test log shows: + // testdb: kept database test_1761100000_a1b2c3d4 for inspection: + // mysql --host='localhost' --port=3306 --user='root' --password='root' 'test_1761100000_a1b2c3d4' + // The same switch for a whole run: TESTDB_KEEP_ON_FAILURE=1 go test ./... +} From c92a8b292a8869d17366e3899cd48e9426671862 Mon Sep 17 00:00:00 2001 From: Marc Alvarez Date: Thu, 22 Oct 2026 00:47:47 -0600 Subject: [PATCH 2/2] Wait for the killed holder's databases, not its records The pool records a prepared database before cloning into it, so two records can exist while the second CREATE DATABASE has not run yet; CI killed the holder at that instant and found one database. The test now waits for both databases to exist, which also makes a kill mid-clone a case the sweep is seen to handle. --- mysql/concurrency_test.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/mysql/concurrency_test.go b/mysql/concurrency_test.go index 8c0a2b1..162e7f8 100644 --- a/mysql/concurrency_test.go +++ b/mysql/concurrency_test.go @@ -164,9 +164,13 @@ func TestTemplateSurvivesKilledProcess(t *testing.T) { return err == nil }) users := qualify(name, bookkeepingPrefix+"users") - readyTable := qualify(name, bookkeepingPrefix+"ready") + readyPattern := strings.ReplaceAll(readyPrefix, "_", `\_`) + `\_%` + // The pool records a database before cloning into it, so wait for the + // databases themselves, not the records; a kill that lands mid-clone + // is a fine case for the sweep, but the assertion below needs both + // databases to exist waitUntil(t, 30*time.Second, "holder's pool to fill", func() bool { - return scanInt(t, admin, "SELECT COUNT(*) FROM "+readyTable) == 2 + return schemasLike(t, admin, readyPattern) == 2 }) if n := scanInt(t, admin, "SELECT COUNT(*) FROM "+users); n != 1 { t.Fatalf("%d template users registered while the holder runs, want 1\n%s", n, holderOut.String()) @@ -187,7 +191,7 @@ func TestTemplateSurvivesKilledProcess(t *testing.T) { waitUntil(t, 10*time.Second, "the killed process's session to end", func() bool { return scanInt(t, admin, "SELECT COUNT(*) FROM information_schema.PROCESSLIST WHERE id = ?", holderConn) == 0 }) - if n := schemasLike(t, admin, strings.ReplaceAll(readyPrefix, "_", `\_`)+`\_%`); n != 2 { + if n := schemasLike(t, admin, readyPattern); n != 2 { t.Fatalf("expected the dead process's 2 prepared databases to still exist before the sweep, found %d", n) } @@ -208,7 +212,7 @@ func TestTemplateSurvivesKilledProcess(t *testing.T) { if schemaExists(t, admin, name) { t.Errorf("template %s was left behind: a dead process kept it alive", name) } - if n := schemasLike(t, admin, strings.ReplaceAll(readyPrefix, "_", `\_`)+`\_%`); n != 0 { + if n := schemasLike(t, admin, readyPattern); n != 0 { t.Errorf("%d prepared databases of the dead process were not swept", n) } }