The state trap
You write a function that saves a user to the database. You run the test, it passes. You run it again, it fails because the user already exists. You delete the database, run again, it passes. You add a second test, now they fight over the connection. This is the classic integration test trap. Your code talks to real infrastructure, and real infrastructure has state.
Tests that touch a database must manage that state carefully. If one test leaves data behind, the next test sees a dirty world. If tests share a connection without isolation, they race against each other. Go gives you tools to handle this, but you have to use them in the right order.
Integration tests verify the boundary
Integration tests check that your Go code talks correctly to the outside world. The database, the network, the file system. A unit test mocks everything. An integration test uses the real thing, or something close enough that bugs in the real thing show up.
Think of it like testing a recipe. A unit test checks if you chopped the onion correctly. An integration test actually cooks the soup and tastes it. If the salt is wrong, the unit test won't catch it. The integration test will.
The goal is isolation. Each test should start with a known state, run its operation, and leave no trace. The next test should never know the previous one ran.
Mock the world, test the logic. Test the boundary, trust the world.
Minimal test structure
Here's the skeleton of a database test: set up the resource, run isolated subtests, and tear down automatically.
func TestUserRepo(t *testing.T) {
// setupTestDB creates a fresh in-memory SQLite database
// This isolates the test from the production database
db, cleanup := setupTestDB(t)
// t.Cleanup registers the function to run after the test finishes
// It guarantees resources are released even on failure or panic
t.Cleanup(cleanup)
// t.Run creates a subtest with its own lifecycle
// Subtests can be run independently with -run flag
t.Run("Insert", func(t *testing.T) {
err := db.InsertUser("alice")
if err != nil {
// t.Fatal stops the test immediately and marks it failed
t.Fatal(err)
}
})
t.Run("Select", func(t *testing.T) {
user, err := db.GetUser("alice")
if err != nil {
t.Fatal(err)
}
// Verify the data matches expectations
if user.Name != "alice" {
t.Fatal("name mismatch")
}
})
}
Subtests isolate logic. Cleanup guarantees resources. t.Run is your friend.
Walkthrough: lifecycle and cleanup
When you run go test, the testing package calls TestUserRepo. The setup function spins up a temporary database. In SQLite, :memory: creates a database that lives only in RAM. It vanishes when the connection closes.
The t.Cleanup call registers the teardown function. The testing framework calls this function after the test function returns, or if a panic occurs. This is safer than defer because t.Cleanup also runs after subtests complete. If you use defer in the parent test, it runs before subtests finish, which can close the database while subtests are still running.
The subtests run sequentially by default. Each subtest can be targeted individually. If "Insert" fails, "Select" still runs unless you use t.Skip or the failure stops the parent. t.Fatal stops the current test function. It does not stop sibling subtests.
The testing package tracks line numbers for failures. If setupTestDB calls t.Fatal, the error report points to the setup function. Add t.Helper() as the first line of setupTestDB so the error points to the test that called it. This is a small convention that saves debugging time.
t.Cleanup runs after subtests. defer runs before. Choose cleanup.
Realistic repository test
Real code usually involves a repository struct, context propagation, and error wrapping. Here's a more complete example showing how to structure the setup and handle errors properly.
// UserRepo wraps database access for user data
type UserRepo struct {
db *sql.DB
}
// CreateUser inserts a user into the database
// Context allows cancellation if the operation takes too long
func (r *UserRepo) CreateUser(ctx context.Context, name string) error {
query := "INSERT INTO users (name) VALUES (?)"
// ExecContext respects context cancellation and deadlines
_, err := r.db.ExecContext(ctx, query, name)
if err != nil {
// Wrap the error to preserve the call stack
// The %w verb allows callers to unwrap the error later
return fmt.Errorf("create user %s: %w", name, err)
}
return nil
}
func TestUserRepo_CreateUser(t *testing.T) {
// setupTestDB creates a fresh in-memory database
// It also runs the migration to create the users table
db := setupTestDB(t)
// Close the database connection when the test finishes
t.Cleanup(db.Close)
repo := &UserRepo{db: db}
t.Run("Success", func(t *testing.T) {
ctx := context.Background()
err := repo.CreateUser(ctx, "bob")
if err != nil {
// t.Fatalf formats the error message and fails the test
t.Fatalf("expected no error, got %v", err)
}
})
t.Run("Duplicate", func(t *testing.T) {
ctx := context.Background()
// Insert first to create the conflict condition
if err := repo.CreateUser(ctx, "bob"); err != nil {
t.Fatalf("setup failed: %v", err)
}
// Attempting to insert again should fail
err := repo.CreateUser(ctx, "bob")
if err == nil {
t.Fatal("expected error for duplicate, got nil")
}
})
}
Context flows. Errors wrap. Receivers name.
The receiver is named r for UserRepo. This matches the type initial. The community convention is one or two letters. context.Context is the first parameter. Functions that take a context should respect cancellation and deadlines. Error wrapping uses %w so callers can check the error chain.
If you close the database too early, the compiler won't stop you, but the runtime panics with sql: database is closed. If you share a global database variable, tests interfere. You might see UNIQUE constraint failed when a previous test didn't clean up.
Pitfalls: shared state and cleanup
The most common bug is shared state. If tests share a database connection and one test crashes, the connection might be left in a bad state. Subsequent tests fail for unrelated reasons.
Using t.Parallel with a shared database causes race conditions. The runtime might panic with concurrent map writes or the database driver complains about concurrent access. Only use t.Parallel when tests are fully isolated.
Another pitfall is forgetting to handle context cancellation. A long-running query might hang the test suite. The test runner eventually kills the process, or you see a timeout error. Always pass a context with a deadline in tests if the operation could block.
Transactions offer a fast way to isolate tests. For databases that support transactions, you can wrap the entire test in a transaction and roll it back at the end. This avoids deleting rows and keeps the database pristine.
func TestWithTransaction(t *testing.T) {
db := setupTestDB(t)
t.Cleanup(db.Close)
// Begin a transaction to isolate test data
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
// Rollback discards all changes made during the test
// This is faster than deleting rows and safer than truncating
t.Cleanup(func() { tx.Rollback() })
// Pass the transaction to the repository
// The repo must accept an interface or handle *sql.Tx
repo := &UserRepo{db: tx}
ctx := context.Background()
err = repo.CreateUser(ctx, "tx-user")
if err != nil {
t.Fatal(err)
}
}
Transactions are fast. Rollback is your friend. Don't leave data behind.
Decision matrix
Use an in-memory database like SQLite when you need fast, isolated tests that don't require a running server. Use a test container with Docker when you need to test against the exact production database engine and features. Use a mock repository when you are testing business logic that doesn't depend on database behavior. Use t.Cleanup when you need to guarantee resource teardown even if subtests or panics occur. Use t.Parallel when your tests are fully isolated and don't share mutable state. Use a transaction with rollback when the database supports it and you want to avoid cleanup queries. Use t.Helper in setup functions so error reports point to the test, not the helper.