The Challenge of Testing Against Real Databases
We've all been there: a unit test passes with flying colours, mocks behave perfectly, and then the code hits production and something subtle goes wrong with transactions, constraints, or connection pooling. Integration tests with Testcontainers in Go: a deep dive shows us how to test against real databases without the friction of managing local infrastructure. Testcontainers-go lets us spin up ephemeral Docker containers for each test run, ensuring we catch real-world database interactions before they reach production.
How testcontainers-go Starts Docker Containers
Testcontainers-go is a Go library that programmatically creates and manages Docker containers during test execution. Rather than mocking database calls, we get an actual PostgreSQL, MySQL, or Redis instance running in a clean state for each test suite. The library handles container lifecycle management: starting, waiting for readiness, and cleanup.
Here's how we start a PostgreSQL container in a test:
package repository_test
import (
"context"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
"testing"
)
func setupPostgres(ctx context.Context) (string, error) {
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"),
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
return "", err
}
host, err := container.Host(ctx)
if err != nil {
return "", err
}
port, err := container.MappedPort(ctx, "5432")
if err != nil {
return "", err
}
dsn := fmt.Sprintf("user=testuser password=testpass host=%s port=%s dbname=testdb sslmode=disable",
host, port.Port())
return dsn, nil
}
The WaitingFor strategy is crucial here. Testcontainers won't consider the container ready until PostgreSQL logs the readiness message. This prevents flaky tests caused by connections arriving before the database is fully initialized.
Run Migrations Before Tests
A fresh database container starts empty. Our tables, indexes, and constraints don't exist until we run migrations. Integration tests need the same schema as production, so we automate this during test setup.
If we're using a migration tool like Flyway or sql-migrate, we can run it programmatically:
package repository_test
import (
"context"
"database/sql"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
)
func applyMigrations(db *sql.DB) error {
driver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
return err
}
m, err := migrate.NewWithDatabaseInstance(
"file://./migrations",
"postgres",
driver,
)
if err != nil {
return err
}
return m.Up()
}
func TestUserRepository(t *testing.T) {
ctx := context.Background()
dsn, err := setupPostgres(ctx)
if err != nil {
t.Fatalf("failed to setup postgres: %v", err)
}
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatalf("failed to connect: %v", err)
}
defer db.Close()
if err := applyMigrations(db); err != nil {
t.Fatalf("failed to apply migrations: %v", err)
}
// Now test against a fully initialized database
repo := repository.NewUserRepository(db)
user, err := repo.GetByID(ctx, 1)
// ...
}
This ensures every test runs against a schema that matches what the application expects. We catch not just logic errors but also problems with constraints, foreign keys, and cascading deletes.
Share Containers Per Package
Starting a new container for every test function is expensive. Container startup can take 5-10 seconds, multiplied across dozens of tests adds up fast. We can share a single container instance across all tests in a package and clean up afterwards.
Go's TestMain hook lets us manage container lifecycle at the package level:
package repository_test
import (
"context"
"database/sql"
"os"
"testing"
)
var testDB *sql.DB
var container testcontainers.Container
func TestMain(m *testing.M) {
ctx := context.Background()
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"),
}
var err error
container, err = testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
panic(err)
}
host, _ := container.Host(ctx)
port, _ := container.MappedPort(ctx, "5432")
dsn := fmt.Sprintf("user=testuser password=testpass host=%s port=%s dbname=testdb sslmode=disable",
host, port.Port())
testDB, _ = sql.Open("postgres", dsn)
testDB.Ping()
applyMigrations(testDB)
code := m.Run()
container.Terminate(ctx)
testDB.Close()
os.Exit(code)
}
func TestCreateUser(t *testing.T) {
repo := repository.NewUserRepository(testDB)
// ...
}
func TestUpdateUser(t *testing.T) {
repo := repository.NewUserRepository(testDB)
// ...
}
Now the container starts once, runs all package tests, and terminates cleanly. Each test should truncate relevant tables or use transactions to keep state isolated, but the overhead is minimal.
Use Build Tags to Separate Slow Tests
Integration tests are slower than unit tests. In a large codebase, developers might run quick unit tests dozens of times per minute during active coding. Running integration tests that often gets frustrating. Go's build tags let us separate integration tests so they only run when explicitly requested.
Add a build tag comment to the top of integration test files:
//go:build integration
package repository_test
import (
"context"
"testing"
)
func TestUserRepositoryIntegration(t *testing.T) {
// Full integration test with Testcontainers
}
Now we run unit tests with the standard command:
go test ./...
And integration tests only when we explicitly include the tag:
go test -tags=integration ./...
We can also set up CI/CD to run -tags=integration in the pipeline while developers run fast feedback loops locally. This balance keeps everyone productive while ensuring comprehensive testing happens before deployment.
Common Mistakes
Not waiting for container readiness: Skipping the WaitingFor strategy causes intermittent connection failures. Always wait for the actual service to signal readiness, not just port availability.
Polluting test state across functions: Shared containers are efficient but dangerous if data persists between tests. Use transactions that rollback, or truncate tables between tests. A test that passes alone but fails in the suite has an invisible dependency on test order.
Running all containers sequentially on CI: If your pipeline runs one integration test file after another, waiting 10 seconds per file gets painful. Consider running test packages in parallel with go test -parallel and ensuring each has its own container.
Ignoring resource cleanup: Forgetting to call container.Terminate() leaves dangling containers consuming disk and memory. Always use defer or proper TestMain structure.
Key Takeaways
- Testcontainers-go eliminates the friction of managing test infrastructure by spinning up real databases in Docker containers programmatically.
- Always apply migrations inside tests to ensure schema matches production and catch constraint-related bugs early.
- Share containers across a test package using TestMain to keep test suites fast while maintaining real database interactions.
- Use build tags to gate integration tests behind
-tags=integration, letting developers run quick unit tests locally and full suites in CI. - Pay close attention to container readiness strategies, test isolation, and resource cleanup to avoid flaky tests and infrastructure leaks.






Comments
Loading comments…