Contents

go test compiles each package into a separate test binary and runs it as a separate OS process. That design buys you isolation, and it makes one thing structurally difficult: sharing a fixture across packages. A Postgres container that pkg/user, pkg/order, and pkg/billing all need. A Redis instance that three packages query. A schema migration that should run once for the entire test run, not once per package. Sharing any of these means crossing process boundaries.

None of the mainstream Go testing frameworks ships a built-in answer for it. gotest does, through a mechanism called shared fixtures.

A previous post covered fixture patterns within a single package: global variables, helper functions, sync.Once wrappers, and gotest’s DAG-based package fixtures. All of those work within one test binary, one process. This post is about the harder problem: crossing that boundary.

Why this is hard

The process-per-package model is not a quirk of the implementation — it is a deliberate design choice that gives you process-level isolation between packages.

The consequence: there is no shared memory between pkg/user and pkg/order. They are different binaries, different processes, different address spaces. A *sql.DB created in one package cannot be passed to another. A sync.Once in one process cannot coordinate with a sync.Once in another.

This means every cross-package fixture approach in the stdlib has the same fundamental problem:

What teams do today

Without a built-in solution, most teams fall back to one of two patterns:

External orchestration

A Makefile, docker-compose file, or CI script starts the database before go test runs. The DSN is passed through an environment variable. Each package’s TestMain connects independently.

Makefile
test:
	docker compose up -d postgres
	sleep 3
	TEST_DB_URL=postgres://localhost:5432/testdb go test ./...
	docker compose down

This works. The database is shared. But the fixture lifecycle lives outside of Go, in shell scripts and YAML files. The test code cannot express “I need a Postgres instance” as a dependency; it can only assume one already exists. And the sleep 3 is a reminder that coordination across processes is fundamentally a timing problem when you solve it with scripts.

Per-package duplication

Each package starts its own container. This is simple and isolated, but expensive. If five packages each start a Postgres container, you’re spending 15–30 seconds on container startup alone. In CI, this adds up.

pkg/user/testmain_test.go
func TestMain(m *testing.M) {
    container := startPostgres()  // 3-6 seconds
    defer container.Stop()
    os.Exit(m.Run())
}
pkg/order/testmain_test.go
func TestMain(m *testing.M) {
    container := startPostgres()  // same 3-6 seconds, again
    defer container.Stop()
    os.Exit(m.Run())
}

Both approaches have the same root cause: Go’s process-per-package model has no built-in mechanism for cross-process resource sharing.

Shared fixtures: the model

gotest’s answer is shared fixtures: structs whose name ends in SharedFixture. Shared fixtures run in a dedicated setup subprocess — one subprocess for all of them, separate from every test process — once for the entire test run. Their state is serialized as JSON and transferred to every test package that needs it.

The lifecycle looks like this:

1. gotest starts a fixture subprocess 2. SharedFixture.BeforeAll runs (start container, run migrations) 3. Exported fields are serialized to JSON (the "transfer state") 4. For each suite process that needs this fixture (each suite runs in its own process): a. Transfer state is deserialized into a new instance b. SharedFixture.Hydrate runs (open connections from transfer state) c. Tests run d. SharedFixture.Dehydrate runs (close connections) 5. All suite processes finish 6. SharedFixture.AfterAll runs (stop container, cleanup)

The key insight is the split between what can cross a process boundary (data) and what cannot (connections, file handles, goroutines). The fixture’s transfer fields — exported fields that Hydrate leaves alone — get serialized to JSON and sent to each test process. Its local fields — anything Hydrate assigns, plus all unexported fields — hold resources that are reconstructed by Hydrate in each process.

A concrete example

Consider a Postgres container that multiple packages need:

testinfra/fixtures.go
package testinfra

import (
    "context"
    "database/sql"

    "github.com/mvrahden/go-test/pkg/gotest"
)

type PostgresSharedFixture struct {
    DSN string // transfer: serialized to JSON, sent to test packages

    container *postgresContainer // local: only lives in the fixture subprocess
    conn      *sql.DB            // local: created by Hydrate in each test process
}

func (f *PostgresSharedFixture) SharedFixtureConfig() gotest.FixtureConfig {
    return gotest.ContainerFixtureConfig()  // 5m timeout, 1 retry, 5s delay
}

func (f *PostgresSharedFixture) BeforeAll(ctx context.Context) error {
    // Start a container, run migrations, seed data.
    // This runs once for the entire test run.
    container, err := startPostgresContainer(ctx)
    if err != nil {
        return err
    }
    f.container = container
    f.DSN = container.DSN()

    f.conn, err = sql.Open("postgres", f.DSN)
    if err != nil {
        return err
    }
    _, err = f.conn.ExecContext(ctx, "CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT)")
    return err
}

func (f *PostgresSharedFixture) AfterAll(ctx context.Context) error {
    if f.conn != nil {
        f.conn.Close()
    }
    return f.container.Terminate(ctx)
}

func (f *PostgresSharedFixture) Hydrate(ctx context.Context) error {
    // Called in each test package's process.
    // f.DSN is already populated from the JSON transfer.
    var err error
    f.conn, err = sql.Open("postgres", f.DSN)
    return err
}

func (f *PostgresSharedFixture) Dehydrate(ctx context.Context) error {
    // Called when a test package's process is done.
    if f.conn != nil {
        return f.conn.Close()
    }
    return nil
}

// Conn returns the database connection for test code to use.
func (f *PostgresSharedFixture) Conn() *sql.DB {
    return f.conn
}

The lifecycle methods map to distinct moments:

For the ordering and cleanup guarantees behind hooks like these — what runs when, and what still runs after a failure — see Go Test Lifecycle.

Consuming shared fixtures from test suites

A suite declares its dependency on a shared fixture the same way it declares any fixture dependency: a pointer field.

pkg/user/suite_test.go
package user

import (
    "github.com/mvrahden/go-test/pkg/gotest"
    "yourproject/testinfra"
)

type UserRepositoryTestSuite struct {
    Postgres *testinfra.PostgresSharedFixture
    repo     *userRepository
}

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

func (s *UserRepositoryTestSuite) TestCreateUser(t *gotest.T) {
    t.When("the input is valid", func(w *gotest.T) {
        err := s.repo.Create(User{ID: "1", Email: "alice@example.com"})

        w.It("succeeds", func(it *gotest.T) {
            gotest.NoError(it, err)
        })
    })
}
pkg/order/suite_test.go
package order

import (
    "github.com/mvrahden/go-test/pkg/gotest"
    "yourproject/testinfra"
)

type OrderRepositoryTestSuite struct {
    Postgres *testinfra.PostgresSharedFixture
    repo     *orderRepository
}

func (s *OrderRepositoryTestSuite) BeforeEach(t *gotest.T) {
    s.repo = newOrderRepository(s.Postgres.Conn())
}

func (s *OrderRepositoryTestSuite) TestPlaceOrder(t *gotest.T) {
    // Uses the same Postgres container as UserRepositoryTestSuite
    // but in a different OS process
}

Both suites reference *testinfra.PostgresSharedFixture. gotest sees the pointer to a SharedFixture-suffixed type and wires it up. The container starts once. Both packages get their own database connection, hydrated from the same DSN. When both packages are done, the container stops.

Transfer fields vs. local fields

The distinction between transfer fields and local fields is the core of the shared fixture model. It maps directly to what can and cannot cross a process boundary:

The classification goes by assignment, not just by export rules: the generator analyzes the Hydrate body (following one level of receiver method calls, so a f.connect() helper counts too), and any field assigned there is treated as local and excluded from the snapshot — even an exported one, such as a Pool *pgxpool.Pool that Hydrate populates. Unexported fields never cross the boundary regardless, because encoding/json ignores them.

Not every shared fixture needs Hydrate and Dehydrate. If the fixture’s exported fields are sufficient for test code to work with (a port number, a URL, a token), you can skip them. Hydrate/Dehydrate are for resources that need to be opened and closed in each process — and they come as a pair: defining only one of the two is a generation error.

Shared fixture dependencies

Shared fixtures can depend on other shared fixtures, forming a DAG just like package fixtures. A schema migration fixture that depends on a Postgres container:

testinfra/fixtures.go
type SchemaSharedFixture struct {
    Postgres *PostgresSharedFixture  // depends on Postgres being up
    Version  string
}

func (f *SchemaSharedFixture) BeforeAll(ctx context.Context) error {
    // f.Postgres.conn is live (Postgres.BeforeAll already ran)
    _, err := f.Postgres.Conn().ExecContext(ctx,
        "CREATE TABLE IF NOT EXISTS orders (id TEXT, user_id TEXT, total NUMERIC)")
    if err != nil {
        return err
    }
    f.Version = "v2"
    return nil
}

func (f *SchemaSharedFixture) AfterAll(_ context.Context) error {
    return nil
}

gotest resolves these dependencies in topological order. PostgresSharedFixture.BeforeAll runs first. When it completes, SchemaSharedFixture.BeforeAll runs. Independent fixtures (no dependency between them) set up concurrently. Teardown runs in reverse order. Cyclic dependencies are rejected at resolution time with a clear error message.

Suites that depend on SchemaSharedFixture automatically get PostgresSharedFixture too — transitive dependencies are included. A suite doesn’t need to list every fixture in the chain; the pointer to the leaf fixture is enough. For more composition patterns — embedding vs. referencing, and binding shared fixtures to package-local resources — see Advanced Go Test Fixtures.

Dispatch timing

Suites are dispatched as soon as their specific shared fixture dependencies are ready. They do not wait for unrelated fixtures. If suite A needs Postgres and suite B needs Redis, A starts running as soon as Postgres is ready, even if Redis is still starting.

PostgresSharedFixture.BeforeAll ─── ready ──> pkg/user tests start pkg/order tests start RedisSharedFixture.BeforeAll ────── ready ──> pkg/cache tests start

This means the wall-clock cost of fixture setup is the longest single fixture, not the sum of all fixtures. If Postgres takes 4 seconds and Redis takes 2 seconds, the total setup overhead is 4 seconds, not 6.

Configuration and timeouts

Shared fixtures support the same configuration pattern as package fixtures, through a SharedFixtureConfig() marker method:

func (f *PostgresSharedFixture) SharedFixtureConfig() gotest.FixtureConfig {
    return gotest.ContainerFixtureConfig()  // 5m timeout, 1 retry, 5s delay
}

ContainerFixtureConfig() is a preset that gives a 5-minute timeout with one retry and a 5-second retry delay — appropriate for infrastructure that might need time to pull an image or start a process. You can also specify values directly:

func (f *PostgresSharedFixture) SharedFixtureConfig() gotest.FixtureConfig {
    return gotest.FixtureConfig{
        Timeout: 2 * time.Minute,
        Retries: 2,
    }
}

There is also a project-level timeout, --setup-timeout, which sets a wall-clock budget for the entire shared fixture setup phase. The two timeouts run concurrently: the per-fixture timeout governs individual fixtures, and the project-level timeout governs the total. Whichever fires first wins.

When to use shared fixtures vs. package fixtures

The decision is about scope and cost:

If in doubt, start with package fixtures. Promote to shared fixtures when the per-package setup time becomes a problem. The struct pattern is similar enough that the migration is mechanical: rename the suffix, add Hydrate/Dehydrate for non-serializable fields, and move the struct to a shared package.

What this replaces

Shared fixtures replace the external orchestration layer that most Go projects use today: the Makefile that starts containers, the docker-compose file that seeds databases, the CI script that waits for health checks. The fixture lifecycle moves into Go, where it is type-checked, version-controlled, and visible in the test code itself.

The test code can now say “I need a Postgres instance with this schema” as a typed dependency, not as an assumption about what make test did before go test ran. If the fixture fails to start, the error appears in the test output with a file and line number. If the fixture is slow, ContainerFixtureConfig gives it retries and a timeout. If the fixture depends on another fixture, the dependency is a pointer field that the generator resolves automatically.

This is the kind of problem that, once you see the solution, feels obvious. But it requires crossing a conceptual boundary: the fixture lifecycle must span multiple OS processes, which means serialization, subprocesses, and careful lifecycle orchestration. That is what the SharedFixture model provides.

Further reading

For the single-package fundamentals this model builds on, start with Test Fixtures in Go. For composition patterns, container-backed fixtures, and per-test isolation, continue with Advanced Go Test Fixtures. And for the exact ordering and cleanup guarantees of every hook, see Go Test Lifecycle.

Try shared fixtures in your next project.

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