# Fixtures in Go: Extracting Infrastructure from Automated Tests with Axiom
Integration and E2E tests in Go often suffer from mixing business logic with infrastructure management. The standard testing package doesn't provide a fixtures mechanism, forcing developers to duplicate code for initializing clients, preparing data, and cleaning up resources right inside the tests. This reduces readability, complicates maintenance, and hinders scaling the test suite. The solution is implementing fixtures via third-party libraries like Axiom, which allow declaratively describing dependencies with managed lifecycles.
What Are Fixtures in the Context of Testing
A fixture isn't just a helper function—it's a resource with a clearly defined lifecycle: on-demand creation, automatic cleanup after test execution, ability to depend on other fixtures, and isolation from assertion logic. Unlike manual management of connections or data, fixtures provide:
- Lazy initialization—the resource is created only on first access.
- Automatic cleanup—even if the test fails, the state is guaranteed to be cleaned up.
- Declarative dependencies—one fixture can reference another without manual parameter passing.
- Isolation between tests and retries—state doesn't leak between attempts.
Languages like Python (pytest) or Java (JUnit) have these mechanisms built in. In Go, they must be implemented explicitly or using specialized tools.
The Problem Without Fixtures: Infrastructure Inside the Test
A typical Go test without fixtures looks like this:
func TestGetUser(t *testing.T) {
client, err := NewUserClient()
if err != nil {
t.Fatalf("failed to create client: %v", err)
}
defer client.Close()
user, err := CreateUser(client)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer func() {
_ = DeleteUser(client, user.ID)
}()
resp, err := client.GetUser(user.ID)
if err != nil {
t.Fatalf("failed to get user: %v", err)
}
if resp.ID != user.ID {
t.Fatalf("unexpected user id")
}
}
Here, everything—from creating the client to deleting the user—is inside the test body. As the number of dependencies grows (databases, external APIs, message queues), this approach leads to:
- High code duplication.
- Difficulty making changes (e.g., updating client authentication).
- Increased chance of errors in cleanup logic.
- Poor readability: the core assertion gets lost amid infrastructure details.
Implementing Fixtures with Axiom
The Axiom library provides a mechanism for describing fixtures as functions that return a value, a cleanup function, and an error. The lifecycle is managed via Runner and Case.
Example structure:
// Fixture client
func UserClientFixture(_ *axiom.Config) (any, func(), error) {
client, err := NewUserClient()
if err != nil {
return nil, nil, err
}
return client, client.Close, nil
}
// Fixture polzovatelya, zavisyaschaya from client
func UserFixture(cfg *axiom.Config) (any, func(), error) {
client := axiom.GetFixture*UserClient
user, err := client.CreateUser()
if err != nil {
return nil, nil, err
}
cleanup := func() { _ = client.DeleteUser(user.ID) }
return user, cleanup, nil
}
// Runner with globalnoy fiksturoy
var runner = axiom.NewRunner(
axiom.WithRunnerFixture("client", UserClientFixture),
)
// Test
func TestGetUser(t *testing.T) {
c := axiom.NewCase(
axiom.WithCaseName("get user by id"),
axiom.WithCaseFixture("user", UserFixture),
)
runner.RunCase(t, c, func(cfg *axiom.Config) {
client := axiom.GetFixture*UserClient
user := axiom.GetFixture*User
resp, err := client.GetUser(user.ID)
if err != nil {
t.Fatalf("failed to get user: %v", err)
}
if resp.ID != user.ID {
t.Fatalf("unexpected user id")
}
})
}
Key advantages of this approach:
- Clear separation of concerns: fixtures handle infrastructure, tests handle behavior.
- Reusability: a single client fixture can be used across all tests via Runner.
- Retry safety: on test retry, all fixtures are recreated, preventing influence from prior states.
- Declarative: dependencies are explicitly declared without imperative code in the test body.
When to Implement Fixtures
Fixtures are especially useful in these scenarios:
- Integration tests with multiple external dependencies (DB, API, file storage).
- E2E tests requiring complex environment setup.
- Projects with a large number of tests (>50–100), where maintenance is critical.
- Teams aiming for uniformity and predictability in test infrastructure.
Don't use fixtures for simple unit tests where dependencies can be easily mocked via interfaces.
Key Points
- Fixtures aren't built into Go—you need to implement them explicitly or use libraries like Axiom.
- The goal of fixtures is to extract infrastructure logic from the test body.
- Axiom provides lazy initialization, automatic cleanup, and isolation between tests.
- The approach is especially effective in large projects with hundreds of integration tests.
- Fixtures don't replace mocks—they complement them in scenarios requiring real environments.
— Editorial Team
No comments yet.