Skip to content
MV

Golang Integration Testing with Docker: My 2026 Setup

By

01 / Published

14.12.2023 Updated 15.07.2026

02 / Read time

11 min read

03 / Tags

  • docker
  • golang
  • integration-testing
Show 3 more
  • postgresql
  • testcontainers
  • testing
Golang Integration Testing with Docker: My 2026 Setup cover image

Back in 2023, I wrote the first version of this article about running integration tests with Go, Docker Compose, database migrations, and Testify. I also mentioned that creating a separate database for every test could improve isolation, then filed it under “cross that bridge when you get to it.” Future Morten has now reached the bridge and would like to register a mild complaint with Past Morten.

The setup I use for Golang integration testing in 2026 starts one PostgreSQL container for each test package and creates a separate migrated database for every test. Tests use normal Go tooling, TestMain, t.Cleanup, Testcontainers, and small factories for test data. Most application tests run at the controller boundary, where they can exercise the HTTP handler, model code, SQL, and actual database together.

This approach now forms part of how Andurel, my web framework for Go, encourages integration testing. You can use the same pattern without Andurel since the interesting part is a relatively small test helper. The rest of this article explains why I changed the setup, how the pieces fit together, and where another strategy may make more sense.

The integration-testing bridge I eventually crossed

The old version used Docker Compose to run the application and PostgreSQL, applied migrations before tests, and removed the database state afterward. It worked and was reasonably portable, which were the main goals at the time. The shared database also meant that cleanup had to remain correct and tests generally had to run sequentially.

The current setup moves the Docker lifecycle into Go through Testcontainers. PostgreSQL starts once for the package, while individual tests receive separate databases inside that PostgreSQL instance. A failed test can leave its database full of absolute nonsense and the next test will still begin with a clean migrated schema.

The lifecycle looks like this:

go test
   |
   v
TestMain starts one PostgreSQL container
   |
   +--> Test A creates database A
   |        |
   |        +--> Run migrations
   |        +--> Run test
   |        +--> Drop database A
   |
   +--> Test B creates database B
   |        |
   |        +--> Run migrations
   |        +--> Run test
   |        +--> Drop database B
   |
   +--> Test C creates database C
            |
            +--> Run migrations
            +--> Run test
            +--> Drop database C
   |
   v

TestMain terminates the container

This gives every test real PostgreSQL behaviour and predictable isolation. It also avoids starting a complete container for each individual test, which is where the waiting starts to become a little tedious. The first image pull can still take a while, so it remains an excellent opportunity to stare meaningfully at the terminal and pretend you are thinking about architecture.

Start one PostgreSQL container for the package

Go runs TestMain once for a test package when that function exists. It is a useful place for package-level resources that are expensive to create and can safely be shared. The official [testing documentation](https://pkg.go.dev/testing) describes it as a low-level primitive for setup and teardown around m.Run.

The setup used by Andurel applications looks like this:

 1package controllers_test
 2
 3import (
 4	"context"
 5	"os"
 6	"testing"
 7
 8	"yourapp/internal/storage"
 9)
10
11var testCluster *storage.TestCluster
12
13func TestMain(m *testing.M) {
14	ctx := context.Background()
15
16	var err error
17	testCluster, err = storage.NewTestCluster(ctx)
18	if err != nil {
19		panic(err)
20	}
21
22	code := m.Run()
23
24	if err := testCluster.Close(ctx); err != nil && code == 0 {
25		panic(err)
26	}
27
28	os.Exit(code)
29}

NewTestCluster starts PostgreSQL through the Testcontainers PostgreSQL module and records the dynamically assigned host and port. The current Testcontainers API uses postgres.Run; many older examples use RunContainer, which the [official PostgreSQL module documentation](https://golang.testcontainers.org/modules/postgres/) marks as deprecated. A readiness strategy waits until PostgreSQL has announced that it can accept connections.

Here is the relevant container setup:

func NewTestCluster(ctx context.Context) (*TestCluster, error) {
	const (
		user     = "testuser"
		password = "testpass"
		adminDB  = "postgres"
	)

	pgContainer, err := postgres.Run(
		ctx,
		"postgres:17-alpine",
		postgres.WithDatabase(adminDB),
		postgres.WithUsername(user),
		postgres.WithPassword(password),
		testcontainers.WithWaitStrategy(
			wait.ForLog("database system is ready to accept connections").
				WithOccurrence(2).
				WithStartupTimeout(60*time.Second),
		),
	)
	if err != nil {
		return nil, fmt.Errorf("start postgres container: %w", err)
	}

	port, err := pgContainer.MappedPort(ctx, "5432/tcp")
	if err != nil {
		_ = pgContainer.Terminate(ctx)
		return nil, fmt.Errorf("get postgres port: %w", err)
	}

	host, err := pgContainer.Host(ctx)
	if err != nil {
		_ = pgContainer.Terminate(ctx)
		return nil, fmt.Errorf("get postgres host: %w", err)
	}

	return &TestCluster{
		container: pgContainer,
		host:      host,
		port:      port.Port(),
		user:      user,
		password:  password,
		adminDB:   adminDB,
	}, nil
}

A package can now run any number of tests against this PostgreSQL server. Each package containing its own TestMain will create its own container, so go test ./... may run several containers when integration tests live across multiple packages. That separation is usually helpful, although the available CI memory eventually gets a vote too.

Give every test its own migrated database

Starting PostgreSQL solves the infrastructure problem and leaves the more annoying state problem. Reusing one database requires transactions, snapshots, table truncation, or a cleanup script that somebody will eventually forget to update. The Andurel approach creates another database inside the existing PostgreSQL server instead.

A test asks for its database with one line:

db := testCluster.NewTestDB(t, database.Migrations, "migrations")

database.Migrations is an embedded filesystem containing the real application migrations. NewTestDB creates a uniquely named database, opens a normal application connection, applies the migrations, and registers its cleanup with the test. The returned value implements the same database interface used by the application.

The lifecycle inside the helper is roughly this:

func (tc *TestCluster) NewTestDB(
	t testing.TB,
	migrations fs.FS,
	migrationDir string,
) Pool {
	t.Helper()

	ctx := context.Background()
	name := fmt.Sprintf("test_%d", time.Now().UnixNano())

	admin, err := NewPostgres(ctx, tc.databaseURL(tc.adminDB))
	if err != nil {
		t.Fatalf("connect to admin database: %v", err)
	}

	t.Cleanup(func() {
		_ = admin.Close()
	})

	if _, err := admin.Conn().ExecContext(
		ctx,
		fmt.Sprintf(`CREATE DATABASE %q`, name),
	); err != nil {
		t.Fatalf("create test database: %v", err)
	}

	dropped := false
	t.Cleanup(func() {
		if dropped {
			return
		}

		_, _ = admin.Conn().ExecContext(
			ctx,
			fmt.Sprintf(`DROP DATABASE IF EXISTS %q WITH (FORCE)`, name),
		)
	})

	db, err := NewPostgres(ctx, tc.databaseURL(name))
	if err != nil {
		t.Fatalf("connect to test database: %v", err)
	}

	t.Cleanup(func() {
		_ = db.Close()

		_, _ = admin.Conn().ExecContext(
			ctx,
			fmt.Sprintf(`DROP DATABASE IF EXISTS %q WITH (FORCE)`, name),
		)

		dropped = true
	})

	if err := RunMigrations(
		ctx,
		db.Conn(),
		migrations,
		migrationDir,
	); err != nil {
		t.Fatalf("run migrations: %v", err)
	}

	return db
}

The actual helper includes validation and wraps errors with more context. The lifecycle remains the important part: create, connect, migrate, test, close, and drop. Since t.Cleanup runs after the test and its subtests complete, cleanup still happens when a test calls t.Fatal.

Running the real migrations gives the tests the schema the application will actually use. SQL syntax errors, missing columns, broken constraints, and incorrect migration ordering fail during setup instead of waiting for deployment to become a surprise debugging workshop. This is one of the main reasons I prefer PostgreSQL over replacing the test database with SQLite or a mock.

Test behaviour at the controller boundary

I generally want integration tests at the highest useful layer. For a web application, that often means calling a controller with an HTTP request and checking both the response and the resulting database state. This covers more useful behaviour than testing the persistence method alone while remaining easier to control than starting the complete application server.

A create test can look like this:

func TestProducts_Create(t *testing.T) {
	db := testCluster.NewTestDB(
		t,
		database.Migrations,
		"migrations",
	)

	controller := controllers.NewProducts(db)

	form := url.Values{}
	form.Set("name", "Integration Test Product")
	form.Set("description", "Created through the controller")
	form.Set("price", "29.99")

	e := echo.New()
	req := httptest.NewRequest(
		http.MethodPost,
		"/products",
		strings.NewReader(form.Encode()),
	)
	req.Header.Set(
		echo.HeaderContentType,
		echo.MIMEApplicationForm,
	)

	rec := httptest.NewRecorder()
	ctx := e.NewContext(req, rec)

	if err := controller.Create(ctx); err != nil {
		t.Fatalf("Create failed: %v", err)
	}

	if rec.Code != http.StatusSeeOther {
		t.Errorf(
			"expected status %d, got %d",
			http.StatusSeeOther,
			rec.Code,
		)
	}

	products, err := models.AllProducts(
		ctx.Request().Context(),
		db.Executor(),
	)
	if err != nil {
		t.Fatalf("query products: %v", err)
	}

	if len(products) != 1 {
		t.Fatalf("expected 1 product, got %d", len(products))
	}

	if products[0].Name != "Integration Test Product" {
		t.Errorf(
			"expected saved product name %q, got %q",
			"Integration Test Product",
			products[0].Name,
		)
	}
}

The request passes through the real controller, model code, query builder, PostgreSQL driver, migrations, and database. The test verifies the public response and then checks the durable side effect independently. A controller that returns a redirect without saving the product will fail, as will a controller that saves malformed data while returning the expected status.

Calling the controller directly skips route registration and router middleware. Authentication, CSRF protection, and route parameter behaviour deserve tests that include those parts of the request path when they matter. The useful boundary depends on the behaviour being tested, and the default controller boundary keeps ordinary CRUD tests focused.

Use factories to arrange test data

Most controller tests need existing records before the request can mean anything. Creating every field manually makes tests noisy and couples them to unrelated model changes. Small factories provide valid defaults and let each test override the values relevant to its scenario.

A show test becomes fairly small:

func TestProducts_Show(t *testing.T) {
	db := testCluster.NewTestDB(
		t,
		database.Migrations,
		"migrations",
	)

	product := factories.Product().
		WithName("The product we care about").
		Create(db.Executor())

	controller := controllers.NewProducts(db)

	e := echo.New()
	req := httptest.NewRequest(http.MethodGet, "/", nil)
	rec := httptest.NewRecorder()
	ctx := e.NewContext(req, rec)

	ctx.SetPathValues(echo.PathValues{
		Name:  "id",
		Value: product.ID.String(),
	})

	if err := controller.Show(ctx); err != nil {
		t.Fatalf("Show failed: %v", err)
	}

	if rec.Code != http.StatusOK {
		t.Errorf(
			"expected status %d, got %d",
			http.StatusOK,
			rec.Code,
		)
	}
}

The test says which product detail matters and leaves everything else to the factory defaults. Related records can use the same pattern, with explicit user or product IDs passed into the next factory. Keep invariant business rules in the model and let factories handle valid defaults, persistence, and deliberate overrides.

Factories can also create misleading tests when their defaults contain too much magic. A factory that quietly creates five related records, sends an email, and makes the user an administrator turns test setup into a small adventure game. Predictable defaults and visible relationships are easier to maintain.

Comparing database-isolation strategies

A database per test is one option among several reasonable choices. The right choice depends on how the application opens connections, whether tests execute background work, and how expensive the migrations have become. The important requirement is that one test cannot change the outcome of another.

Strategy Isolation Relative speed Main constraint
Container per test Excellent Slowest Repeated image and container startup
Shared database with cleanup Depends on cleanup Fast Every table and side effect must be reset
Transaction rollback Excellent within the transaction Fastest Application operations must share that transaction
Testcontainers snapshot and restore Excellent Fast Adds snapshot lifecycle and driver considerations
Database per test Excellent Moderate Database creation and migrations take time

Transaction rollback is a strong option when every operation can use the same transaction. Independent database connections, background workers, and code that commits its own transactions require additional coordination. That coordination may be entirely worthwhile for a large test suite with expensive migrations.

The Testcontainers PostgreSQL module also supports snapshot and restore. A migrated database can be snapshotted once and restored after each test, reducing repeated migration work. I would move toward that approach after test timings show that database creation or migrations have become the meaningful bottleneck.

The database-per-test setup is easy to reason about and works with normal application connections. Every test starts from the same migrated schema and owns the entire database for its lifetime. That simplicity currently matters more to me than extracting the last bit of speed from the suite.

Parallel tests need more than database isolation

Separate databases remove the largest shared-state problem in many web applications. Parallel safety also depends on queues, files, object storage, clocks, environment variables, and any other mutable process-level state. A unique database cannot save two tests that both rewrite the same global configuration.

Add t.Parallel() after checking all dependencies touched by the test. Package-level PostgreSQL can handle multiple test databases, subject to the machine’s available connections and resources. The simple approach is to begin sequentially and introduce parallel execution when the suite’s runtime justifies the additional reasoning.

Running the integration tests

These tests use normal Go files and the normal testing package. Docker must be available because Testcontainers starts PostgreSQL through the local container runtime. No separate Docker Compose command is required.

Run the complete suite with:

go test ./...

Run only the controllers package with:

go test ./controllers

Run one test while working on it with:

go test ./controllers -run TestProducts_Create

I keep these integration tests in the ordinary test suite rather than hiding them behind a build tag. Tests that require a special command tend to receive less attention locally and in CI. A project with unusually expensive dependencies may reach a different conclusion, especially when every test would otherwise start several external services.

The first run may need to download the PostgreSQL image. CI also needs access to a Docker-compatible runtime supported by Testcontainers. A startup timeout should leave enough room for slower CI machines instead of assuming that every computer shares your laptop’s enthusiasm.

Common ways this setup goes wrong

The infrastructure is intentionally small, although there are still several creative ways to make it unreliable. Most problems come from placing lifecycle responsibilities at the wrong level. Keeping container ownership at the package level and database ownership at the test level prevents a large portion of them.

  • Starting a container inside every test: This provides strong isolation and pays the full startup cost repeatedly.
  • Sharing one migrated database without reliable cleanup: The suite eventually depends on test order, often after enough time has passed that nobody remembers why.
  • Using different migrations in tests and production: The tests can pass against a schema that the deployed application never receives.
  • Asserting only the HTTP status: A handler can return the correct response while writing incorrect data or skipping the write entirely.
  • Putting business behaviour inside factories: Tests can pass because factory setup performed work that the application path was supposed to perform.
  • Adding parallel execution immediately: Database isolation covers PostgreSQL state, while other shared resources may still collide.

These failures are easier to diagnose when the helper stays boring. One container owner, one database owner, and one cleanup path provide enough structure for most applications. Extra abstractions can wait until the suite presents a measured reason for them.

What I use now

My current Golang integration-testing workflow is straightforward: start PostgreSQL once per package, create and migrate a database for each test, arrange records with factories, and test behaviour through the controller whenever possible. I use ordinary Go assertions and keep the tests in the standard test suite. Snapshots, transactions, and parallel execution remain available when the suite grows large enough to justify them.

The previous version of this article ended by recommending that you cross the database-isolation bridge when you reached it. I eventually reached it while building Andurel and found that the bridge required less machinery than expected. More importantly, I no longer need to wonder whether a test passed because the previous test happened to leave the database in a generous mood.

You can inspect the complete generated setup in Andurel and adapt the same lifecycle to another Go application.