Migrations & Testing
Database schemas change over time. Migrations track those changes in version-controlled files that can be applied forward and rolled back. Testing database code requires a real database -- mocking SQL leads to tests that pass while production breaks. This topic covers migration tools, testing with real databases, and patterns for reliable database tests.
Migration Tools
goose
goose is a lightweight migration tool that supports SQL and Go migrations:
go install github.com/pressly/goose/v3/cmd/goose@latest
Create a migration:
goose -dir migrations create add_users_table sql
This creates a timestamped file:
-- migrations/20250115100000_add_users_table.sql
-- +goose Up
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_users_email ON users (email);
-- +goose Down
DROP TABLE users;
Run migrations:
goose -dir migrations postgres "postgres://user:pass@localhost:5432/mydb" up
golang-migrate
golang-migrate uses numbered pairs of up/down files:
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
migrate create -ext sql -dir migrations -seq add_users_table
This creates two files:
migrations/
000001_add_users_table.up.sql
000001_add_users_table.down.sql
-- 000001_add_users_table.up.sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 000001_add_users_table.down.sql
DROP TABLE users;
Run migrations:
migrate -path migrations -database "postgres://user:pass@localhost:5432/mydb" up
Choosing Between Them
Feature goose golang-migrate
--------------------------------------------------
SQL migrations yes yes
Go migrations yes yes
File naming timestamps sequential numbers
Embedding go:embed go:embed
Popularity growing established
Both work well. Pick one and stick with it.
Running Migrations in Code
Embed migrations in your binary and run them at startup:
import (
"embed"
"database/sql"
"github.com/pressly/goose/v3"
)
//go:embed migrations/*.sql
var migrations embed.FS
func runMigrations(db *sql.DB) error {
goose.SetBaseFS(migrations)
if err := goose.SetDialect("postgres"); err != nil {
return err
}
return goose.Up(db, "migrations")
}
Run migrations before starting the server:
func main() {
db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
if err := runMigrations(db); err != nil {
log.Fatal("migrations failed:", err)
}
// Start server...
}
Best Practices for Migrations
- Every migration must have a down. Even if you never roll back in production, the down migration is essential for development and testing.
- Never edit a migration that has been applied. Create a new migration instead. Editing an applied migration causes checksum mismatches.
- Keep migrations small. One logical change per migration. "Add users table" and "add orders table" should be separate migrations.
- Test migrations against a real database. A migration that works on SQLite may fail on PostgreSQL.
Testing with a Real Database
Mocking database/sql is painful and gives false confidence. The queries that matter -- JOINs, constraints, indexes -- only work against a real database. Use testcontainers-go to spin up a real PostgreSQL instance for tests.
testcontainers-go
import (
"context"
"database/sql"
"testing"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
container, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2),
),
)
if err != nil {
t.Fatal("starting postgres container:", err)
}
t.Cleanup(func() { container.Terminate(ctx) })
connStr, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatal("getting connection string:", err)
}
db, err := sql.Open("pgx", connStr)
if err != nil {
t.Fatal("opening database:", err)
}
t.Cleanup(func() { db.Close() })
if err := runMigrations(db); err != nil {
t.Fatal("running migrations:", err)
}
return db
}
Using the Test Database
func TestUserStore_Create(t *testing.T) {
db := setupTestDB(t)
store := NewUserStore(db)
user := &User{Name: "Alice", Email: "alice@example.com"}
err := store.Create(context.Background(), user)
if err != nil {
t.Fatal("creating user:", err)
}
if user.ID == 0 {
t.Error("expected user ID to be set")
}
// Verify it was persisted
got, err := store.GetByID(context.Background(), user.ID)
if err != nil {
t.Fatal("getting user:", err)
}
if got.Name != "Alice" {
t.Errorf("got name %q, want %q", got.Name, "Alice")
}
}
Truncating Between Tests
Tests should not depend on each other. Truncate all tables between tests:
func truncateTables(t *testing.T, db *sql.DB) {
t.Helper()
tables := []string{"users", "orders", "products"}
for _, table := range tables {
_, err := db.Exec("TRUNCATE TABLE " + table + " CASCADE")
if err != nil {
t.Fatal("truncating", table, ":", err)
}
}
}
func TestUserStore_List(t *testing.T) {
db := setupTestDB(t)
truncateTables(t, db)
store := NewUserStore(db)
// Test starts with empty tables...
}
For faster tests, share one container across all tests in a package and truncate between each test:
var testDB *sql.DB
func TestMain(m *testing.M) {
// Setup container once for all tests
ctx := context.Background()
container, db := startContainer(ctx)
testDB = db
code := m.Run()
db.Close()
container.Terminate(ctx)
os.Exit(code)
}
func TestUserCreate(t *testing.T) {
truncateTables(t, testDB)
// ...
}
t.Cleanup for Teardown
t.Cleanup registers functions that run after the test (and its subtests) finish. It is Go's answer to setUp/tearDown:
func setupTestUser(t *testing.T, db *sql.DB) *User {
t.Helper()
user := &User{Name: "Test User", Email: "test@example.com"}
_, err := db.Exec(
"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id",
user.Name, user.Email,
)
if err != nil {
t.Fatal("inserting test user:", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM users WHERE email = $1", user.Email)
})
return user
}
Cleanup functions run in LIFO order (last registered runs first), so resources are torn down in the reverse order of creation.
Build Tags for Integration Tests
Integration tests (those needing a database) are slower than unit tests. Separate them with build tags:
//go:build integration
package store_test
import "testing"
func TestUserStore_Integration(t *testing.T) {
db := setupTestDB(t)
// ...
}
Run only unit tests (fast):
go test ./...
Run integration tests too:
go test -tags integration ./...
This keeps go test fast during development while CI runs the full suite.
Testing Transactions
Test that transactions actually roll back on failure:
func TestTransfer_InsufficientFunds(t *testing.T) {
db := setupTestDB(t)
truncateTables(t, db)
// Setup: account with $100
_, err := db.Exec(
"INSERT INTO accounts (id, balance) VALUES ($1, $2)", 1, 100.0,
)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(
"INSERT INTO accounts (id, balance) VALUES ($1, $2)", 2, 0.0,
)
if err != nil {
t.Fatal(err)
}
// Attempt transfer of $200 from account with $100
err = TransferFunds(context.Background(), db, 1, 2, 200.0)
if err == nil {
t.Fatal("expected error for insufficient funds")
}
// Verify no money moved (transaction rolled back)
var balance float64
db.QueryRow("SELECT balance FROM accounts WHERE id = $1", 1).Scan(&balance)
if balance != 100.0 {
t.Errorf("source balance = %f, want 100.0", balance)
}
}
Common Pitfalls
- Mocking the database instead of testing against a real one. Mocks do not catch SQL syntax errors, constraint violations, or type mismatches. Use testcontainers for real database tests.
- Not cleaning up between tests. Tests that depend on data from other tests are flaky. Truncate tables or use transactions that roll back.
- Editing applied migrations. Once a migration is applied (especially in production), it is immutable. Create a new migration for changes.
- Running integration tests in every go test invocation. Use build tags to separate fast unit tests from slow integration tests.
- Sharing test database state across parallel tests. If tests run in parallel and share a database, use separate schemas or unique data to avoid conflicts.
- Forgetting to run migrations in test setup. The test database starts empty. Always run migrations before testing.
Key Takeaways
- Use goose or golang-migrate for versioned, reversible database migrations.
- Embed migrations in the binary with
go:embedand run them at application startup. - Test database code against a real database using testcontainers-go.
- Truncate tables between tests to ensure isolation.
- Use
t.Cleanupfor teardown andTestMainfor one-time setup. - Separate integration tests with
//go:build integrationbuild tags. - Never mock
database/sql. Test the real queries against a real database.