Mastering Integration Testing in Go with Testcontainers

February 4, 2026 ¿Ves algún error? Corregir artículo testcontainers-go-wallpaper

Integration tests are crucial for ensuring your application works correctly with external dependencies like databases, message queues, and caches. However, setting up and managing these dependencies for testing can be challenging. Testcontainers solves this problem by providing a clean API to run Docker containers as part of your test suite.

In this article, we'll explore how to use Testcontainers for Go to write reliable integration tests that run real databases and services in isolated containers.

What is Testcontainers?

Testcontainers is a library that provides lightweight, disposable instances of databases, message brokers, web browsers, or anything that can run in a Docker container. It's perfect for integration tests because:

  • Real dependencies: Test against actual databases, not mocks
  • Isolation: Each test can have its own container
  • Automatic cleanup: Containers are removed after tests complete
  • Reproducibility: Same environment every time
  • CI/CD friendly: Works seamlessly in containerized environments

Installation

First, let's install the Testcontainers library:

~
go get github.com/testcontainers/testcontainers-go

Make sure you have Docker running on your machine before running tests.

Basic Example: Testing with PostgreSQL

Let's start with a practical example. We'll create a simple user repository that stores data in PostgreSQL and write integration tests for it.

Project Structure

Our project will have the following structure:

internal

repository

user.go

user_test.go

go.mod

go.sum

User Repository Implementation

Let's create a simple user repository:

internal/repository/user.go
package repository import ( "context" "database/sql" "fmt" _ "github.com/lib/pq" ) type User struct { ID int Name string Email string } type UserRepository struct { db *sql.DB } func NewUserRepository(db *sql.DB) *UserRepository { return &UserRepository{db: db} } func (r *UserRepository) Create(ctx context.Context, user *User) error { query := `INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id` return r.db.QueryRowContext(ctx, query, user.Name, user.Email).Scan(&user.ID) } func (r *UserRepository) GetByID(ctx context.Context, id int) (*User, error) { query := `SELECT id, name, email FROM users WHERE id = $1` user := &User{} err := r.db.QueryRowContext(ctx, query, id).Scan(&user.ID, &user.Name, &user.Email) if err == sql.ErrNoRows { return nil, fmt.Errorf("user not found") } return user, err } func (r *UserRepository) GetAll(ctx context.Context) ([]*User, error) { query := `SELECT id, name, email FROM users ORDER BY id` rows, err := r.db.QueryContext(ctx, query) if err != nil { return nil, err } defer rows.Close() var users []*User for rows.Next() { user := &User{} if err := rows.Scan(&user.ID, &user.Name, &user.Email); err != nil { return nil, err } users = append(users, user) } return users, rows.Err() } func (r *UserRepository) Delete(ctx context.Context, id int) error { query := `DELETE FROM users WHERE id = $1` result, err := r.db.ExecContext(ctx, query, id) if err != nil { return err } rowsAffected, err := result.RowsAffected() if err != nil { return err } if rowsAffected == 0 { return fmt.Errorf("user not found") } return nil }

Integration Tests with Testcontainers

Now let's write integration tests using Testcontainers. We'll create a PostgreSQL container and run our tests against it:

internal/repository/user_test.go
package repository import ( "context" "database/sql" "fmt" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" ) func setupPostgresContainer(t *testing.T) (*sql.DB, func()) { ctx := context.Background() // Create PostgreSQL container req := testcontainers.ContainerRequest{ Image: "postgres:15-alpine", ExposedPorts: []string{"5432/tcp"}, Env: map[string]string{ "POSTGRES_USER": "testuser", "POSTGRES_PASSWORD": "testpass", "POSTGRES_DB": "testdb", }, WaitingFor: wait.ForLog("database system is ready to accept connections"). WithOccurrence(2). WithStartupTimeout(60 * time.Second), } container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) require.NoError(t, err) // Get connection details host, err := container.Host(ctx) require.NoError(t, err) port, err := container.MappedPort(ctx, "5432") require.NoError(t, err) // Connect to database dsn := fmt.Sprintf("postgres://testuser:testpass@%s:%s/testdb?sslmode=disable", host, port.Port()) db, err := sql.Open("postgres", dsn) require.NoError(t, err) require.NoError(t, db.Ping()) // Create schema _, err = db.Exec(` CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(255) UNIQUE NOT NULL ) `) require.NoError(t, err) // Return cleanup function cleanup := func() { db.Close() container.Terminate(ctx) } return db, cleanup } func TestUserRepository_Create(t *testing.T) { db, cleanup := setupPostgresContainer(t) defer cleanup() repo := NewUserRepository(db) ctx := context.Background() tests := map[string]struct { user *User expectError bool }{ "successful creation": { user: &User{ Name: "John Doe", Email: "john@example.com", }, expectError: false, }, "duplicate email": { user: &User{ Name: "Jane Doe", Email: "john@example.com", // Same email as previous test }, expectError: true, }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { err := repo.Create(ctx, tc.user) if tc.expectError { assert.Error(t, err) } else { assert.NoError(t, err) assert.NotZero(t, tc.user.ID) } }) } } func TestUserRepository_GetByID(t *testing.T) { db, cleanup := setupPostgresContainer(t) defer cleanup() repo := NewUserRepository(db) ctx := context.Background() // Create a test user testUser := &User{ Name: "Test User", Email: "test@example.com", } err := repo.Create(ctx, testUser) require.NoError(t, err) tests := map[string]struct { userID int expectError bool checkUser func(*testing.T, *User) }{ "existing user": { userID: testUser.ID, expectError: false, checkUser: func(t *testing.T, user *User) { assert.Equal(t, testUser.ID, user.ID) assert.Equal(t, testUser.Name, user.Name) assert.Equal(t, testUser.Email, user.Email) }, }, "non-existing user": { userID: 99999, expectError: true, checkUser: nil, }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { user, err := repo.GetByID(ctx, tc.userID) if tc.expectError { assert.Error(t, err) assert.Nil(t, user) } else { assert.NoError(t, err) assert.NotNil(t, user) if tc.checkUser != nil { tc.checkUser(t, user) } } }) } } func TestUserRepository_GetAll(t *testing.T) { db, cleanup := setupPostgresContainer(t) defer cleanup() repo := NewUserRepository(db) ctx := context.Background() // Create test users users := []*User{ {Name: "User 1", Email: "user1@example.com"}, {Name: "User 2", Email: "user2@example.com"}, {Name: "User 3", Email: "user3@example.com"}, } for _, user := range users { err := repo.Create(ctx, user) require.NoError(t, err) } // Get all users result, err := repo.GetAll(ctx) assert.NoError(t, err) assert.Len(t, result, 3) // Verify order and content for i, user := range result { assert.Equal(t, users[i].Name, user.Name) assert.Equal(t, users[i].Email, user.Email) } } func TestUserRepository_Delete(t *testing.T) { db, cleanup := setupPostgresContainer(t) defer cleanup() repo := NewUserRepository(db) ctx := context.Background() // Create a test user testUser := &User{ Name: "Delete Me", Email: "delete@example.com", } err := repo.Create(ctx, testUser) require.NoError(t, err) tests := map[string]struct { userID int expectError bool }{ "delete existing user": { userID: testUser.ID, expectError: false, }, "delete non-existing user": { userID: 99999, expectError: true, }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { err := repo.Delete(ctx, tc.userID) if tc.expectError { assert.Error(t, err) } else { assert.NoError(t, err) // Verify user is deleted _, err := repo.GetByID(ctx, tc.userID) assert.Error(t, err) } }) } }

Advanced Example: Testing with Redis

Let's add another example using Redis for caching. This demonstrates how to use multiple containers in your tests.

internal/cache/cache.go
package cache import ( "context" "encoding/json" "time" "github.com/redis/go-redis/v9" ) type Cache struct { client *redis.Client } func NewCache(client *redis.Client) *Cache { return &Cache{client: client} } func (c *Cache) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error { data, err := json.Marshal(value) if err != nil { return err } return c.client.Set(ctx, key, data, expiration).Err() } func (c *Cache) Get(ctx context.Context, key string, dest interface{}) error { data, err := c.client.Get(ctx, key).Bytes() if err != nil { return err } return json.Unmarshal(data, dest) } func (c *Cache) Delete(ctx context.Context, key string) error { return c.client.Del(ctx, key).Err() } func (c *Cache) Exists(ctx context.Context, key string) (bool, error) { count, err := c.client.Exists(ctx, key).Result() return count > 0, err }
internal/cache/cache_test.go
package cache import ( "context" "testing" "time" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" ) type TestData struct { ID int `json:"id"` Name string `json:"name"` } func setupRedisContainer(t *testing.T) (*redis.Client, func()) { ctx := context.Background() req := testcontainers.ContainerRequest{ Image: "redis:7-alpine", ExposedPorts: []string{"6379/tcp"}, WaitingFor: wait.ForLog("Ready to accept connections"), } container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) require.NoError(t, err) host, err := container.Host(ctx) require.NoError(t, err) port, err := container.MappedPort(ctx, "6379") require.NoError(t, err) client := redis.NewClient(&redis.Options{ Addr: host + ":" + port.Port(), }) require.NoError(t, client.Ping(ctx).Err()) cleanup := func() { client.Close() container.Terminate(ctx) } return client, cleanup } func TestCache_SetAndGet(t *testing.T) { client, cleanup := setupRedisContainer(t) defer cleanup() cache := NewCache(client) ctx := context.Background() testData := TestData{ ID: 1, Name: "Test Item", } // Set value err := cache.Set(ctx, "test:1", testData, 5*time.Minute) assert.NoError(t, err) // Get value var result TestData err = cache.Get(ctx, "test:1", &result) assert.NoError(t, err) assert.Equal(t, testData.ID, result.ID) assert.Equal(t, testData.Name, result.Name) } func TestCache_Expiration(t *testing.T) { client, cleanup := setupRedisContainer(t) defer cleanup() cache := NewCache(client) ctx := context.Background() testData := TestData{ID: 1, Name: "Expire Me"} // Set with short expiration err := cache.Set(ctx, "test:expire", testData, 1*time.Second) assert.NoError(t, err) // Verify it exists exists, err := cache.Exists(ctx, "test:expire") assert.NoError(t, err) assert.True(t, exists) // Wait for expiration time.Sleep(2 * time.Second) // Verify it's gone exists, err = cache.Exists(ctx, "test:expire") assert.NoError(t, err) assert.False(t, exists) } func TestCache_Delete(t *testing.T) { client, cleanup := setupRedisContainer(t) defer cleanup() cache := NewCache(client) ctx := context.Background() testData := TestData{ID: 1, Name: "Delete Me"} // Set value err := cache.Set(ctx, "test:delete", testData, 5*time.Minute) assert.NoError(t, err) // Delete value err = cache.Delete(ctx, "test:delete") assert.NoError(t, err) // Verify it's gone exists, err := cache.Exists(ctx, "test:delete") assert.NoError(t, err) assert.False(t, exists) }

Best Practices

1. Use Wait Strategies

Always use appropriate wait strategies to ensure containers are ready before running tests:

~
// Wait for specific log message WaitingFor: wait.ForLog("database system is ready"). WithOccurrence(2). WithStartupTimeout(60 * time.Second) // Wait for HTTP endpoint WaitingFor: wait.ForHTTP("/health"). WithPort("8080/tcp"). WithStartupTimeout(30 * time.Second) // Wait for listening port WaitingFor: wait.ForListeningPort("5432/tcp")

2. Proper Cleanup

Always ensure containers are terminated after tests:

~
func setupContainer(t *testing.T) (*sql.DB, func()) { // ... setup code ... cleanup := func() { db.Close() if err := container.Terminate(context.Background()); err != nil { t.Logf("failed to terminate container: %v", err) } } return db, cleanup } func TestSomething(t *testing.T) { db, cleanup := setupContainer(t) defer cleanup() // Always defer cleanup // ... test code ... }

3. Reuse Containers for Test Suites

For faster test execution, you can reuse containers across multiple tests:

~
var ( testDB *sql.DB testCleanup func() ) func TestMain(m *testing.M) { // Setup testDB, testCleanup = setupPostgresContainer(nil) // Run tests code := m.Run() // Cleanup testCleanup() os.Exit(code) } func TestFunction1(t *testing.T) { // Clean database before test _, err := testDB.Exec("TRUNCATE TABLE users") require.NoError(t, err) // Use testDB... } func TestFunction2(t *testing.T) { // Clean database before test _, err := testDB.Exec("TRUNCATE TABLE users") require.NoError(t, err) // Use testDB... }

4. Use Parallel Tests with Caution

When running tests in parallel with shared containers, ensure proper isolation:

~
func TestParallel(t *testing.T) { t.Run("Test1", func(t *testing.T) { t.Parallel() db, cleanup := setupPostgresContainer(t) defer cleanup() // Test with isolated container... }) t.Run("Test2", func(t *testing.T) { t.Parallel() db, cleanup := setupPostgresContainer(t) defer cleanup() // Test with isolated container... }) }

5. Environment Variables for Configuration

Make your tests flexible by using environment variables:

~
func getPostgresImage() string { image := os.Getenv("POSTGRES_IMAGE") if image == "" { return "postgres:15-alpine" } return image } func setupPostgresContainer(t *testing.T) (*sql.DB, func()) { req := testcontainers.ContainerRequest{ Image: getPostgresImage(), // ... rest of configuration } // ... }

Running Tests in CI/CD

Testcontainers works seamlessly in CI/CD pipelines. Here's an example GitHub Actions workflow:

~
name: Integration Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Go uses: actions/setup-go@v4 with: go-version: '1.22' - name: Run integration tests run: go test -v ./... env: TESTCONTAINERS_RYUK_DISABLED: false

Performance Considerations

1. Container Startup Time

Container startup can be slow. Consider these optimizations:

  • Use Alpine-based images when possible
  • Pull images before running tests
  • Reuse containers across tests when appropriate

2. Parallel Execution

For large test suites, run tests in parallel:

~
# Run tests in parallel with 4 workers go test -v -parallel 4 ./...

3. Skip in Short Mode

Allow developers to skip integration tests for faster feedback:

~
func TestIntegration(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode") } // ... rest of test } // Run: go test -short # Skips integration tests // Run: go test # Runs all tests

Common Patterns

Testing Database Migrations

~
func TestMigrations(t *testing.T) { db, cleanup := setupPostgresContainer(t) defer cleanup() // Apply migrations err := applyMigrations(db) require.NoError(t, err) // Verify schema var tableExists bool err = db.QueryRow(` SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_name = 'users' ) `).Scan(&tableExists) require.NoError(t, err) assert.True(t, tableExists) }

Testing Transaction Rollbacks

~
func TestTransactionRollback(t *testing.T) { db, cleanup := setupPostgresContainer(t) defer cleanup() repo := NewUserRepository(db) ctx := context.Background() // Start transaction tx, err := db.BeginTx(ctx, nil) require.NoError(t, err) // Create user in transaction user := &User{Name: "Test", Email: "test@example.com"} err = repo.CreateWithTx(ctx, tx, user) require.NoError(t, err) // Rollback err = tx.Rollback() require.NoError(t, err) // Verify user doesn't exist _, err = repo.GetByID(ctx, user.ID) assert.Error(t, err) }

Debugging Tips

1. Keep Containers Running

For debugging, you can keep containers running after tests fail:

~
cleanup := func() { if t.Failed() { t.Logf("Test failed, keeping container running") t.Logf("Container ID: %s", container.GetContainerID()) return } db.Close() container.Terminate(ctx) }

2. View Container Logs

Access container logs for debugging:

~
func setupWithLogs(t *testing.T) (*sql.DB, func()) { // ... setup container ... if t.Failed() { logs, err := container.Logs(context.Background()) if err == nil { defer logs.Close() logBytes, _ := io.ReadAll(logs) t.Logf("Container logs: %s", logBytes) } } // ... }

3. Execute Commands in Containers

Run commands inside containers for debugging:

~
// Execute psql command exitCode, reader, err := container.Exec(ctx, []string{ "psql", "-U", "testuser", "-d", "testdb", "-c", "SELECT * FROM users", }) require.NoError(t, err) require.Equal(t, 0, exitCode) output, _ := io.ReadAll(reader) t.Logf("Query result: %s", output)

Conclusion

Testcontainers for Go is a powerful tool that brings the reliability of integration testing to a new level. By testing against real databases and services, you can catch issues that mocks would miss, leading to more robust applications.

Key takeaways:

  • Use real dependencies instead of mocks for integration tests
  • Automatic cleanup ensures no leftover containers
  • CI/CD friendly with minimal configuration
  • Flexible - supports any Docker container
  • Reliable - tests behave the same everywhere

While integration tests with Testcontainers are slower than unit tests, they provide invaluable confidence that your application works correctly with real infrastructure. Use them strategically for critical paths and combine them with unit tests for comprehensive coverage.

The examples in this article provide a solid foundation for implementing integration tests in your Go projects. Start small, add integration tests for your most critical components, and gradually expand your test coverage.

Happy testing!

Conviértete en un Go Ninja 🥷.Suscríbete a mi newsletter y recibe las últimas novedades en Go.