Skip to content
Dev Tools Intermediate Tutorial

Deterministic Integration Tests in Go with Testcontainers

Boot real Postgres, Redis, and Kafka per test run and catch the bugs your mocks wave through.

Priya Nair
Priya Nair
AI & Developer Experience Writer · Aug 22, 2026 · 7 min read
Deterministic Integration Tests in Go with Testcontainers

What you'll build

A Go test that boots real Postgres, Redis, and Kafka containers, runs an PlaceOrder function across all three, and asserts on what actually landed in each — then tears everything down automatically. Every run starts from a clean, identical state, so the suite is deterministic and mock-free.

Prerequisites

Verified against these versions on macOS 15 (Apple Silicon) with Docker Desktop; Linux with Docker Engine works the same.

  • Go 1.25 or newer — testcontainers-go v0.44.0's go.mod requires go 1.25.0.
  • Docker Desktop or Engine running locally, with access to Docker Hub (the first run pulls ~1.2 GB of images, mostly Kafka).
  • Testcontainers for Go v0.44.0 with its postgres, redis, and kafka modules.
  • Client libraries: pgx v5.10.0, go-redis v9.22.0, kafka-go v0.4.51.

Testcontainers talks to the daemon over DOCKER_HOST or the default socket; on Docker Desktop, the resolved host is unix:///var/run/docker.sock and nothing extra is needed. Podman and Colima work too, but set DOCKER_HOST explicitly.

1. Create the module and install dependencies

mkdir tcdemo && cd tcdemo
go mod init example.com/tcdemo
go get github.com/testcontainers/testcontainers-go@v0.44.0 \
  github.com/testcontainers/testcontainers-go/modules/postgres@v0.44.0 \
  github.com/testcontainers/testcontainers-go/modules/redis@v0.44.0 \
  github.com/testcontainers/testcontainers-go/modules/kafka@v0.44.0 \
  github.com/jackc/pgx/v5@v5.10.0 \
  github.com/redis/go-redis/v9@v9.22.0 \
  github.com/segmentio/kafka-go@v0.4.51

Each Testcontainers module is its own Go module, which is why you pull them in individually.

2. Write the code under test

This is deliberately the kind of function mocks lie about: it depends on a SQL CHECK constraint, a cache write, and a Kafka publish.

store.go:

package tcdemo

import (
	"context"
	"encoding/json"
	"fmt"

	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/redis/go-redis/v9"
	"github.com/segmentio/kafka-go"
)

type Order struct {
	ID       int    `json:"id"`
	SKU      string `json:"sku"`
	Quantity int    `json:"quantity"`
}

type Store struct {
	DB     *pgxpool.Pool
	Cache  *redis.Client
	Writer *kafka.Writer
}

// PlaceOrder inserts the row, caches it, and publishes an event.
func (s *Store) PlaceOrder(ctx context.Context, sku string, qty int) (Order, error) {
	var o Order
	err := s.DB.QueryRow(ctx,
		`INSERT INTO orders (sku, quantity) VALUES ($1, $2) RETURNING id, sku, quantity`,
		sku, qty).Scan(&o.ID, &o.SKU, &o.Quantity)
	if err != nil {
		return o, fmt.Errorf("insert: %w", err)
	}
	payload, _ := json.Marshal(o)
	if err := s.Cache.Set(ctx, fmt.Sprintf("order:%d", o.ID), payload, 0).Err(); err != nil {
		return o, fmt.Errorf("cache: %w", err)
	}
	if err := s.Writer.WriteMessages(ctx, kafka.Message{
		Key: []byte(fmt.Sprint(o.ID)), Value: payload,
	}); err != nil {
		return o, fmt.Errorf("publish: %w", err)
	}
	return o, nil
}

3. Add the schema as an init script

The Postgres module runs anything in /docker-entrypoint-initdb.d on first boot, so the schema is applied before your test gets a connection string.

testdata/schema.sql:

CREATE TABLE orders (
    id         SERIAL PRIMARY KEY,
    sku        TEXT NOT NULL,
    quantity   INT  NOT NULL CHECK (quantity > 0),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

4. Write the container fixture

The helper starts all three containers and wires real clients. Two details matter: testcontainers.CleanupContainer is registered before the error check (it's nil-safe, and this guarantees cleanup even if Run returned a partially-created container), and Postgres gets an explicit wait strategy because the module's docs are clear that it ships without a default one — the WithOccurrence(2) is because Postgres logs "ready" once during init and once for real.

store_test.go:

package tcdemo

import (
	"context"
	"encoding/json"
	"testing"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/redis/go-redis/v9"
	"github.com/segmentio/kafka-go"
	"github.com/testcontainers/testcontainers-go"
	tckafka "github.com/testcontainers/testcontainers-go/modules/kafka"
	"github.com/testcontainers/testcontainers-go/modules/postgres"
	tcredis "github.com/testcontainers/testcontainers-go/modules/redis"
	"github.com/testcontainers/testcontainers-go/wait"
)

const topic = "orders"

func newStore(t *testing.T) (*Store, []string) {
	t.Helper()
	ctx := context.Background()

	pg, err := postgres.Run(ctx, "postgres:17-alpine",
		postgres.WithDatabase("shop"),
		postgres.WithUsername("shop"),
		postgres.WithPassword("secret"),
		postgres.WithInitScripts("testdata/schema.sql"),
		testcontainers.WithWaitStrategy(
			wait.ForLog("database system is ready to accept connections").
				WithOccurrence(2).WithStartupTimeout(30*time.Second)),
	)
	testcontainers.CleanupContainer(t, pg)
	if err != nil {
		t.Fatalf("start postgres: %v", err)
	}

	rd, err := tcredis.Run(ctx, "redis:7-alpine")
	testcontainers.CleanupContainer(t, rd)
	if err != nil {
		t.Fatalf("start redis: %v", err)
	}

	kf, err := tckafka.Run(ctx, "confluentinc/confluent-local:7.5.0",
		tckafka.WithClusterID("test-cluster"))
	testcontainers.CleanupContainer(t, kf)
	if err != nil {
		t.Fatalf("start kafka: %v", err)
	}

	dsn, err := pg.ConnectionString(ctx, "sslmode=disable")
	if err != nil {
		t.Fatal(err)
	}
	pool, err := pgxpool.New(ctx, dsn)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(pool.Close)

	redisURL, err := rd.ConnectionString(ctx)
	if err != nil {
		t.Fatal(err)
	}
	opts, err := redis.ParseURL(redisURL)
	if err != nil {
		t.Fatal(err)
	}
	cache := redis.NewClient(opts)
	t.Cleanup(func() { cache.Close() })

	brokers, err := kf.Brokers(ctx)
	if err != nil {
		t.Fatal(err)
	}
	writer := &kafka.Writer{
		Addr:                   kafka.TCP(brokers...),
		Topic:                  topic,
		AllowAutoTopicCreation: true,
	}
	t.Cleanup(func() { writer.Close() })

	return &Store{DB: pool, Cache: cache, Writer: writer}, brokers
}

The Kafka module runs Confluent's image in KRaft mode (no ZooKeeper) and exposes the broker on a random host port mapped to 9093/tcp; Brokers() returns that mapped address, so you never hardcode ports.

5. Write the test

Append to store_test.go:

func TestPlaceOrder(t *testing.T) {
	ctx := context.Background()
	s, brokers := newStore(t)

	order, err := s.PlaceOrder(ctx, "SKU-123", 2)
	if err != nil {
		t.Fatalf("PlaceOrder: %v", err)
	}

	// Postgres: the row is really there, and the CHECK constraint is enforced.
	var n int
	if err := s.DB.QueryRow(ctx, `SELECT count(*) FROM orders`).Scan(&n); err != nil || n != 1 {
		t.Fatalf("want 1 row, got %d (err=%v)", n, err)
	}
	if _, err := s.PlaceOrder(ctx, "SKU-123", 0); err == nil {
		t.Fatal("expected CHECK constraint violation for quantity 0")
	}

	// Redis: cached JSON matches.
	raw, err := s.Cache.Get(ctx, "order:1").Bytes()
	if err != nil {
		t.Fatalf("cache get: %v", err)
	}
	var cached Order
	if err := json.Unmarshal(raw, &cached); err != nil || cached != order {
		t.Fatalf("cached %+v != %+v", cached, order)
	}

	// Kafka: the event was actually published.
	reader := kafka.NewReader(kafka.ReaderConfig{
		Brokers: brokers,
		Topic:   topic,
		MaxWait: time.Second,
	})
	t.Cleanup(func() { reader.Close() })
	readCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
	defer cancel()
	msg, err := reader.ReadMessage(readCtx)
	if err != nil {
		t.Fatalf("read kafka: %v", err)
	}
	if string(msg.Key) != "1" {
		t.Fatalf("want key 1, got %q", msg.Key)
	}
}

The quantity = 0 assertion is the point of the exercise: a mocked repository would happily "insert" it. Only the real database knows about orders_quantity_check.

Verify it works

go test -count=1 -v ./...

-count=1 disables Go's test cache; you want the containers to actually start every time. Expected output (IDs and ports will differ):

=== RUN   TestPlaceOrder
2026/08/22 14:00:01 github.com/testcontainers/testcontainers-go - Connected to docker:
  Server Version: 29.2.1
  ...
  Testcontainers for Go Version: v0.44.0
2026/08/22 14:00:01 🐳 Creating container for image testcontainers/ryuk:0.13.0
2026/08/22 14:00:01 🐳 Creating container for image postgres:17-alpine
2026/08/22 14:00:01 ✅ Container started: 7fa6e57abe8a
2026/08/22 14:00:01 ⏳ Waiting for container id 7fa6e57abe8a image: postgres:17-alpine. Waiting for: all of: [log message "database system is ready to accept connections" (occurrence: 2)]
2026/08/22 14:00:02 🔔 Container is ready: 7fa6e57abe8a
2026/08/22 14:00:02 🐳 Creating container for image redis:7-alpine
2026/08/22 14:00:02 🔔 Container is ready: 0a0485af7ea0
2026/08/22 14:00:02 🐳 Creating container for image confluentinc/confluent-local:7.5.0
...
2026/08/22 14:00:09 🚫 Container terminated: 7fa6e57abe8a
--- PASS: TestPlaceOrder (8.41s)
PASS
ok  	example.com/tcdemo	8.9s

After the run, docker ps -a --filter label=org.testcontainers should list nothing: CleanupContainer terminated each container, and the Ryuk sidecar (testcontainers/ryuk) is the safety net that reaps anything left behind if the process dies mid-test.

Troubleshooting

wait until ready: "database system is ready to accept connections" matched 1 times, expected 2 — the container started but the wait strategy timed out. Usually the image is still being pulled on a slow connection or Docker is starved for memory. Raise WithStartupTimeout or pre-pull with docker pull postgres:17-alpine. If you see matched 0 times, check the log string — it must match the image's actual output verbatim.

version=7.3.0. KRaft mode is only available since version 7.4.0 — the Kafka module validates the image tag. Use confluentinc/confluent-local:7.4.0 or newer; the module ignores version checks for non-Confluent images, but those won't have the KRaft init script it relies on.

Test hangs right after 🐳 Creating container for image ... with no further output — Testcontainers is trying to pull testcontainers/ryuk and Docker can't reach the registry (a corporate proxy or a broken Docker Desktop network is the usual culprit). Confirm with docker pull hello-world. As a temporary workaround, TESTCONTAINERS_RYUK_DISABLED=true go test ./... skips the reaper — but then a kill -9 during a test leaves containers running.

Cannot connect to the Docker daemon or a long retry loop before anything starts — Docker isn't running, or DOCKER_HOST points somewhere stale. Run docker context show and unset DOCKER_HOST if it's set to an old Colima or Podman socket.

Next steps

  • Cut startup time by sharing containers across tests in a package: start them in TestMain, hand out connection strings, and reset state per test with TRUNCATE, FLUSHALL, and a fresh Kafka topic name. Postgres's Snapshot/Restore methods (WithSQLDriver) make DB resets near-instant.
  • Wire this into CI. GitHub Actions' ubuntu-latest runners have Docker preinstalled, so go test ./... works unchanged; on other CI systems the Testcontainers Docker requirements page covers the socket and Ryuk permission details.
  • Browse the module catalog — MySQL, MongoDB, LocalStack, Elasticsearch, Redpanda and forty-odd others use the same Run/CleanupContainer pattern, so the fixture above scales to whatever your service actually depends on.

Sources & further reading

  1. Postgres module - Testcontainers for Go — golang.testcontainers.org
  2. Redis module - Testcontainers for Go — golang.testcontainers.org
  3. Kafka (KRaft) module - Testcontainers for Go — golang.testcontainers.org
  4. testcontainers-go v0.44.0 release — github.com
  5. kafka-go package documentation — pkg.go.dev
Priya Nair
Written by
Priya Nair · AI & Developer Experience Writer

Priya covers AI frameworks, developer productivity tooling, and the startup ecosystem across South and Southeast Asia, bringing a researcher's rigour and a practitioner's empathy to every story. She is deeply sceptical of benchmarks and asks hard questions so her readers don't have to.

Discussion 1

Join the discussion

Sign in or create an account to comment and vote.

Yuki Tanaka @distsys_yuki · 5 hours ago

real containers per test is slower but catches ordering bugs that mocks hide. tradeoff worth taking.

Related Reading