Go Integration Tests: Build Tags and TestMain
Every Go project eventually faces the same problem: your test suite grows, and suddenly `go test ./...` takes five minutes because it's spinning up database connections, hitting external APIs, and...
Key Insights
- Build tags (
//go:build integration) let you separate slow integration tests from fast unit tests, giving you precise control over which tests run in different environments TestMainprovides a single entry point to set up expensive resources (databases, containers, connections) once for an entire test package rather than per-test- Combining both patterns creates a robust testing strategy where integration tests only run when explicitly requested and share properly managed infrastructure
The Integration Test Challenge
Every Go project eventually faces the same problem: your test suite grows, and suddenly go test ./... takes five minutes because it’s spinning up database connections, hitting external APIs, and running complex scenarios that don’t belong in a quick feedback loop.
The symptoms are predictable. Developers stop running tests locally because they’re too slow. CI pipelines become bottlenecks. Someone accidentally runs integration tests without a database running and gets cryptic connection errors. Test data from one package pollutes another.
Go provides two mechanisms that solve these problems elegantly: build tags for selective test execution and TestMain for test lifecycle control. Used together, they create a clean separation between fast unit tests and slower integration tests while ensuring your test infrastructure is properly managed.
Build Tags for Test Separation
Build tags (or build constraints) tell the Go compiler to include or exclude files from compilation based on conditions. For testing, this means you can mark certain test files as integration tests and only run them when explicitly requested.
The syntax uses a special comment at the top of your file:
//go:build integration
package repository
import (
"testing"
)
func TestUserRepository_Create(t *testing.T) {
// This test hits a real database
repo := NewUserRepository(testDB)
user, err := repo.Create(ctx, &User{
Email: "test@example.com",
Name: "Test User",
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
if user.ID == 0 {
t.Error("expected user to have an ID after creation")
}
}
The //go:build integration line must be the first line of the file (before the package declaration). There must be a blank line between the build constraint and the package statement.
Running tests becomes straightforward:
# Run only unit tests (files without the integration tag)
go test ./...
# Run only integration tests
go test -tags=integration ./...
# Run both unit and integration tests
go test -tags=integration ./...
Wait—the last two commands look the same. That’s intentional. When you specify -tags=integration, Go includes files with that tag in addition to files without any tags. If you want integration tests to run only when explicitly requested, your unit test files don’t need any special tags.
For more complex scenarios, you can combine tags:
//go:build integration && postgres
package repository
This file only compiles when both integration and postgres tags are specified:
go test -tags="integration,postgres" ./...
Understanding TestMain
Every Go test package has an implicit entry point that runs your test functions. TestMain lets you replace that entry point with your own function, giving you hooks before and after all tests in a package run.
package repository
import (
"os"
"testing"
)
func TestMain(m *testing.M) {
// Setup code runs before any tests
code := m.Run() // Execute all tests in this package
// Teardown code runs after all tests complete
os.Exit(code) // Exit with the test result code
}
The m.Run() call executes all test functions and returns an exit code (0 for success, 1 for failure). You must call os.Exit() with this code, or your test binary won’t properly report failures to the calling process.
When do you need TestMain? Common scenarios include:
- Setting up a database connection pool shared across tests
- Starting test containers or mock servers
- Loading test fixtures or configuration
- Establishing connections to external services
- Global cleanup that must happen regardless of test outcomes
Without TestMain, you’d either duplicate setup code in every test function or use init() functions that can’t guarantee cleanup runs.
Setting Up Test Infrastructure with TestMain
Here’s where TestMain proves its worth. Instead of each test function creating and destroying database connections, you set up the infrastructure once:
//go:build integration
package repository
import (
"context"
"database/sql"
"log"
"os"
"testing"
"time"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
_ "github.com/lib/pq"
)
var testDB *sql.DB
func TestMain(m *testing.M) {
ctx := context.Background()
// Start PostgreSQL container
pgContainer, 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).
WithStartupTimeout(5*time.Second),
),
)
if err != nil {
log.Fatalf("failed to start postgres container: %v", err)
}
// Get connection string
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
log.Fatalf("failed to get connection string: %v", err)
}
// Connect to database
testDB, err = sql.Open("postgres", connStr)
if err != nil {
log.Fatalf("failed to connect to database: %v", err)
}
// Run migrations
if err := runMigrations(testDB); err != nil {
log.Fatalf("failed to run migrations: %v", err)
}
// Execute tests
code := m.Run()
// Cleanup
testDB.Close()
if err := pgContainer.Terminate(ctx); err != nil {
log.Printf("failed to terminate container: %v", err)
}
os.Exit(code)
}
func runMigrations(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`)
return err
}
Now every test function in this package can use testDB without worrying about setup or teardown. The container starts once, tests run, and cleanup happens automatically.
Combining Build Tags and TestMain
The real power comes from combining both patterns. Build tags ensure your integration tests don’t run accidentally, while TestMain manages the expensive infrastructure setup.
Here’s a complete example with Redis:
//go:build integration
package cache
import (
"context"
"log"
"os"
"testing"
"time"
"github.com/redis/go-redis/v9"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
var redisClient *redis.Client
func TestMain(m *testing.M) {
ctx := context.Background()
req := testcontainers.ContainerRequest{
Image: "redis:7-alpine",
ExposedPorts: []string{"6379/tcp"},
WaitingFor: wait.ForLog("Ready to accept connections"),
}
redisContainer, err := testcontainers.GenericContainer(ctx,
testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
log.Fatalf("failed to start redis: %v", err)
}
host, _ := redisContainer.Host(ctx)
port, _ := redisContainer.MappedPort(ctx, "6379")
redisClient = redis.NewClient(&redis.Options{
Addr: host + ":" + port.Port(),
})
// Verify connection
if err := redisClient.Ping(ctx).Err(); err != nil {
log.Fatalf("failed to ping redis: %v", err)
}
code := m.Run()
redisClient.Close()
redisContainer.Terminate(ctx)
os.Exit(code)
}
func TestCache_SetAndGet(t *testing.T) {
ctx := context.Background()
cache := NewCache(redisClient)
err := cache.Set(ctx, "user:123", `{"name":"Alice"}`, time.Minute)
if err != nil {
t.Fatalf("Set failed: %v", err)
}
val, err := cache.Get(ctx, "user:123")
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if val != `{"name":"Alice"}` {
t.Errorf("unexpected value: %s", val)
}
}
func TestCache_Expiration(t *testing.T) {
ctx := context.Background()
cache := NewCache(redisClient)
err := cache.Set(ctx, "temp:key", "value", 100*time.Millisecond)
if err != nil {
t.Fatalf("Set failed: %v", err)
}
time.Sleep(150 * time.Millisecond)
_, err = cache.Get(ctx, "temp:key")
if err != ErrNotFound {
t.Errorf("expected ErrNotFound, got: %v", err)
}
}
Running go test ./... skips this file entirely. Running go test -tags=integration ./... starts the Redis container, runs both tests, and cleans up.
CI/CD Integration
Your build system needs clear targets for different test types. A Makefile keeps this simple:
.PHONY: test test-unit test-integration test-all
test: test-unit
test-unit:
go test -race -short ./...
test-integration:
go test -race -tags=integration ./...
test-all: test-unit test-integration
test-coverage:
go test -race -tags=integration -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
For GitHub Actions, separate jobs give you parallel execution and clearer failure reporting:
name: Tests
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- run: make test-unit
integration-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- run: make test-integration
Common Pitfalls and Best Practices
Forgetting os.Exit: If you don’t call os.Exit(m.Run()), test failures won’t propagate to CI systems. Your pipeline will show green even when tests fail.
Cleanup on panic: If setup code panics, your cleanup code never runs. Use defer for critical cleanup, but be aware that os.Exit doesn’t run deferred functions. For critical cleanup, consider using a cleanup function that handles both normal and panic scenarios:
func TestMain(m *testing.M) {
var code int
defer func() {
cleanup() // Always runs
os.Exit(code)
}()
setup()
code = m.Run()
}
Test isolation: Shared infrastructure doesn’t mean shared state. Clean up test data between tests or use unique keys/identifiers per test to prevent pollution.
Environment variables: Don’t hardcode connection strings. Use environment variables with sensible defaults for local development:
dbHost := os.Getenv("TEST_DB_HOST")
if dbHost == "" {
dbHost = "localhost"
}
Parallel tests: If you’re using t.Parallel(), ensure your tests don’t conflict over shared resources. Use unique identifiers or separate namespaces per test.
The combination of build tags and TestMain gives you a testing architecture that scales. Unit tests stay fast for rapid feedback. Integration tests run reliably with proper infrastructure management. CI pipelines can run them separately or together. That’s the foundation of a test suite developers actually want to run.