DEV Community

Cover image for testcontainers-go: Real Integration Tests for Go Without Mocks
Gabriel Anhaia
Gabriel Anhaia

Posted on

testcontainers-go: Real Integration Tests for Go Without Mocks


You wrote a repository. It takes a *sql.DB, runs a few queries,
maps rows to structs. To test it, you reached for a mock. You wrote
a fake that returns canned rows, asserted your code calls it with
the right arguments, and got a green bar.

Then the query shipped with a typo in a column name. The mock never
noticed, because the mock doesn't parse SQL. It doesn't know your
ON CONFLICT clause is wrong, that your migration forgot an index,
or that Postgres returns NULL where your struct expects a
non-nullable int. The mock tests that your Go code calls a
function. It tests nothing about the database.

testcontainers-go takes the
other road. It starts a real Postgres in a Docker container from
inside your test, hands you a connection string, and tears the
container down when the test ends. Your repository talks to the same
database it will talk to in production. No mock, no in-memory
substitute that behaves almost-but-not-quite like the real thing.

The smallest thing that works

Add the module and the Postgres helper:

// go.mod requires:
//   github.com/testcontainers/testcontainers-go
//   github.com/testcontainers/testcontainers-go/modules/postgres
Enter fullscreen mode Exit fullscreen mode

Here is a test that starts Postgres, waits until it accepts
connections, and runs a query against it:

package store_test

import (
    "context"
    "database/sql"
    "testing"
    "time"

    _ "github.com/jackc/pgx/v5/stdlib"
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
    "github.com/testcontainers/testcontainers-go/wait"
)

func TestUserStore(t *testing.T) {
    ctx := context.Background()

    pg, err := postgres.Run(ctx,
        "postgres:16-alpine",
        postgres.WithDatabase("app"),
        postgres.WithUsername("app"),
        postgres.WithPassword("secret"),
        testcontainers.WithWaitStrategy(
            wait.ForListeningPort("5432/tcp").
                WithStartupTimeout(30*time.Second),
        ),
    )
    if err != nil {
        t.Fatalf("start postgres: %v", err)
    }
    t.Cleanup(func() {
        if err := pg.Terminate(ctx); err != nil {
            t.Logf("terminate: %v", err)
        }
    })
Enter fullscreen mode Exit fullscreen mode

The postgres.Run call pulls postgres:16-alpine if it is not
already cached, starts the container, and blocks until the wait
strategy passes. t.Cleanup handles teardown even when the test
fails partway through. With the container up, ask it for a DSN and
open a connection:

    dsn, err := pg.ConnectionString(ctx, "sslmode=disable")
    if err != nil {
        t.Fatalf("dsn: %v", err)
    }

    db, err := sql.Open("pgx", dsn)
    if err != nil {
        t.Fatalf("open: %v", err)
    }
    t.Cleanup(func() { db.Close() })

    // ... run migrations, then test the store.
}
Enter fullscreen mode Exit fullscreen mode

ConnectionString gives you a DSN pointing at the mapped host port,
which Docker assigns randomly so parallel runs never collide.

Getting readiness right

The single mistake that makes container tests flaky is starting to
query before the database is actually ready. A container that is
"running" is not the same as a Postgres that accepts connections.
Postgres starts, then restarts once during its own init when it
sets up the data directory. If you connect during that window, you
get a connection refused or a database that vanishes under you.

wait.ForListeningPort is a start, but it only checks the TCP
socket. The Postgres module ships a better strategy that waits for
the log line Postgres prints when it is genuinely ready for client
connections, and it accounts for the double start:

pg, err := postgres.Run(ctx,
    "postgres:16-alpine",
    postgres.WithDatabase("app"),
    postgres.WithUsername("app"),
    postgres.WithPassword("secret"),
    testcontainers.WithWaitStrategy(
        wait.ForLog("database system is ready to accept connections").
            WithOccurrence(2).
            WithStartupTimeout(60*time.Second),
    ),
)
Enter fullscreen mode Exit fullscreen mode

WithOccurrence(2) is the part people miss. Postgres logs that line
twice: once during init, once for real. Waiting for the second
occurrence is what saves you from the race. If you want belt and
suspenders, add a strategy that opens an actual connection:

wait.ForSQL("5432/tcp", "pgx", func(host string, p nat.Port) string {
    return fmt.Sprintf(
        "postgres://app:secret@%s:%s/app?sslmode=disable",
        host, p.Port(),
    )
})
Enter fullscreen mode Exit fullscreen mode

That one does not pass until a real query succeeds. It is slower to
express but it removes the last source of readiness flake.

Run migrations, then test the real thing

A test database with no schema tests nothing. Run your migrations
against the container before the assertions. If you use plain SQL
files, embed them and apply them in order:

//go:embed migrations/*.sql
var migrations embed.FS

func applyMigrations(t *testing.T, db *sql.DB) {
    t.Helper()
    entries, _ := migrations.ReadDir("migrations")
    for _, e := range entries {
        sqlBytes, err := migrations.ReadFile(
            "migrations/" + e.Name(),
        )
        if err != nil {
            t.Fatalf("read %s: %v", e.Name(), err)
        }
        if _, err := db.Exec(string(sqlBytes)); err != nil {
            t.Fatalf("apply %s: %v", e.Name(), err)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the store test exercises the same schema production runs. A
missing index, a wrong constraint, a column type that does not match
your Go struct, all of it surfaces here instead of in an incident:

func TestInsertAndFind(t *testing.T) {
    db := newTestDB(t) // starts container, migrates
    store := NewUserStore(db)

    id, err := store.Create(ctx, "ada@example.com")
    if err != nil {
        t.Fatalf("create: %v", err)
    }
    got, err := store.FindByEmail(ctx, "ada@example.com")
    if err != nil {
        t.Fatalf("find: %v", err)
    }
    if got.ID != id {
        t.Fatalf("id mismatch: %d != %d", got.ID, id)
    }
}
Enter fullscreen mode Exit fullscreen mode

If FindByEmail has a typo in the column name, this test fails with
a real Postgres error. The mock version stayed green.

Keeping the suite fast enough for every push

The knock on container tests is speed. Starting a fresh Postgres for
every test function is too slow to run on every push. The fix is to
start the container once and reuse it across the tests in a package.

TestMain gives you a package-level setup and teardown hook:

var testDB *sql.DB

func TestMain(m *testing.M) {
    ctx := context.Background()
    pg, err := postgres.Run(ctx, "postgres:16-alpine",
        postgres.WithDatabase("app"),
        postgres.WithUsername("app"),
        postgres.WithPassword("secret"),
        testcontainers.WithWaitStrategy(
            wait.ForLog("ready to accept connections").
                WithOccurrence(2),
        ),
    )
    if err != nil {
        log.Fatalf("start: %v", err)
    }
    dsn, _ := pg.ConnectionString(ctx, "sslmode=disable")
    testDB, _ = sql.Open("pgx", dsn)
    applyOnce(testDB)

    code := m.Run()

    _ = pg.Terminate(ctx)
    os.Exit(code)
}
Enter fullscreen mode Exit fullscreen mode

One container, one migration pass, every test in the package shares
it. To keep tests isolated without paying for a new container each
time, wrap each test in a transaction and roll it back at the end,
or truncate the tables the test touched in t.Cleanup. Rollback is
the cleaner option because it never leaves state behind:

func newTx(t *testing.T) *sql.Tx {
    tx, err := testDB.BeginTx(context.Background(), nil)
    if err != nil {
        t.Fatalf("begin: %v", err)
    }
    t.Cleanup(func() { tx.Rollback() })
    return tx
}
Enter fullscreen mode Exit fullscreen mode

Two more speed levers. First, testcontainers-go starts a small
ryuk reaper container that cleans up leftovers if your process
dies. On CI where the runner is torn down anyway, you can disable it
with TESTCONTAINERS_RYUK_DISABLED=true to shave a second off
startup. Second, mark the container tests so a fast unit run can skip
them:

func TestInsertAndFind(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping container test in -short mode")
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Now go test -short ./... runs the pure-Go tests in milliseconds on
every save, and the full go test ./... runs the container-backed
ones on every push. The Docker image is cached after the first pull,
so a warm run starts Postgres in a second or two.

Where this belongs

Testcontainers is not a replacement for unit tests. Your domain
logic, the code that has no database in it, should stay covered by
plain Go tests that run instantly. testcontainers-go is for the
boundary: the adapter that turns a *sql.DB into your repository.
That is exactly the layer where mocks lie to you, because a mock
cannot know how Postgres behaves. Test the boundary against the real
dependency, test the core in isolation, and you stop shipping the
class of bug that only a real database can catch.

The container approach is not Postgres-only. The same modules exist
for Redis, Kafka, MySQL, MongoDB, and dozens more. The pattern holds:
Run, wait for readiness, get a connection, test against the real
thing, terminate on cleanup.


If you keep the database at a clean boundary, tests like these stay
small and honest, which is the whole argument of Hexagonal
Architecture in Go
— the adapter is the only place the real Postgres
shows up, so it is the only place you need a container. And when you
want the deeper why behind TestMain, t.Cleanup, embed.FS, and
how the Go test runner sequences all of it, The Complete Guide to Go
Programming
walks the runtime and standard library end to end.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)