How to Write Integration Tests in Go

Integration tests verify that multiple components of your application work correctly together. Unlike unit tests that isolate individual functions with mocks, integration tests exercise real...

Key Insights

  • Integration tests in Go should use build tags like //go:build integration to separate them from unit tests, allowing you to run fast unit tests during development and comprehensive integration tests in CI/CD pipelines.
  • Use testcontainers-go or dockertest to spin up real database instances for testing rather than mocking—this catches actual database-specific bugs and ensures your queries work as expected in production.
  • Always use t.Cleanup() or defer for resource teardown in integration tests to prevent port conflicts, database connection leaks, and orphaned Docker containers that will break subsequent test runs.

Introduction to Integration Testing in Go

Integration tests verify that multiple components of your application work correctly together. Unlike unit tests that isolate individual functions with mocks, integration tests exercise real dependencies—databases, HTTP servers, message queues, and external APIs.

You need integration tests when unit tests can’t catch certain bugs. A perfectly unit-tested repository layer might still fail when hitting a real PostgreSQL database due to SQL syntax differences, transaction isolation issues, or constraint violations. Integration tests catch these problems.

Go’s built-in testing package works excellently for integration tests. You don’t need separate frameworks—the same go test command, table-driven test patterns, and helper functions apply. The key is organizing and running integration tests separately from your fast unit tests.

Setting Up the Test Environment

Integration tests belong in separate files marked with build tags. This lets developers run quick unit tests locally while CI/CD runs the full suite.

Create integration test files with the _test.go suffix and add a build tag at the top:

//go:build integration

package api_test

import (
    "context"
    "testing"
    "time"
)

func TestUserRegistrationFlow(t *testing.T) {
    // Integration test code
}

Run integration tests explicitly:

# Run only unit tests (default)
go test ./...

# Run only integration tests
go test -tags=integration ./...

# Run everything
go test -tags=integration ./... && go test ./...

For test fixtures and data, avoid checked-in JSON or SQL files when possible. Generate test data programmatically in your tests—it’s more maintainable and makes test intent clearer. When you must use fixtures, place them in a testdata directory (Go tooling ignores this by convention).

//go:build integration

package repository_test

import (
    "testing"
)

// TestMain runs once before all tests in the package
func TestMain(m *testing.M) {
    // Global setup
    code := m.Run()
    // Global teardown
    os.Exit(code)
}

Testing Database Interactions

Testing against real databases catches bugs that mocks miss. Use testcontainers-go to spin up actual database instances in Docker containers.

Here’s a complete PostgreSQL integration test with setup and teardown:

//go:build integration

package repository_test

import (
    "context"
    "database/sql"
    "testing"
    "time"

    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/postgres"
    "github.com/testcontainers/testcontainers-go/wait"
    _ "github.com/lib/pq"
)

var testDB *sql.DB

func TestMain(m *testing.M) {
    ctx := context.Background()
    
    // Start PostgreSQL container
    postgresContainer, err := postgres.RunContainer(ctx,
        testcontainers.WithImage("postgres:15-alpine"),
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("testuser"),
        postgres.WithPassword("testpass"),
        testcontainers.WithWaitStrategy(
            wait.ForLog("database system is ready to accept connections").
                WithOccurrence(2).
                WithStartupTimeout(5*time.Second)),
    )
    if err != nil {
        panic(err)
    }
    defer postgresContainer.Terminate(ctx)
    
    // Get connection string
    connStr, err := postgresContainer.ConnectionString(ctx, "sslmode=disable")
    if err != nil {
        panic(err)
    }
    
    // Connect to database
    testDB, err = sql.Open("postgres", connStr)
    if err != nil {
        panic(err)
    }
    defer testDB.Close()
    
    // Run migrations
    if err := runMigrations(testDB); err != nil {
        panic(err)
    }
    
    // Run tests
    os.Exit(m.Run())
}

func runMigrations(db *sql.DB) error {
    schema := `
        CREATE TABLE users (
            id SERIAL PRIMARY KEY,
            email VARCHAR(255) UNIQUE NOT NULL,
            created_at TIMESTAMP DEFAULT NOW()
        );
    `
    _, err := db.Exec(schema)
    return err
}

func TestUserRepository_Create(t *testing.T) {
    ctx := context.Background()
    repo := NewUserRepository(testDB)
    
    user := &User{Email: "test@example.com"}
    err := repo.Create(ctx, user)
    
    if err != nil {
        t.Fatalf("failed to create user: %v", err)
    }
    
    if user.ID == 0 {
        t.Error("expected user ID to be set")
    }
    
    // Verify in database
    var count int
    err = testDB.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", 
        user.Email).Scan(&count)
    if err != nil {
        t.Fatalf("failed to query user: %v", err)
    }
    
    if count != 1 {
        t.Errorf("expected 1 user, got %d", count)
    }
}

For each test, clean up test data to ensure isolation. Use transactions or truncate tables:

func setupTest(t *testing.T) func() {
    // Clean tables before test
    testDB.Exec("TRUNCATE users CASCADE")
    
    return func() {
        // Cleanup after test
        testDB.Exec("TRUNCATE users CASCADE")
    }
}

func TestUserRepository_FindByEmail(t *testing.T) {
    cleanup := setupTest(t)
    defer cleanup()
    
    // Test code with clean database state
}

Testing HTTP APIs and External Services

Integration tests for HTTP APIs should test the entire request/response cycle, including middleware, routing, and database interactions.

//go:build integration

package api_test

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    
    "yourapp/api"
    "yourapp/repository"
)

func TestAPI_CreateUser(t *testing.T) {
    // Setup: Create real database connection (from TestMain)
    repo := repository.NewUserRepository(testDB)
    handler := api.NewHandler(repo)
    
    // Prepare request
    reqBody := map[string]string{
        "email": "newuser@example.com",
    }
    body, _ := json.Marshal(reqBody)
    
    req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body))
    req.Header.Set("Content-Type", "application/json")
    
    // Execute request
    rec := httptest.NewRecorder()
    handler.ServeHTTP(rec, req)
    
    // Assert response
    if rec.Code != http.StatusCreated {
        t.Errorf("expected status 201, got %d", rec.Code)
    }
    
    var response map[string]interface{}
    json.NewDecoder(rec.Body).Decode(&response)
    
    if response["email"] != "newuser@example.com" {
        t.Errorf("unexpected email in response: %v", response["email"])
    }
    
    // Verify database side effect
    var count int
    testDB.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1",
        "newuser@example.com").Scan(&count)
    
    if count != 1 {
        t.Error("user was not persisted to database")
    }
}

For external services, decide whether to use real endpoints or mocks based on reliability and cost. For third-party APIs (Stripe, SendGrid), use test/sandbox environments in integration tests. For internal microservices, consider running them in containers alongside your tests.

Best Practices and Patterns

Table-driven tests work excellently for integration tests, letting you verify multiple scenarios with different inputs:

func TestUserAPI_ValidationScenarios(t *testing.T) {
    handler := setupTestServer(t)
    
    tests := []struct {
        name           string
        payload        map[string]string
        expectedStatus int
        expectedError  string
    }{
        {
            name:           "valid user",
            payload:        map[string]string{"email": "valid@example.com"},
            expectedStatus: http.StatusCreated,
        },
        {
            name:           "invalid email",
            payload:        map[string]string{"email": "not-an-email"},
            expectedStatus: http.StatusBadRequest,
            expectedError:  "invalid email format",
        },
        {
            name:           "duplicate email",
            payload:        map[string]string{"email": "duplicate@example.com"},
            expectedStatus: http.StatusConflict,
            expectedError:  "email already exists",
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // Setup clean state
            t.Cleanup(func() {
                testDB.Exec("TRUNCATE users CASCADE")
            })
            
            // Pre-populate for duplicate test
            if tt.name == "duplicate email" {
                testDB.Exec("INSERT INTO users (email) VALUES ($1)", 
                    tt.payload["email"])
            }
            
            body, _ := json.Marshal(tt.payload)
            req := httptest.NewRequest(http.MethodPost, "/users", 
                bytes.NewReader(body))
            rec := httptest.NewRecorder()
            
            handler.ServeHTTP(rec, req)
            
            if rec.Code != tt.expectedStatus {
                t.Errorf("expected status %d, got %d", 
                    tt.expectedStatus, rec.Code)
            }
            
            if tt.expectedError != "" {
                var response map[string]string
                json.NewDecoder(rec.Body).Decode(&response)
                if response["error"] != tt.expectedError {
                    t.Errorf("expected error %q, got %q", 
                        tt.expectedError, response["error"])
                }
            }
        })
    }
}

Use t.Cleanup() instead of defer for resource cleanup—it runs even if the test panics and works correctly with parallel tests:

func TestWithCleanup(t *testing.T) {
    container := startTestContainer(t)
    t.Cleanup(func() {
        container.Terminate(context.Background())
    })
    
    // Test code - cleanup runs automatically
}

Be cautious with t.Parallel() in integration tests. Parallel execution speeds up test suites but requires careful database isolation—use separate schemas, databases, or ensure tests don’t conflict on shared resources.

In CI/CD, run integration tests in a separate pipeline stage after unit tests pass. This gives fast feedback from unit tests while ensuring integration tests run before deployment:

# GitHub Actions example
- name: Unit Tests
  run: go test -v ./...

- name: Integration Tests
  run: go test -v -tags=integration ./...

Common Pitfalls and Troubleshooting

Port conflicts occur when containers don’t clean up properly. Always use t.Cleanup() or defer to terminate containers:

func TestWithContainer(t *testing.T) {
    ctx := context.Background()
    container, err := postgres.RunContainer(ctx, /* ... */)
    if err != nil {
        t.Fatal(err)
    }
    
    t.Cleanup(func() {
        if err := container.Terminate(ctx); err != nil {
            t.Logf("failed to terminate container: %v", err)
        }
    })
    
    // Test code
}

Test isolation failures happen when tests share database state. Each test should start with a clean slate. Use transactions that rollback, truncate tables, or use separate database schemas per test:

func TestWithTransaction(t *testing.T) {
    tx, err := testDB.Begin()
    if err != nil {
        t.Fatal(err)
    }
    
    t.Cleanup(func() {
        tx.Rollback()
    })
    
    // Use tx instead of testDB for isolated test
    repo := repository.NewUserRepository(tx)
    // Test code
}

Timeout issues plague integration tests because containers and databases take time to start. Always set explicit timeouts and wait strategies:

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

container, err := postgres.RunContainer(ctx,
    testcontainers.WithWaitStrategy(
        wait.ForLog("ready to accept connections").
            WithOccurrence(2).
            WithStartupTimeout(10*time.Second)),
)

Integration tests are slower than unit tests—embrace this. Don’t try to make them fast by mocking everything; that defeats the purpose. Instead, run them less frequently (on commit to main, before deployment) while keeping unit tests fast for the development loop.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.