Contents
A single fixture gets you started: start a thing, stop a thing, point your tests at it. Real projects need more. Fixtures that compose into dependency graphs, containers that survive flaky startup, per-test isolation that keeps parallel tests from stepping on each other, and configuration that adapts to each environment. These are the patterns that emerge when fixtures grow beyond a single struct with two hooks.
If you are new to gotest fixtures, Test Fixtures in Go covers the fundamentals: struct types with lifecycle hooks, automatic dependency resolution, DAG-based ordering. Sharing Test Fixtures Across Go Packages covers cross-package sharing through JSON state transfer and subprocess lifecycle management. This post assumes you have built your first fixture.
Fixture composition
When one fixture depends on another, you express it as a pointer field:
type ServiceFixture struct {
DB *DatabaseFixture
Cache *CacheFixture
svc *MyService
}
func (f *ServiceFixture) BeforeAll(ctx context.Context) error {
f.svc = NewMyService(f.DB.Pool, f.Cache.Client)
return nil
}The generator sees the pointer fields, resolves the dependency graph, and starts DatabaseFixture and CacheFixture before ServiceFixture. Teardown runs in reverse order.
The key insight: f.DB and f.Cache are fully initialized by the time ServiceFixture.BeforeAll runs. You do not need to check for nil, do not need to pass dependencies manually, do not need to worry about ordering. The DAG handles it.
This scales to deeper graphs. Consider a three-level composition:
Multiple suites can reference the same fixture graph. Each suite runs in its own test process, so each suite constructs its own instances and pays its own BeforeAll — APITestSuite and WorkerTestSuite each get a ServiceFixture wired to its own DatabaseFixture and CacheFixture. The wiring is declared once and there is no coordination code; when the setup cost must be paid once for the whole run, promote the fixture to a SharedFixture.
Embedding vs. referencing
There are two ways to use a fixture in a suite. The right choice depends on how tests interact with the fixture.
Reference (pointer field)
type OrderTestSuite struct {
DB *DatabaseFixture
}
func (s *OrderTestSuite) TestCreateOrder(t *gotest.T) {
// Access through the fixture field
row := s.DB.Pool.QueryRow(ctx, "SELECT ...")
// ...
}Embedding
type PricingTestSuite struct {
*CatalogFixture
}
func (s *PricingTestSuite) TestDiscountCalculation(t *gotest.T) {
// CatalogFixture's methods are promoted to the suite
product := s.GetProduct("SKU-001")
prices := s.ListPrices(product.ID)
// ...
}Embedding promotes the fixture’s exported methods to the suite. This is useful when the fixture provides a domain-specific API (catalog operations, user factories) that tests call frequently. Referencing is better when you need explicit access to infrastructure like connection pools or containers, where the indirection through a named field makes the code clearer.
The generator treats both forms identically for dependency resolution. The difference is purely ergonomic.
Container-backed fixtures
For fixtures that manage Docker containers (databases, message brokers, caches), gotest provides a preset configuration:
func (f *PostgresFixture) FixtureConfig() gotest.FixtureConfig {
return gotest.ContainerFixtureConfig()
// Timeout: 5 minutes, Retries: 1, RetryDelay: 5 seconds
}This acknowledges reality: containers take time to start and sometimes fail transiently. The retry with delay handles flaky Docker daemon responses. The 5-minute timeout accommodates cold image pulls.
Here is a complete testcontainers example:
type PostgresFixture struct {
container testcontainers.Container
Pool *pgxpool.Pool
DSN string
}
func (f *PostgresFixture) FixtureConfig() gotest.FixtureConfig {
return gotest.ContainerFixtureConfig()
}
func (f *PostgresFixture) BeforeAll(ctx context.Context) error {
req := testcontainers.ContainerRequest{
Image: "postgres:16-alpine",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_DB": "test",
"POSTGRES_PASSWORD": "test",
},
WaitingFor: wait.ForListeningPort("5432/tcp"),
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
return err
}
f.container = container
host, _ := container.Host(ctx)
port, _ := container.MappedPort(ctx, "5432/tcp")
f.DSN = fmt.Sprintf("postgres://postgres:test@%s:%s/test?sslmode=disable", host, port.Port())
pool, err := pgxpool.New(ctx, f.DSN)
if err != nil {
return err
}
f.Pool = pool
return nil
}
func (f *PostgresFixture) AfterAll(ctx context.Context) error {
f.Pool.Close()
return f.container.Terminate(ctx)
}BeforeAll starts the container, waits for it to accept connections, and creates a connection pool. AfterAll closes the pool and terminates the container. The ContainerFixtureConfig preset gives the container up to 5 minutes to start and retries once on transient failure. All of this is fixture-internal; suites that reference PostgresFixture just see f.Pool and f.DSN.
Per-test isolation with BeforeEach/AfterEach
Fixture-level BeforeEach/AfterEach wraps the suite’s own hooks. This is perfect for transaction-per-test isolation. Give the fixture a Tx pgx.Tx field and manage it per test:
func (f *PostgresFixture) BeforeEach(ctx context.Context) error {
tx, err := f.Pool.Begin(ctx)
if err != nil {
return err
}
f.Tx = tx
return nil
}
func (f *PostgresFixture) AfterEach(ctx context.Context) error {
return f.Tx.Rollback(ctx)
}Note that the transaction has to be held as a pgx.Tx, not issued as a bare BEGIN through the pool — each Pool.Exec call can land on a different pooled connection, so a BEGIN on one would not enclose the statements that follow. Tests and suite hooks run their queries through f.Tx, and every statement lands inside a transaction that rolls back after the test. The suite does not need to manage any of it. Isolation is a fixture concern, not a test concern.
This pattern separates two lifecycle scopes cleanly. The container lifecycle (BeforeAll/AfterAll) runs once per suite process — two suites referencing the fixture start two containers; use a SharedFixture to pay for one. The transaction lifecycle (BeforeEach/AfterEach) runs once per test. A suite that references PostgresFixture gets both: the container is already running when the suite starts, and each test gets a fresh transaction.
Custom configuration and presets
FixtureConfig is composable. Start from a preset and override:
func (f *SlowServiceFixture) FixtureConfig() gotest.FixtureConfig {
cfg := gotest.ContainerFixtureConfig()
cfg.Timeout = 10 * time.Minute // Slow image pull
cfg.Retries = 3 // Flaky network
return cfg
}The preset gives you sensible defaults. You override what your environment demands. A fixture pulling a 2 GB ML model image needs a longer timeout. A fixture connecting to an external staging service with spotty DNS needs more retries.
Suites have their own configuration through SuiteConfig:
func (s *IntegrationTestSuite) SuiteConfig() gotest.SuiteConfig {
return gotest.IntegrationSuiteConfig()
// Timeout: 2 minutes, SetupTimeout: 5 minutes
}This is the same composability pattern: start from a preset, override what you need. The presets encode common timeout/retry combinations so that every fixture does not need to reinvent them.
Thread-safe fixture state
When suites run tests in parallel, fixtures must be thread-safe. The pattern: use exported methods with internal synchronization.
type InMemoryStoreFixture struct {
mu sync.RWMutex
items map[string]any
}
func (f *InMemoryStoreFixture) BeforeAll(ctx context.Context) error {
f.items = make(map[string]any)
return nil
}
func (f *InMemoryStoreFixture) Put(key string, value any) {
f.mu.Lock()
defer f.mu.Unlock()
f.items[key] = value
}
func (f *InMemoryStoreFixture) Get(key string) (any, bool) {
f.mu.RLock()
defer f.mu.RUnlock()
v, ok := f.items[key]
return v, ok
}Tests interact with the fixture through its methods, never touching internal state directly. The fixture owns its synchronization. A RWMutex allows concurrent reads while serializing writes, which is the common pattern for test state that is read-heavy.
This matters when a suite declares SuiteConfig{Parallel: true}. The suite’s tests run concurrently, and any shared fixture must handle that. (Parallel suites also require a returning BeforeEach — per-test state lives in the returned context struct, not on the shared suite struct.) The fixture’s API boundary is where thread safety lives.
Binding shared fixtures to local resources
When a shared fixture (cross-package) provides connection information, a package-local fixture can create the actual connection:
type InfraFixture struct {
Alpha *AlphaSharedFixture
conn *grpc.ClientConn
}
func (f *InfraFixture) BeforeAll(ctx context.Context) error {
// Alpha is already hydrated with cross-package state
conn, err := grpc.NewClient(f.Alpha.ServiceAddr,
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return err
}
f.conn = conn
return nil
}
func (f *InfraFixture) AfterAll(ctx context.Context) error {
return f.conn.Close()
}The shared fixture provides the connection string, transferred across processes via JSON. The package fixture creates the actual connection, which is a local resource that cannot cross the process boundary. This separation keeps transfer state minimal and local resources properly managed.
The pattern works for any resource that has a serializable address and a non-serializable handle: gRPC connections, HTTP clients with custom transports, database pools, authenticated SDK clients.
When to extract a fixture
Not every piece of setup needs to be a fixture. Here is a decision guide:
- One suite uses it: keep setup in
BeforeAll/BeforeEachon the suite. Extracting adds indirection without benefit. - Two suites in the same package use it: extract to a fixture. The DAG handles initialization order and shared state.
- Multiple packages need it: extract to a shared fixture. The subprocess lifecycle and JSON transfer handle cross-process coordination.
- It manages a container or external service: always extract. You want
FixtureConfigfor timeouts and retries, and you want the container lifecycle decoupled from any single suite.
The threshold is duplication. If you find two suites with the same BeforeAll body, that body is a fixture waiting to be extracted. And if a package has only a handful of tests with cheap setup, none of this machinery is warranted — a plain helper function is fine.
A composed fixture stack
These patterns compose. A realistic integration test setup might look like this:
// Fixture: manages a Postgres container with per-test transactions
type PostgresFixture struct { /* ... container-backed + per-test isolation ... */ }
// Fixture: manages a Redis container
type RedisFixture struct { /* ... container-backed ... */ }
// Fixture: composes DB + cache, creates a service layer
type ServiceFixture struct {
DB *PostgresFixture // composition
Cache *RedisFixture // composition
Svc *OrderService
}
// Suite: embeds the service fixture for ergonomic access
type OrderFlowTestSuite struct {
*ServiceFixture // embedding
}
func (s *OrderFlowTestSuite) SuiteConfig() gotest.SuiteConfig {
return gotest.IntegrationSuiteConfig() // presets
}
func (s *OrderFlowTestSuite) TestPlaceOrder(t *gotest.T) {
// s.Svc is available directly (promoted from embedded ServiceFixture)
// Each test runs in a transaction (PostgresFixture.BeforeEach)
// Container startup is retried on failure (ContainerFixtureConfig)
order, err := s.Svc.Place(ctx, cart)
gotest.NoError(t, err)
gotest.Equal(t, "confirmed", order.Status)
}The test itself is four lines. The infrastructure, lifecycle, isolation, and retry logic all live in the fixtures. Add another suite that needs the same infrastructure and it references the same fixtures. The DAG prevents duplication.
Further reading
Each pattern here builds on the fundamentals. If you have not read them yet:
- Test Fixtures in Go covers the progression from global variables to DAG-based fixtures.
- Shared Fixtures Across Packages covers the subprocess model and JSON state transfer for cross-package sharing.
- The reference docs have the full API for
FixtureConfig,SuiteConfig, and lifecycle hook signatures.
Compose your first fixture DAG.
go install github.com/mvrahden/go-test/cmd/gotest@latest