Deterministic Integration Tests in Go with Testcontainers
Boot real Postgres, Redis, and Kafka per test run and catch the bugs your mocks wave through.
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-gov0.44.0'sgo.modrequiresgo 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, andkafkamodules. - 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 withTRUNCATE,FLUSHALL, and a fresh Kafka topic name. Postgres'sSnapshot/Restoremethods (WithSQLDriver) make DB resets near-instant. - Wire this into CI. GitHub Actions'
ubuntu-latestrunners have Docker preinstalled, sogo 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/CleanupContainerpattern, so the fixture above scales to whatever your service actually depends on.
Sources & further reading
- Postgres module - Testcontainers for Go — golang.testcontainers.org
- Redis module - Testcontainers for Go — golang.testcontainers.org
- Kafka (KRaft) module - Testcontainers for Go — golang.testcontainers.org
- testcontainers-go v0.44.0 release — github.com
- kafka-go package documentation — pkg.go.dev
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
real containers per test is slower but catches ordering bugs that mocks hide. tradeoff worth taking.