Running go test ./... caused flaky database test failures due to concurrent test packages sharing a single MySQL test database

Problem description

I have a Go project with integration tests that use a MySQL test database. Each package has its own tests (e.g. cmd/web, internal/models), and each package sets up and tears down the database schema using setup.sql and teardown.sql.

Individually, running tests per package works fine:

  • go test ./internal/models → passes
  • go test ./cmd/web → passes

However, when running the full suite: go test ./…

I started getting intermittent failures such as:

  • Error 1050: Table 'snippets' already exists
  • teardown not consistently removing schema state
  • tests passing or failing depending on which packages were included

Initially, I assumed this was a cleanup issue in my setup/teardown logic. But after investigation, I realized the key issue:

  • Each package is compiled into a separate test binary
  • go test ./... runs multiple test binaries concurrently
  • All test packages were connecting to the same MySQL database (test_snippetbox)

This caused race conditions where:

  • one package’s setup interfered with another package’s teardown
  • schema creation happened while another package still assumed a clean DB

Running with -p=1 confirmed the issue was concurrency-related.

Solution I ended up using

I changed the test setup so that each test package gets its own isolated database, e.g.:

  • test_snippetbox_cmd_web
  • test_snippetbox_internal_models

Each package:

  • creates its own database in setup
  • runs migrations / setup.sql inside that database
  • drops the database in teardown

This removed all cross-package interference and made go test ./... fully stable even with parallel execution.

Question

Is this the recommended approach in Go for integration tests with external databases (i.e. per-package database isolation), or is there a more idiomatic pattern that is generally preferred in production Go projects?

1 Like

You could try something like this where you wrap each test in a transaction then roll it back to leave the DB in a known good state for other tests:

1 Like

Isolate per test, not per package — the package boundary is arbitrary, and two writing tests in the same package still collide.

Move the isolation into a helper rather than a naming convention. Each writing test gets its own uniquely-named database, with schema applied and teardown registered on the spot via t.Cleanup:

func newTestDB(t *testing.T) *sql.DB {
    t.Helper()
    name := fmt.Sprintf("test_%s_%d", sanitize(t.Name()), atomic.AddInt64(&counter, 1))

    admin := mustConnect(t, adminDSN)
    mustExec(t, admin, "CREATE DATABASE "+name)
    t.Cleanup(func() { mustExec(t, admin, "DROP DATABASE "+name); admin.Close() })

    db := mustConnect(t, dsnFor(name))
    applySchema(t, db)                    // setup.sql, once
    t.Cleanup(func() { db.Close() })
    return db
}

This makes the package boundary irrelevant, so the cross-binary interleaving vanishes with no need for -p=1, and every test can now call t.Parallel(). Under it, let testcontainers-go supply the MySQL instance itself — one throwaway container per run, matching the production version, which is the whole point of integrating against MySQL rather than substituting SQLite.

Two caveats. Transaction-rollback isolation is tempting but fails here: DDL triggers an implicit commit in MySQL, and code that manages its own transactions defeats an outer rollback. And truncate-between-tests only becomes worth it if a large schema makes per-test migration slow — for a snippetbox it doesn’t, so the simple helper suffices. Keep -p=1 as a diagnostic, never as the fix.

1 Like