Contents

Most Go projects eventually need shared test infrastructure: a database connection, a message queue, a container that takes seconds to start. The stdlib doesn’t have a built-in answer for this. So every team invents its own fixture pattern, and most of those patterns quietly break the day you add t.Parallel() or a second package that needs the same Postgres.

This post looks at the common approaches, where each one breaks down, and what a fixture system needs to handle the cases that real projects run into.

The fixture lifecycle problem

A test fixture is any shared resource that tests depend on. The simplest example: a database that multiple test functions query. The fixture problem is about lifecycle: when does the database get created, when does it get torn down, and how do you make sure tests don’t step on each other?

The challenge grows along two axes:

Pattern 1: Global variables

The most common first approach:

var testDB *sql.DB

func TestMain(m *testing.M) {
    var err error
    testDB, err = sql.Open("postgres", os.Getenv("TEST_DB_URL"))
    if err != nil {
        log.Fatal(err)
    }
    defer testDB.Close()
    os.Exit(m.Run())
}

func TestCreateUser(t *testing.T) {
    _, err := testDB.Exec("INSERT INTO users ...")
    // ...
}

func TestListUsers(t *testing.T) {
    rows, err := testDB.Query("SELECT * FROM users")
    // depends on what TestCreateUser left behind
}

TestMain is Go’s package-level setup hook: it runs once before any tests in the package. The database connection lives in a global variable that every test function can access.

This works until it doesn’t:

Pattern 2: Helper functions

The next step is to wrap fixture creation in a helper:

func setupTestDB(t *testing.T) *sql.DB {
    t.Helper()
    db, err := sql.Open("postgres", os.Getenv("TEST_DB_URL"))
    if err != nil {
        t.Fatal(err)
    }
    t.Cleanup(func() { db.Close() })
    return db
}

func TestCreateUser(t *testing.T) {
    db := setupTestDB(t)
    // db is fresh for this test
}

func TestListUsers(t *testing.T) {
    db := setupTestDB(t)
    // db is fresh for this test too
}

Better. Each test gets its own connection. t.Cleanup handles teardown even if the test panics. No shared state between tests. This is the same setupTestDB helper that shows up in every flat test file — Organizing Go Tests covers how that file-level structure evolves.

But the cost problem is back: if the fixture is expensive (a container, a schema migration, a seed dataset), creating it per-test is too slow. And if you add caching to amortize the cost, say a sync.Once around the container startup, you’ve reinvented the global variable pattern with extra steps.

Pattern 3: Struct-based fixtures with sync.Once

A more structured approach uses a struct to hold the fixture state and sync.Once to ensure one-time initialization:

type DBFixture struct {
    once sync.Once
    db   *sql.DB
    err  error
}

func (f *DBFixture) Get(t *testing.T) *sql.DB {
    t.Helper()
    f.once.Do(func() {
        f.db, f.err = sql.Open("postgres", os.Getenv("TEST_DB_URL"))
    })
    if f.err != nil {
        t.Fatal(f.err)
    }
    return f.db
}

var dbFixture DBFixture

func TestCreateUser(t *testing.T) {
    db := dbFixture.Get(t)
    // ...
}

This solves the cost problem: the database is created once and shared. But it still has the state-leaking problem (tests share the same database), and teardown is still unclear: when does the database get closed? sync.Once has no corresponding “undo.”

More fundamentally, none of these patterns handle fixture dependencies. What if your test needs a database and a cache, and the cache needs the database connection to configure itself? Now you need to manage initialization order, and that’s where hand-rolled patterns start to collapse.

What fixtures actually need

Looking at the patterns above, a fixture system needs to handle four concerns:

  1. Lifecycle hooks. Setup and teardown at both the suite level (once) and the test level (per-test). Fixtures that take context.Context and return error, so expensive operations can be cancelled and failures can be handled.
  2. Dependency ordering. If fixture B depends on fixture A, A’s setup must complete before B’s begins. Teardown runs in reverse order. This is a DAG (directed acyclic graph) problem.
  3. Scope control. Some fixtures are suite-scoped (set up once per suite). Some are shared across suites and packages. The fixture system needs to understand these scopes.
  4. Isolation. Per-test hooks that reset state between test methods, so tests don’t inherit side effects from each other.

Fixtures as structs with lifecycle conventions

In gotest, a fixture is a struct with naming conventions, the same pattern used for test suites. A struct whose name ends in Fixture is recognized as a package-scoped fixture. Its lifecycle methods are discovered by name:

fixtures.go
type DatabaseFixture struct {
    DB *sql.DB
}

func (f *DatabaseFixture) FixtureConfig() gotest.FixtureConfig {
    return gotest.ContainerFixtureConfig()  // 5m timeout, 1 retry
}

func (f *DatabaseFixture) BeforeAll(_ context.Context) error {
    var err error
    f.DB, err = sql.Open("postgres", os.Getenv("TEST_DB_URL"))
    return err
}

func (f *DatabaseFixture) AfterAll(_ context.Context) error {
    return f.DB.Close()
}

func (f *DatabaseFixture) BeforeEach(_ context.Context) error {
    _, err := f.DB.Exec("TRUNCATE users, orders")
    return err
}

A few things to notice:

Suites consume fixtures by referencing them

A suite declares its fixture dependency by including it as a pointer field:

suite_test.go
type UserRepositoryTestSuite struct {
    DB   *DatabaseFixture
    repo *userRepository
}

func (s *UserRepositoryTestSuite) BeforeEach(t *gotest.T) {
    s.repo = newUserRepository(s.DB.DB)
}

func (s *UserRepositoryTestSuite) TestCreateUser(t *gotest.T) {
    t.When("the input is valid", func(w *gotest.T) {
        err := s.repo.Create(User{Name: "Alice"})

        w.It("succeeds", func(it *gotest.T) {
            gotest.NoError(it, err)
        })
    })
}

The field DB *DatabaseFixture is the entire dependency declaration. gotest sees the pointer to a Fixture-suffixed type and wires it up automatically. By the time BeforeEach runs on the suite, the fixture’s BeforeAll has already completed and s.DB.DB is a live database connection. (The complete ordering guarantees are laid out in Go Test Lifecycle.)

The lifecycle order for each test method is:

  1. Fixture BeforeEach: truncate tables
  2. Suite BeforeEach: create repository
  3. Test method: run the test
  4. Suite AfterEach
  5. Fixture AfterEach

If multiple suites in the same package reference *DatabaseFixture, each suite runs in its own process and constructs its own instance — BeforeAll runs once per suite. When the setup cost should be paid once for the whole run, promote it to a SharedFixture.

Fixture dependencies form a DAG

Fixtures can depend on other fixtures. A cache fixture that needs a database connection declares the dependency the same way, as a pointer field:

type CacheFixture struct {
    DB    *DatabaseFixture  // depends on DatabaseFixture
    Cache *redis.Client
}

func (f *CacheFixture) BeforeAll(ctx context.Context) error {
    f.Cache = redis.NewClient(&redis.Options{Addr: "localhost:6379"})
    // f.DB.DB is already initialized. DatabaseFixture.BeforeAll ran first
    return f.Cache.Ping(ctx).Err()
}

gotest resolves these dependencies into a directed acyclic graph and runs BeforeAll in topological order; teardown runs in reverse. You never manually coordinate initialization order; the dependency pointers encode it. How this plays out at depth — diamond-shaped graphs, concurrent initialization of independent fixtures, timeout and retry configuration — is the subject of Advanced Go Test Fixtures.

The cross-package problem

Everything above works within a single package. But go test runs each package as a separate OS process. There is no shared memory between pkg/user and pkg/order. They are different binaries, running in different processes, with different address spaces.

This means none of the patterns above can share a fixture across packages:

The stdlib has no answer for this. Most teams fall back to external orchestration: a Makefile or CI script starts the database before go test, passes the DSN through an environment variable, and each package’s TestMain connects independently. It works, but the fixture lifecycle lives outside of Go, in shell scripts, docker-compose files, or CI configuration. The test code cannot express “I need a Postgres instance” as a dependency; it can only assume one already exists. To be fair, testcontainers-go handles the container startup side of this well — but it runs inside your test process, so it can’t cross the package-process boundary either; each package still starts its own container.

Sharing fixtures across packages

gotest has shared fixtures for this: structs whose name ends in SharedFixture. Shared fixtures run in a dedicated setup subprocess, separate from every test binary, once for the entire test run. Their state is serialized as JSON and transferred to every test package that needs it.

The core idea is a split between transfer fields and local fields. Exported fields like a DSN are serialized and cross the process boundary; resources that can’t be serialized — connections, file handles, goroutines — are reconstructed in each test package’s process by a Hydrate method and cleaned up by Dehydrate. The container starts once; every package connects to it.

The end-to-end treatment — the fixture definition, the hydration lifecycle, and how it behaves in CI — is in Sharing Test Fixtures Across Go Packages.

Choosing a fixture pattern

Every fixture pattern represents a trade-off between simplicity, cost, and isolation:

A struct-based fixture system with lifecycle hooks, dependency ordering, scope control, and serialization-based shared fixtures addresses all four concerns. The fixture is created once (cheap), torn down reliably (lifecycle hooks with context and error handling), each test gets a clean slate (per-test hooks), and expensive infrastructure like databases and containers can be shared across the process boundaries that go test puts between packages.

The important part isn’t the specific implementation. It is recognizing that fixture management is a graph problem. Once you have more than two fixtures that depend on each other, manual coordination becomes a liability. A system that resolves dependencies, enforces initialization order, handles teardown in reverse, and bridges the cross-package process boundary is worth the upfront investment.

Share one Postgres container across every test package with gotest fixtures.

go install github.com/mvrahden/go-test/cmd/gotest@latest