Contents
Every test framework has setup and teardown. The interesting question is not whether they exist, but what guarantees the lifecycle gives you. Can AfterAll run if BeforeAll panics? Does AfterEach still execute on t.Fatal? Where do fixture hooks fit relative to suite hooks? If you have ever guessed at these answers while debugging a leaked database connection, this post is for you.
Other posts in this series cover fixture patterns and getting started with suites. This post is different. It maps the full lifecycle from the inside out, so you build the correct mental model once and stop guessing.
The four suite lifecycle hooks
A gotest suite can implement up to four lifecycle hooks. All are optional. An unimplemented hook is a no-op.
BeforeAllruns once before the first test method in the suite.AfterAllruns once after the last test method completes.BeforeEachruns before every test method.AfterEachruns after every test method.
Each hook accepts either *gotest.T or *testing.T. You can mix them within the same suite. A BeforeEach that takes *gotest.T and a TestCreate that takes *testing.T is perfectly valid.
The ordering is what you would expect:
So far, nothing surprising. The interesting parts are in the guarantees.
The cleanup guarantee
This is the most important lifecycle property in gotest, and the one most likely to differ from your intuition.
AfterAll is registered via t.Cleanup before BeforeAll executes. This is a deliberate design choice, not an implementation detail. It means AfterAll runs even if BeforeAll panics or calls t.Fatal. The Go test runner guarantees that t.Cleanup functions run regardless of how a test terminates, and gotest leverages that guarantee.
AfterEach is deferred within each test’s scope. It runs even if the test method calls t.Fatal, panics, or fails an assertion that stops execution. The defer mechanism in the generated code ensures this.
Why does this matter? Because tests that allocate real resources need deterministic cleanup. A database connection that leaks because BeforeAll panicked after sql.Open but before the rest of setup completed is a real problem. A container that is never terminated because a test called t.Fatal before reaching the cleanup code is a CI pipeline that slowly runs out of memory.
type DatabaseTestSuite struct {
container *PostgresContainer
db *sql.DB
}
func (s *DatabaseTestSuite) BeforeAll(t *gotest.T) {
s.container = startPostgresContainer(t)
s.db = connectDB(t, s.container.ConnectionString())
}
func (s *DatabaseTestSuite) AfterAll(t *gotest.T) {
// Runs even if BeforeAll panicked after starting the container
// but before connecting to the database.
if s.db != nil {
s.db.Close()
}
if s.container != nil {
s.container.Terminate(context.Background())
}
}The nil checks in AfterAll are the key pattern. Because AfterAll is guaranteed to run regardless of what happened in BeforeAll, you guard against partial initialization. The container might have started but the database connection might not exist yet. The cleanup code handles both cases.
Returning BeforeEach: per-test isolation
When BeforeEach returns a value, that value is passed as a parameter to the test method and to AfterEach. Each test gets its own instance. This is the mechanism that makes method-level parallelism safe.
type UserServiceTestSuite struct {
DB *DatabaseFixture
}
func (s *UserServiceTestSuite) BeforeEach(t *gotest.T) *TestCtx {
tx := s.DB.BeginTx(t)
return &TestCtx{
Tx: tx,
Service: NewUserService(tx),
}
}
func (s *UserServiceTestSuite) AfterEach(t *gotest.T, ctx *TestCtx) {
ctx.Tx.Rollback()
}
func (s *UserServiceTestSuite) TestCreate(t *gotest.T, ctx *TestCtx) {
t.When("email is valid", func(t *gotest.T) {
user, err := ctx.Service.Create("alice@example.com")
t.It("creates the user", func(t *gotest.T) {
gotest.NoError(t, err)
gotest.Equal(t, "alice@example.com", user.Email)
})
})
}The ctx parameter is unique per test. TestCreate gets its own transaction and its own service instance. If the suite is configured with SuiteConfig{Parallel: true}, tests run concurrently without data races because they share no mutable state. Each test writes to its own transaction that is rolled back in AfterEach, so no test’s writes are visible to any other test.
This is the non-obvious power of the returning BeforeEach pattern: it turns method-level parallelism from a coordination problem into a non-problem. There is nothing to coordinate when each test has its own world.
Fixture lifecycle: wrapping suites
Fixtures have their own set of lifecycle hooks: BeforeAll, AfterAll, BeforeEach, and AfterEach. These wrap the suite’s hooks, forming an outer layer:
There is an important signature difference. Fixture hooks use (ctx context.Context) error, not *gotest.T. This is because fixtures operate at a different level than tests. They manage infrastructure, not assertions. A fixture’s BeforeAll opens a database connection; it does not make test assertions. The context.Context parameter carries timeouts and cancellation; the error return lets the framework handle failures with retries and structured error reporting.
type DatabaseFixture struct {
DB *sql.DB
Tx *sql.Tx
}
func (f *DatabaseFixture) BeforeAll(ctx context.Context) error {
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
return err
}
f.DB = db
return nil
}
func (f *DatabaseFixture) AfterAll(ctx context.Context) error {
return f.DB.Close()
}
func (f *DatabaseFixture) BeforeEach(ctx context.Context) error {
tx, err := f.DB.BeginTx(ctx, nil)
f.Tx = tx
return err
}
func (f *DatabaseFixture) AfterEach(ctx context.Context) error {
return f.Tx.Rollback()
}The fixture’s BeforeEach begins a transaction before each test, and AfterEach rolls it back afterward. Suite hooks and test methods that query through the fixture’s Tx run inside that transaction, so no test’s writes survive into the next one. This gives you per-test isolation at the database level with no cleanup logic in the suite itself.
Fixture composition: stacking lifecycles
When one fixture depends on another, their lifecycles nest. Dependencies are expressed as pointer fields on the fixture struct:
type ServiceFixture struct {
DB *DatabaseFixture
Cache *CacheFixture
}The generator resolves the dependency graph at build time and orders startup accordingly. Leaf dependencies start first, composed fixtures start after their dependencies are ready, and teardown happens in reverse:
The graph resolution is static. The generator reads the struct fields, builds a DAG, topologically sorts it, and emits the startup and teardown calls in the correct order. If there is a cycle, the generator reports it at build time, not at test execution time.
BeforeAll retry support
Fixtures that depend on external services face transient failures. A container might take longer to start than expected. A network connection might fail on the first attempt. gotest supports retry configuration for fixture BeforeAll through the FixtureConfig method:
func (f *DatabaseFixture) FixtureConfig() gotest.FixtureConfig {
return gotest.ContainerFixtureConfig()
// 5-minute timeout, 1 retry with 5s delay
}For custom values:
func (f *DatabaseFixture) FixtureConfig() gotest.FixtureConfig {
return gotest.FixtureConfig{
Timeout: 3 * time.Minute,
Retries: 2,
RetryDelay: 10 * time.Second,
}
}The retry applies only to BeforeAll. If a fixture’s BeforeAll returns an error, the runtime waits for RetryDelay, then calls BeforeAll again, up to Retries times. The Timeout applies to each individual attempt, enforced through the context.Context passed to the hook. The default configuration (DefaultFixtureConfig) has a 2-minute timeout with no retries. ContainerFixtureConfig extends this to 5 minutes with 1 retry and a 5-second delay, tuned for container startup times.
The complete timeline
Here is the full lifecycle of a test run, from start to finish, with a composed fixture. This is the mental model to internalize:
- Generator resolves the fixture dependency graph (build time). Struct fields form a DAG. Topological sort determines startup order. Cycles are rejected.
- Test binary starts.
go testcompiles and runs the generated code. - Leaf fixtures’
BeforeAllruns (in dependency order).DatabaseFixture.BeforeAllbeforeServiceFixture.BeforeAll. Each gets acontext.Contextwith the configured timeout. Failures are retried according toFixtureConfig. - Composed fixtures’
BeforeAllruns (after their dependencies).ServiceFixture.BeforeAllcan safely referencef.DB.DBbecause theDatabaseFixtureis already initialized. - Suite’s
BeforeAllruns. The suite can reference fixture fields that were populated in the fixture’sBeforeAll. - For each test method:
- Fixture’s
BeforeEach(outer layer) - Suite’s
BeforeEach(returnsctxif applicable) - Test method executes (receives
ctxifBeforeEachreturned one) - Suite’s
AfterEach(receivesctx) - Fixture’s
AfterEach(outer layer)
- Fixture’s
- Suite’s
AfterAllruns (viat.Cleanup, guaranteed). - Composed fixtures’
AfterAllruns. - Leaf fixtures’
AfterAllruns (reverse dependency order).DatabaseFixture.AfterAllruns last because other fixtures may still reference its resources during their own teardown.
The per-test steps repeat for every test method. If the suite uses SuiteConfig{Parallel: true} with a returning BeforeEach, the test methods run concurrently, each with their own ctx from the suite’s BeforeEach and their own fixture BeforeEach/AfterEach wrapping.
The cleanup guarantee applies at every level. Fixture
AfterAllruns even if the suite’sBeforeAllpanics. SuiteAfterEachruns even if the test method callst.Fatal. The generated code registers teardown before calling setup, so the cleanup path is always in place before anything can go wrong.
When to use which hook
The lifecycle gives you four levels of setup and teardown, split across suites and fixtures. Choosing the right one is a matter of cost and scope:
BeforeAll/AfterAllare for expensive, shared resources. Database connections, container startup, service initialization. These run once and are amortized across all tests in the suite. If setup takes more than a few milliseconds, it probably belongs here.BeforeEach/AfterEachare for per-test isolation. Transactions, temp directories, fresh state. These ensure each test starts clean. The cost must be low enough to pay on every test.- Returning
BeforeEachis for when tests need isolated context and you want method-level parallelism. The returned value gives each test its own state object, making concurrent execution safe without locks. - Fixture hooks are for infrastructure that multiple suites share. If two suites both need a Postgres connection, extract the connection management into a
DatabaseFixturewith its ownBeforeAll/AfterAll. The suites reference the fixture; the fixture manages the resource.
A common pattern combines these levels: a fixture’s BeforeAll starts a database, the fixture’s BeforeEach begins a transaction, the suite’s returning BeforeEach creates service instances using that transaction, and the fixture’s AfterEach rolls back. Each level handles one concern.
The lifecycle in one example
The lifecycle is designed so that each layer can be reasoned about independently. Fixtures do not need to know about suite hooks. Suites do not need to know about fixture composition. The generator wires them together in the correct order, and the cleanup guarantee ensures that teardown happens regardless of how tests terminate.
If you remember one thing from this post, make it this: teardown is registered before setup runs. That single property is what makes the entire lifecycle reliable. Everything else follows from it.
For the fixture patterns that this lifecycle supports, see Test Fixtures in Go. For sharing fixtures across package boundaries, see Shared Fixtures. For the full API surface, see the reference documentation.
See these lifecycle guarantees in your own suite.
go install github.com/mvrahden/go-test/cmd/gotest@latest