Skip to content
Software Atlas

Integration Testing

Testing your code against the real database it ships with: Testcontainers in Go and JavaScript, real migrations, keeping tests isolated with transactions, test data builders, the whole HTTP stack with httptest, making it fast, and running it in CI.

⏱ ~40 min read14 sections7 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

Your unit tests are green. You deploy. The first real request fails with column "created" does not exist. Every test passed, because no test ever ran your SQL against a real database.

In one sentenceAn Integration test runs your code against the real database, cache or server it talks to in production — started fresh, just for the test, and thrown away afterwards.

For years the hard part was getting the "real props": a shared test database that everyone's tests trampled on, or a Postgres you installed by hand. Today a library called Testcontainers starts a brand-new Postgres in a Container for your test run, in about two seconds, and deletes it afterwards. This is what one run looks like:

One integration test run (measured on this machine)
container
start Postgres ~2 s
running
test code
migrate
tests
cleanup
remove ~0.3 s
Approximate, from real runs with Postgres 16. The first run ever also downloads the image (~7 s, once). The container start dominates — that's why §04 shows how to start it once for all tests, and §10 measures the difference.
§You'll learn
02Which bugs only a real database can catch — and when a fake is still the right call
03–05Just enough Docker, your first Testcontainers test in Go and JavaScript, and building the schema with real migrations
06–08Keeping tests from trampling on each other's data, transactions that roll back, and test data builders
09Testing the whole HTTP handler stack with httptest
10–11Making it fast, and running it in CI
12–14Pitfalls, a quiz and a cheat sheet
Foundations§02

What a fake can't tell you

In one sentenceA fake database checks your code against what you believe the database does. A real one checks it against what it actually does.

Most backend code is a thin layer of logic wrapped around a database. The logic is easy to Unit test on its own. The bugs that slip through live in the seams — the places where your code hands something to another system and trusts it to behave a certain way:

  • The SQL itself: a typo, a renamed column, a missing ORDER BY.
  • Rules the database enforces: a UNIQUE constraint, a Foreign key, NOT NULL, a CHECK like "total is never negative". Each of these is a constraint.
  • Types and precision: Postgres stores times to the microsecond, Go's time.Time holds nanoseconds; a JavaScript Date keeps only milliseconds.
  • Behaviour you didn't know about: what order rows come back in, what an error looks like, what happens to a transaction after one statement fails.

A Fake — say, a Go map that pretends to be the users table — has none of these. It does exactly what its author thought the database would do. Try five real bugs. Every "real Postgres" output below came from an actual run:

Fake vs real database
Five real bugs. Run each test against an in-memory fake, then against real Postgres. Outputs are from real runs.

A migration renamed created to created_at. One query still uses the old name.

func (s *Store) GetUser(ctx context.Context, id int64) (User, error) {
    var u User
    err := s.db.QueryRow(ctx,
        `SELECT email, created FROM users WHERE id = $1`, id). // ← old column name
        Scan(&u.Email, &u.CreatedAt)
    return u, err
}
Pick a bug, then press both run buttons. Then switch to fixed code and run again.

So should everything use the real database?

No. The real thing is slower (milliseconds instead of microseconds per test, plus a ~2 s start), and a failure could be in any of several layers. The useful split:

Use a real instance in testsKeep using a fake or stub
Your own database (Postgres, MySQL) — your schema, your SQLThird-party APIs you don't run: payments, email, SMS. You can't start Stripe in a container; use a fake, the provider's sandbox, or a contract test — see Mocks, Stubs & Fakes
Your cache or queue (Redis, RabbitMQ) — its commands and expiry rulesPure logic — prices, parsing, validation. Unit-test it without any database
Your own HTTP stack — router, middleware, JSONSlow or random things — the real clock, random numbers. Inject them (Dependency Injection §07)
Foundations§03

Just enough Docker

In one sentenceAn image is a frozen, ready-to-run copy of a program. A container is one running copy of it, with its own files and network, that you can delete in a second.

Testcontainers drives Docker for you, so you only need three ideas.

1

Image

A Container image such as postgres:16-alpine is Postgres 16 plus everything it needs, packed into one download (~117 MB compressed for this one) from a registry — by default Docker Hub. The part after the colon is the tag: which version. alpine means a small Linux base.
2

Container

Starting an image gives you a Container: a running Postgres that can't see your files and that you can delete with all its data. Start ten, and you have ten separate databases.
3

Port mapping

Postgres inside listens on Port 5432 — but inside the container's own private network. Port mapping connects a port on your machine to it.

Here is the whole idea done by hand in a terminal, with no library:

terminal
docker run --rm -d -p 55432:5432 -e POSTGRES_PASSWORD=test postgres:16-alpine
#          │    │   │             │                         └ image to start
#          │    │   │             └ an environment variable for the container
#          │    │   └ your port 55432 → the container's port 5432
#          │    └ run in the background
#          └ delete the container when it stops

psql "postgres://postgres:test@localhost:55432/postgres"   # connect from your machine

Why tests should never pick the host port

The command above hard-codes host port 55432. That's fine once. But tests run in many places at once — two Go packages, two branches, two CI jobs on one machine — and only one program can own a port. Try it:

Where does the test connect?
A container's port 5432 is only reachable from your machine through a mapped host port.
test run 1
connects to localhost:32769
→
your machinehost port
:32769
→
container 1
Postgres listening on :5432
Docker picked a free host port (here 32769). The test asks Testcontainers which one it got, so it never has to guess.

So Testcontainers always asks Docker for a random free host port and then tells your test which one it got. Your test never types a port number; it asks for a Connection string instead.

Practitioner§04

Your first Testcontainers test

In one sentenceA Testcontainers test does four things: start a container, wait until the service inside is truly ready, connect using the address it reports, and remove the container when the test ends.

Our example app is a tiny shop. First, the code under test: a users table, and a Store whose CreateUser returns ErrEmailTaken when the email already exists.

store.go (the code under test)
package shop

// The table, from migrations/001_users.sql:
//   CREATE TABLE users (id bigserial PRIMARY KEY, email text NOT NULL UNIQUE,
//                       name text NOT NULL, created_at timestamptz NOT NULL DEFAULT now());

type User struct {
    ID        int64     `json:"id"`
    Email     string    `json:"email"`
    Name      string    `json:"name"`
    CreatedAt time.Time `json:"createdAt"`
}

var ErrEmailTaken = errors.New("email already taken")

type Store struct{ db DBTX } // DBTX: "a pool or a transaction" — see §05 and §07
func NewStore(db DBTX) *Store { return &Store{db: db} }

func (s *Store) CreateUser(ctx context.Context, email, name string) (User, error) {
    u := User{Email: email, Name: name}
    err := s.db.QueryRow(ctx,
        `INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id, created_at`,
        email, name).Scan(&u.ID, &u.CreatedAt)
    var pgErr *pgconn.PgError
    if errors.As(err, &pgErr) && pgErr.Code == "23505" { // ← 23505 = unique_violation
        return User{}, ErrEmailTaken
    }
    return u, err
}

Now the test. It checks that the database's UNIQUE constraint really turns a second sign-up into ErrEmailTaken — something no fake can prove.

store_test.go
package shop

import (
    "context"
    "errors"
    "testing"

    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
)

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

    pg, err := postgres.Run(ctx, "postgres:16-alpine",
        postgres.BasicWaitStrategies(), // ← wait until Postgres really accepts connections
    )
    testcontainers.CleanupContainer(t, pg) // remove it when the test ends
    if err != nil {
        t.Fatal(err)
    }

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

    if err := Migrate(ctx, pool); err != nil { // create the tables (§05)
        t.Fatal(err)
    }

    store := NewStore(pool)
    if _, err := store.CreateUser(ctx, "ada@example.com", "Ada"); err != nil {
        t.Fatal(err)
    }
    _, err = store.CreateUser(ctx, "ada@example.com", "Ada again")
    if !errors.Is(err, ErrEmailTaken) {
        t.Fatalf("second CreateUser: got err %v, want ErrEmailTaken", err)
    }
}

What happens when you press "run"

One container, start to finish
What postgres.Run does behind the scenes. Timings measured on this machine.
Code
1pg, err := postgres.Run(ctx, "postgres:16-alpine",
2 postgres.BasicWaitStrategies())
3testcontainers.CleanupContainer(t, pg)
4if err != nil {
5 t.Fatal(err)
6}
7dsn, err := pg.ConnectionString(ctx, "sslmode=disable")
8pool, err := pgxpool.New(ctx, dsn)
9err = Migrate(ctx, pool)
10// … the actual test: a few milliseconds …
your test
calls postgres.Run
Docker
pulling postgres:16-alpine…
Postgres container
not started
Testcontainers asks the Docker daemon (the background program that runs containers) for the image. The very first time, Docker downloads it — about 7 s here, once. After that it's cached on disk.
1 / 10

Two ideas from that walkthrough matter everywhere:

  • Wait for a condition, never for a time. A container that has started is not a database that accepts connections. A wait strategy polls for a real signal: a log line, an open port, a successful health check. time.Sleep(3 * time.Second) would be too long on your laptop and too short on a busy CI machine — a guaranteed Flaky test.
  • Cleanup has a backstop. If your test process crashes before cleanup runs, Ryuk removes the leftovers.

Here's the real go test -v output of the test above (log lines shortened), on a machine that already had the image:

go test -v -run TestCreateUser_DuplicateEmail (real output, trimmed)
=== RUN   TestCreateUser_DuplicateEmail
🐳 Creating container for image postgres:16-alpine
✅ Container started: 55ad4f9d646b
⏳ Waiting for container id 55ad4f9d646b image: postgres:16-alpine. Waiting for: all of:
   [log message "database system is ready to accept connections" (occurrence: 2),
    port 5432/tcp to be listening]
🔔 Container is ready: 55ad4f9d646b
🐳 Terminating container: 55ad4f9d646b
--- PASS: TestCreateUser_DuplicateEmail (2.22s)
PASS
ok      example.com/shop    2.230s

Redis works the same way

Every common service has a module. Here's a real Redis behind a small Rate limit helper: each key may call Allow 3 times per minute. The last check catches a bug no fake would: forgetting to set the key's expiry, so the user is blocked forever.

ratelimit_test.go
func TestAllow(t *testing.T) {
    ctx := context.Background()
    ctr, err := tcredis.Run(ctx, "redis:7-alpine") // tcredis = .../modules/redis
    testcontainers.CleanupContainer(t, ctr)
    if err != nil {
        t.Fatal(err)
    }
    url, err := ctr.ConnectionString(ctx) // "redis://localhost:PORT"
    if err != nil {
        t.Fatal(err)
    }
    opts, err := redis.ParseURL(url) // redis = github.com/redis/go-redis/v9
    if err != nil {
        t.Fatal(err)
    }
    rdb := redis.NewClient(opts)
    t.Cleanup(func() { rdb.Close() })

    for i := 1; i <= 4; i++ {
        ok, err := Allow(ctx, rdb, "alice", 3)
        if err != nil {
            t.Fatal(err)
        }
        if want := i <= 3; ok != want { // calls 1–3 allowed, call 4 refused
            t.Fatalf("call %d: Allow = %v, want %v", i, ok, want)
        }
    }
    if ttl := rdb.TTL(ctx, "rl:alice").Val(); ttl <= 0 {
        t.Errorf("TTL = %v, want a positive expiry", ttl) // ← forgot EXPIRE?
    }
}

Start it once for many tests: TestMain and globalSetup

The test above starts its own container, so ten such tests would pay ~2 s ten times. Real suites start one container and share it. (Sharing raises a new problem — tests seeing each other's rows — which §06 and §07 solve.)

In Go, a function named TestMain in a test file runs instead of the tests, and calls m.Run() to run them. That's the place to start one container for the whole Package / module. In Vitest, a global setup file runs once before all test files and can hand them values with provide / inject.

main_test.go
var pool *pgxpool.Pool // shared by every test in this package

func TestMain(m *testing.M) {
    os.Exit(run(m)) // run's defers have finished before we exit
}

func run(m *testing.M) int {
    ctx := context.Background()
    pg, err := postgres.Run(ctx, "postgres:16-alpine", postgres.BasicWaitStrategies())
    defer testcontainers.TerminateContainer(pg) // safe even if pg is nil
    if err != nil {
        log.Println(err)
        return 1 // ← return, don't log.Fatal: the defer above still runs
    }
    pool, err = pgxpool.New(ctx, pg.MustConnectionString(ctx, "sslmode=disable"))
    if err != nil {
        log.Println(err)
        return 1
    }
    defer pool.Close()
    if err := Migrate(ctx, pool); err != nil {
        log.Println(err)
        return 1
    }
    return m.Run() // ← all the tests run here; the exit code says whether they passed
}
Practitioner§05

Migrations: build the schema the real way

In one sentenceThe test database should be built by the same migration files that build production. Then a broken migration fails a test, not a deploy.

A fresh container holds an empty database: no tables. You need to create the schema before any test runs. There are two ways, and only one is safe:

✕ A hand-written schema just for tests
test/schema.sql
-- copied from production… in March
CREATE TABLE users (
    id    bigserial PRIMARY KEY,
    email text NOT NULL,
    name  text
);
-- (production has since added UNIQUE(email),
--  made name NOT NULL, and added orders)
✓ Run your real migrations
migrations/
001_users.sql      CREATE TABLE users (… email text NOT NULL UNIQUE …)
002_orders.sql     CREATE TABLE orders (… REFERENCES users(id) …)
003_…              every later change, in order

The test runs 001, 002, 003… — exactly
what production ran.

A separate test schema drifts: someone adds a constraint in a migration and forgets the copy. Now the tests run against a database that doesn't exist anywhere in production, and the duplicate-email test from §04 passes for the wrong reason. A Database migration file is the only description of the schema that production has actually run.

A tiny migration runner

Real projects use a tool (below), but the idea fits in a few lines: read the .sql files in name order and run each one.

migrate.go
//go:embed migrations/*.sql
var migrationFiles embed.FS // ← the .sql files are compiled into the binary

// Migrate runs every migrations/*.sql file in name order.
func Migrate(ctx context.Context, db DBTX) error {
    names, err := fs.Glob(migrationFiles, "migrations/*.sql")
    if err != nil {
        return err
    }
    sort.Strings(names) // 001_… before 002_…
    for _, name := range names {
        sql, err := migrationFiles.ReadFile(name)
        if err != nil {
            return err
        }
        if _, err := db.Exec(ctx, string(sql)); err != nil {
            return fmt.Errorf("%s: %w", name, err) // say which file broke
        }
    }
    return nil
}

Real tools

In production you'd use a migration tool, which also records which files already ran (in a table like schema_migrations) so each runs exactly once: in Go, goose or golang-migrate; in Node, node-pg-migrate, or the migration command of your ORM (Prisma, Drizzle, Knex). All of them can be called from test setup — usually a single function like goose.Up(db, "migrations"). Whatever you use in production, call the same thing in tests.

Practitioner§06

Keeping tests out of each other's way

In one sentenceStarting a container per test is too slow (§10), so tests share one database. The job then is to make every test behave as if it had the database to itself.

That property is called Test isolation. Without it, tests leave rows behind and the next test trips over them. Here's a real run of 50 small tests against one shared Postgres with no cleanup. Each test creates 3 users and checks that the count is 3:

go test (real output, no isolation)
    main_test.go:142: CountUsers() = 6, want 3
    main_test.go:142: CountUsers() = 9, want 3
    main_test.go:142: CountUsers() = 12, want 3
    main_test.go:142: CountUsers() = 15, want 3
    …
--- FAIL: TestMany

The first test passes; every later one sees the leftovers of all the tests before it. Worse, each test passes when run on its own — so the failure depends on which tests ran first. Tests like that are called order-dependent, and they're one of the main sources of flaky tests.

First: connections, pools and transactions

Two of the strategies below depend on how programs talk to Postgres, so here's the picture.

  • A database connection is one open network session between your program and Postgres. It runs one query at a time.
  • Opening a connection takes several milliseconds, so programs keep a Connection pool: a handful of open connections that requests borrow and give back.
  • A Database transaction lives on one connection. BEGIN borrows a connection and keeps it until COMMIT or ROLLBACK. Rows it writes are visible on that connection only; every other connection sees only committed data.
A pool, two connections, three tests
Who can see which rows, and why a transaction holds on to one connection.
connection 1
idle (open, in the pool)
connection 2
idle (open, in the pool)
waiting for a connection
T1
T2
T3
users table (committed)
empty
A pool with 2 open connections (real pools hold more; 2 keeps the picture small). Three tests want to talk to the database. The committed table is empty.
1 / 8

Four ways to clean up

StrategyHowCost per test (measured)In parallel?
Shared, no cleanupNothing0✗ fails, serial too
TRUNCATE after eachEmpty every table when the test ends~7 ms✗ one test wipes another's rows
Transaction per testBEGIN before, ROLLBACK after — nothing is ever saved~1 ms✓
Database per testCopy a ready-made template database for each test~95 ms✓

Run them yourself. Each "test" is a tiny script; the simulator follows the same visibility rules Postgres does. Press run more than once — in parallel mode the interleaving is random, just like real parallel tests:

Isolation simulator
Three tests share one Postgres. Pick how they're kept apart, then run the suite — several times.
T1signup creates one user
T2import adds three users
T3empty list shows “no users”
users tableone table shared by everyone
empty
cleanup cost ≈ 0 ms for 3 tests (from measured per-test costs)
Start with shared, no cleanup and press run twice. Then try each strategy, with and without in parallel. Small numbers show the order operations actually ran in.

What you should have seen:

  • Shared, no cleanup fails even one-at-a-time. Run it twice and T1 crashes with a duplicate-key error: the first run's ada@example.com is still there. That's exactly what happens with a shared "dev" database on your laptop.
  • TRUNCATE works serially, but in parallel one test's cleanup empties the table while another test is still using it. In a real run with 50 parallel tests, 47 failed, with different counts each time.
  • Transaction per test passes in every mode, because a transaction's uncommitted rows are invisible to every other connection, and ROLLBACK throws them away (Transactions explains why). It's the default choice — §07 shows how to wire it.
  • Database per test also always passes, at ~95 ms per test. Use it when the code under test must really COMMIT (§07 lists the cases).
Practitioner§07

A transaction per test, in code

In one sentenceOpen a Database transaction at the start of the test, give it to the code as if it were the database, and roll it back at the end. Everything the test wrote vanishes, in about a millisecond.

Step through one test first:

One test, one transaction, rolled back
What each connection can see while the test runs.
Code
1func TestSignup(t *testing.T) {
2 tx, err := pool.Begin(ctx)
3 if err != nil { t.Fatal(err) }
4 t.Cleanup(func() { tx.Rollback(ctx) })
5
6 store := shop.NewStore(tx)
7 store.CreateUser(ctx, "ada@example.com", "Ada")
8
9 n, _ := store.CountUsers(ctx)
10 if n != 1 { t.Fatalf("count = %d, want 1", n) }
11}
the test's transaction
BEGIN
any other connection
sees: (nothing)
users table (committed)
(empty)
pool.Begin takes one connection from the pool and sends BEGIN. Everything this test does now happens inside one transaction: changes nobody else can see until COMMIT.
1 / 5

The trick: one interface for "pool" and "transaction"

For this to work, the code under test must not care whether it's talking to a pool or to a transaction. In Go, give the store a small Interface with just the methods it uses. pgx's pool, a single connection and a transaction all have those methods, so any of them fits:

store.go + test helper
// DBTX is satisfied by *pgxpool.Pool, *pgx.Conn and pgx.Tx.
type DBTX interface {
    Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
    Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
    QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
    Begin(ctx context.Context) (pgx.Tx, error) // on a pool: BEGIN; on a transaction: a savepoint
}

type Store struct{ db DBTX }                        // production: NewStore(pool)
func NewStore(db DBTX) *Store { return &Store{db: db} }

// --- in a _test.go file ---
var pool *pgxpool.Pool // one pool for the whole package, set up once in TestMain (§04)

func newTx(t *testing.T) pgx.Tx { // a pgx.Tx satisfies DBTX
    t.Helper()
    tx, err := pool.Begin(context.Background())
    if err != nil {
        t.Fatal(err)
    }
    t.Cleanup(func() { tx.Rollback(context.Background()) }) // ← undo everything
    return tx
}

func TestSignup(t *testing.T) {
    t.Parallel()                // safe: every test has its own transaction
    store := NewStore(newTx(t)) // ← the store can't tell it's a transaction
    // … CreateUser, assert …
}

This is the same idea as constructor injection: the store receives its database from outside, so a test can hand it something else — here not a fake, but a real transaction.

Where the trick breaks

A rolled-back transaction is not quite the same as production. Each of these was checked against real Postgres 16:

1

One failed statement poisons the whole transaction

After any error inside a transaction, Postgres refuses everything else until the transaction ends. So a test that expects a duplicate-key error and then keeps querying gets this, from a real run:

dup: email already taken
after: ERROR: current transaction is aborted, commands ignored until end of transaction block (SQLSTATE 25P02)

In production each request usually runs in its own transaction, so this never comes up — the test environment created it. Fix: wrap the statement that is expected to fail in a Savepoint. In pgx, calling Begin on a transaction creates one; roll back to it after the expected error and carry on:

sp, err := tx.Begin(ctx) // ← a savepoint inside the test transaction (tx came from newTx)
if err != nil {
    t.Fatal(err)
}
_, err = NewStore(sp).CreateUser(ctx, "bo@example.com", "Bo again")
if !errors.Is(err, ErrEmailTaken) {
    t.Fatalf("got %v, want ErrEmailTaken", err)
}
sp.Rollback(ctx) // undo just the failed part; tx is usable again
Expected error inside a test transaction
Without and with a savepoint. The errors are the real Postgres ones.
Code
1BEGIN;
2INSERT INTO users (email, name) VALUES ('bo@example.com', 'Bo');
3INSERT INTO users (email, name) VALUES ('bo@example.com', 'Bo 2');
4SELECT count(*) FROM users;
5ROLLBACK;
transaction state
transaction open
results
nothing yet
The test's transaction starts.
1 / 5
2

Code that opens its own transactions, or uses other connections

If DeleteUser calls pool.Begin() and COMMIT on a pool it holds directly, it ignores your test transaction: its changes are really saved, and they leak into other tests. Two fixes:

  • Begin through DBTX. That's why the interface above includes Begin. In production s.db.Begin(ctx) on the pool starts a real transaction. In a test, s.db is the test's transaction, and pgx turns Begin into a Savepoint, and the code's Commit into "release the savepoint". The work stays inside the test transaction and is still rolled back at the end. (Checked for real: a DeleteUser that begins and commits its own transaction passed inside a rolled-back test and left nothing behind.)
  • A database per test (below), when the code really must commit — or when a background worker or a second service connects separately: they open their own connections, so they can't see your uncommitted test rows at all.
3

Ids keep counting, and the clock stands still

  • A bigserial id comes from a sequence, which is never rolled back. Real run: the first test's user got id 1, the next test's user got id 2, though the first row was rolled back. Never assert id == 1; use the id the insert returned.
  • Inside one transaction, Postgres's now() returns the transaction's start time every time. In a real run, two now() calls 200 ms apart in one transaction returned the identical time. A test of "newest first" ordering can't tell them apart.

Database per test, from a template

When the code must COMMIT, give each test its own database. Creating one from scratch and migrating it is slow, but Postgres can copy an existing database: migrate once into a database called shop, then run CREATE DATABASE test_17 TEMPLATE shop for each test. Here shop is the template. One hard rule: nobody may be connected to shop during a copy, or Postgres refuses with source database "shop" is being accessed by other users (SQLSTATE 55006) (real error).

Copying a template database
Each test gets its own copy of the migrated database. Try it with and without a connection left open to shop.
shoptemplate · migrated
users, orders
0 connections
copy →
test databasesone per test
none yet
Make a few copies, then switch on “a pool still connected” and try again.
main_test.go — database per test (run for real)
var (
    pg    *postgres.PostgresContainer
    admin *pgxpool.Pool // connected to the "postgres" database — never to "shop"
    dbSeq atomic.Int64  // numbers the test databases: test_1, test_2, …
)

// poolFor opens a pool on another database in the same container.
func poolFor(ctx context.Context, dbName string) (*pgxpool.Pool, error) {
    cfg, err := pgxpool.ParseConfig(pg.MustConnectionString(ctx, "sslmode=disable"))
    if err != nil {
        return nil, err
    }
    cfg.ConnConfig.Database = dbName // same server, different database
    return pgxpool.NewWithConfig(ctx, cfg)
}

// In run(m), from §04, with two changes:
//   pg, err = postgres.Run(ctx, "postgres:16-alpine",
//       postgres.WithDatabase("shop"), postgres.BasicWaitStrategies()) // ← name the database
//   shopPool, err := poolFor(ctx, "shop")
//   err = Migrate(ctx, shopPool)
//   shopPool.Close() // ← disconnect from "shop" so it can be copied
//   admin, err = poolFor(ctx, "postgres")

func newDB(t *testing.T) *pgxpool.Pool {
    t.Helper()
    ctx := context.Background()
    name := fmt.Sprintf("test_%d", dbSeq.Add(1))
    if _, err := admin.Exec(ctx, "CREATE DATABASE "+name+" TEMPLATE shop"); err != nil { // ~95 ms
        t.Fatalf("create %s: %v", name, err)
    }
    db, err := poolFor(ctx, name)
    if err != nil {
        t.Fatal(err)
    }
    t.Cleanup(func() {
        db.Close()
        // WITH (FORCE), Postgres 13+: disconnect anyone still connected, then drop.
        if _, err := admin.Exec(ctx, "DROP DATABASE "+name+" WITH (FORCE)"); err != nil {
            t.Errorf("drop %s: %v", name, err)
        }
    })
    return db // the code under test may COMMIT freely: this database is the test's alone
}

Five parallel tests, each committing a user with the same email ada@example.com into its own database, all passed. Keeping one extra connection open to shop turned every CREATE DATABASE into the 55006 error above.

Practitioner§08

Test data builders

In one sentenceA test data builder creates a valid row with sensible defaults. The test overrides only the fields it cares about, so you can read what matters at a glance.

Integration tests need data: a user to place an order, an order to refund. There are three common ways to get it, and the first two age badly.

ApproachWhat it looks likeWhat goes wrong
Shared seed fileOne seed.sql with "user 1 is Ada, user 2 has 3 orders", loaded before all testsEvery test depends on it. Change Ada's email for one test and eleven others fail. Nobody dares touch it.
Copy-pasted INSERTsEach test writes its own full rowsAdd a NOT NULL column and you fix 200 tests. The 15 fields hide the 1 field the test is about.
BuildersnewUser(t, db), overriding only the fields the test needsAdd a column → change one function. Tests show only what they care about.

A Test data builder is just a function. Here's one for users:

builders_test.go
var seq atomic.Int64 // makes every generated email unique, even in parallel tests

// newUser inserts a valid user. Pass functions to change only the fields a test cares about.
func newUser(t *testing.T, db DBTX, changes ...func(*User)) User {
    t.Helper()
    u := User{
        Email: fmt.Sprintf("user%d@example.com", seq.Add(1)), // user1@…, user2@…
        Name:  "Test User",
    }
    for _, change := range changes {
        change(&u) // let the test overwrite fields
    }
    created, err := NewStore(db).CreateUser(context.Background(), u.Email, u.Name)
    if err != nil {
        t.Fatalf("newUser: %v", err)
    }
    return created // with the real id and created_at from the database
}

// In a test:
ada := newUser(t, db, func(u *User) { u.Name = "Ada" }) // ← the only thing this test cares about
someone := newUser(t, db)                               // ← any valid user will do

Builders that build their dependencies

The real payoff comes with related rows. An order needs a user; most order tests don't care which user. So the order builder makes one unless you pass one in:

builders_test.go
func newOrder(t *testing.T, db DBTX, user *User, totalCents int) int64 {
    t.Helper()
    if user == nil { // ← caller doesn't care: make a user automatically
        u := newUser(t, db)
        user = &u
    }
    var id int64
    err := db.QueryRow(context.Background(),
        `INSERT INTO orders (user_id, total_cents) VALUES ($1, $2) RETURNING id`,
        user.ID, totalCents).Scan(&id)
    if err != nil {
        t.Fatalf("newOrder: %v", err)
    }
    return id
}

// A refund test only cares about the total:
orderID := newOrder(t, db, nil, 4500)
Practitioner§09

The whole handler stack with httptest

In one sentenceRun your real router, middleware, handlers and store on a free local port, talk to it with a normal HTTP client, and check what a real caller would see — plus what landed in the database.

So far we tested the store: Go functions calling SQL. But a user never calls CreateUser; they send POST /users with some JSON. Between the two sit routing, JSON decoding, validation, error-to-status mapping and Middleware. Each is a seam where bugs hide: the handler maps "email taken" to 500 instead of 409, a field is called created_at in JSON when the frontend expects createdAt.

Go's standard library has a package just for this, httptest. Step through one request:

One request through the real stack
httptest.NewServer + a real database: only the network to the outside world is missing.
Code
1db := newTx(t)
2srv := httptest.NewServer(shop.NewServer(shop.NewStore(db)))
3defer srv.Close()
4
5res, err := http.Post(srv.URL+"/users", "application/json",
6 strings.NewReader(`{"email":"ada@example.com","name":"Ada"}`))
7// check: status 201, JSON body, and the row in the database
8if res.StatusCode != http.StatusCreated { t.Fatalf(…) }
9json.NewDecoder(res.Body).Decode(&got)
10db.QueryRow(ctx, `SELECT name FROM users WHERE id = $1`, got.ID)
the test (as HTTP client)
transaction open
your server: router → handler → store
idle
Postgres (test container)
tables ready (migrated)
Start a transaction for this test (§07), so whatever the request writes is rolled back afterwards.
1 / 7
api_test.go
func TestCreateUserAPI(t *testing.T) {
    db := newTx(t) // §07
    srv := httptest.NewServer(NewServer(NewStore(db))) // real router, real store, real SQL
    defer srv.Close()

    body := strings.NewReader(`{"email":"ada@example.com","name":"Ada"}`)
    res, err := http.Post(srv.URL+"/users", "application/json", body)
    if err != nil {
        t.Fatal(err)
    }
    defer res.Body.Close()

    if res.StatusCode != http.StatusCreated {
        t.Fatalf("status = %d, want 201", res.StatusCode)
    }
    var got User
    if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
        t.Fatal(err)
    }
    if got.ID == 0 || got.Email != "ada@example.com" {
        t.Errorf("response = %+v", got)
    }

    // Check the database too: the row is really there.
    var name string
    err = db.QueryRow(context.Background(),
        `SELECT name FROM users WHERE id = $1`, got.ID).Scan(&name)
    if err != nil || name != "Ada" {
        t.Errorf("row in DB: name=%q err=%v", name, err)
    }
}

What to check, and what not to

Check the three things a caller depends on: the status code, the fields of the body that clients use, and the state in the database. Don't compare the whole JSON string byte for byte: adding a harmless new field would break the test (a false alarm).

When the handler calls other services

If POST /users also calls an email provider, don't call the real one. Point the code at a stand-in server instead — httptest.NewServer again, with a handler that answers like the provider, or MSW in JavaScript. That's covered in Mocks, Stubs & Fakes. The line from §02 holds: real for what you run, stand-ins for what others run.

Staff-level§10

Making it fast

In one sentenceThe expensive part is starting Postgres (~2 s); a test inside a transaction costs ~1 ms. So start one container for many tests, isolate with transactions, and run tests in parallel.

Here's the same 50-test suite from §06 run five ways, measured on this machine (Go, Postgres 16, image already downloaded). Each test inserts three users and counts them:

SetupIsolation50 tests tookNotes
New container per test(fresh database each time)1 min 49 s~2.2 s per test, almost all of it start-up
One containerTRUNCATE after each, serial2.1 s start + 0.36 s47 of 50 failed when run in parallel
One containerDatabase per test from a template2.1 s start + 4.8 s (3.8 s parallel)~95 ms per CREATE DATABASE
One containerTransaction per test, serial2.1 s start + 69 ms
One containerTransaction per test, t.Parallel()2.1 s start + 28 msThe fastest correct option

The lesson is in the first and last rows: about 50× faster from where the container is started, not from anything clever in the tests. Try other suite sizes:

How long will the suite take?
Per-test costs measured on this machine (50-test suite, Postgres 16), scaled linearly. A model: compare the strategies, don't quote the exact figures.
estimated wall time
2.5 s
container start2.2 s
200 tests × 1.40 ms280 ms
The cheapest by far: ROLLBACK just throws away the transaction. Measured: 50 tests in 69 ms serial, 28 ms with t.Parallel(). The container start dominates.

The one-container setup is the TestMain / globalSetup code from §04; the transaction per test is §07. The rest of this section is about squeezing out more.

Reusing a container between runs (local only)

Both libraries can reuse a container: if one with the same name is already running, connect to it instead of starting a new one. In Go that's testcontainers.WithReuseByName("shop-test-pg"); in Node, .withReuse(). Measured here: the first run took 2.2 s to get a database; later runs took ~0.6 s with Ryuk on, and ~0.1 s with Ryuk disabled.

But there are catches, all checked for real:

  • With the Ryuk reaper on (the default), the reused container is still removed about 10 s after the test run ends. Reuse then only helps packages within a single go test ./... run. To keep it between runs you must turn Ryuk off (TESTCONTAINERS_RYUK_DISABLED=true) and remove the container yourself.
  • A reused database keeps its data between runs. Your migrations must cope with already-applied files (real migration tools track this), and every test must clean up after itself — the transaction approach does.
  • Don't use it in CI: every CI job should start from a clean, known state.
Staff-level§11

Running in CI

In one sentenceIntegration tests earn their keep in CI, on every pull request. There are two ways to get a database there: let Testcontainers start it, or let the CI system start it as a service container.

Option 1: Testcontainers, unchanged

GitHub's hosted Ubuntu runners have Docker installed and running, so the tests from §04–§10 run as they are. No database setup in the workflow at all:

.github/workflows/test.yml
name: test
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest        # Docker is preinstalled here
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with:
          go-version-file: go.mod
      - run: go test -race ./...  # TestMain starts Postgres itself

This is the simplest option and keeps CI identical to your laptop. It needs a runner where Docker is available: the hosted Linux runners are; the hosted macOS and Windows runners can't run Linux containers this way, and some self-hosted or Kubernetes-based runners need extra setup (Docker-in-Docker, or Testcontainers Cloud).

Option 2: a service container

Most CI systems can start helper containers next to your job. In GitHub Actions these are service containers:

.github/workflows/test.yml
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_PASSWORD: test
        ports:
          - 5432:5432               # fine here: this job is alone on its machine
        options: >-
          --health-cmd "pg_isready -h localhost -U postgres"
          --health-interval 2s
          --health-timeout 5s
          --health-retries 15
    env:
      TEST_DATABASE_URL: postgres://postgres:test@localhost:5432/postgres?sslmode=disable
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with:
          go-version-file: go.mod
      - run: go test -race ./...

One TestMain for both

Make the tests use TEST_DATABASE_URL when it's set, and start a container when it isn't. The same code then runs on your laptop (container) and in CI (either option):

main_test.go
func TestMain(m *testing.M) {
    flag.Parse() // so testing.Short() works inside TestMain
    if testing.Short() {
        log.Println("skipping integration tests in -short mode")
        return
    }
    os.Exit(run(m))
}

func run(m *testing.M) int {
    ctx := context.Background()
    dsn := os.Getenv("TEST_DATABASE_URL") // ← set by CI when a service container runs
    if dsn == "" {
        pg, err := postgres.Run(ctx, "postgres:16-alpine", postgres.BasicWaitStrategies())
        defer testcontainers.TerminateContainer(pg) // runs when run() returns, not at the end of the if
        if err != nil {
            log.Println(err)
            return 1
        }
        dsn = pg.MustConnectionString(ctx, "sslmode=disable")
    }
    // …open the pool, migrate, return m.Run() — as in §04
}

Checked both ways here: without the variable the test started a container (2.9 s); with a Postgres already running and TEST_DATABASE_URL set, the same package finished in 0.05 s.

Which option?

Testcontainers in CIService container
Same setup as local✓ identical✗ CI-only YAML
Isolation between packagesEach package can have its own containerOne shared database for the whole job — isolation (§06) matters more
Needs Docker access from the jobYesNo (the CI system runs the container)
Extra services (Redis, Kafka…)Add a module in codeAdd a block in YAML
Practitioner§12

Pitfalls gallery

In one sentenceAlmost every flaky or misleading integration test is one of these. Each has a cheap fix.
1

Sleeping instead of waiting

pg, _ := postgres.Run(ctx, "postgres:16-alpine") // no wait strategy
time.Sleep(3 * time.Second)                      // ← "should be enough"

Three seconds is too long on your laptop (every run wastes time) and too short on a loaded CI machine (connection refused, now and then). Fix: a wait strategy that checks a real condition — postgres.BasicWaitStrategies(), or the Node module's built-in health check (§04).

2

Hard-coded ports and hosts

postgres://localhost:5432/… in a test works until a local Postgres, a second package or a parallel CI job already holds 5432 (Docker: Bind for 0.0.0.0:5432 failed: port is already allocated). It also breaks where Docker isn't on localhost (some CI setups). Fix: always ask the container: ConnectionString(ctx) / getConnectionUri() (§03).

3

Testing against a different database

SQLite or an in-memory "Postgres-compatible" engine in tests, real Postgres in production. You get green tests and production errors about types, dates and locking. Fix: the same database engine and major version as production, in a container (§02).

4

Asserting on order without ORDER BY

SELECT … FROM users usually returns rows in insertion order on a small fresh table — until an UPDATE moves a row (real run in §02: [Bo Cy Ada L.]). Fix: add ORDER BY to any query whose order matters, and in tests either sort before comparing or check membership instead of position.

5

Asserting on ids and exact timestamps

if user.ID != 1 breaks as soon as another test (or a rolled-back insert) used the sequence (§07). created_at == time.Now() breaks on precision — Postgres keeps microseconds — and time zones. Fix: use the id the insert returned; for times, compare after Truncate(time.Microsecond), or check a range ("within the last minute"), and store times as timestamptz in UTC.

6

One giant shared seed file

seed.sql with forty rows that every test relies on. Each test silently depends on rows defined far away, and changing one breaks many. Fix: each test creates the rows it needs with builders (§08). Keep seed data only for true reference data, like a list of countries, and never modify it in tests.

7

Tests that only pass in one order

A test passes alone and fails in the suite (or the other way round) — leftover rows, a changed setting, a cache that wasn't cleared. Fix: isolation per test (§06). To find these, shuffle: go test -shuffle=on and Vitest's --sequence.shuffle run tests in random order; a failure that prints its seed can be replayed. More in Flaky Tests.

8

Forgetting the cache

You isolated Postgres perfectly, but the code also caches users in Redis — and the next test reads a user that was rolled back an hour ago in test time. Fix: isolate every stateful dependency: FLUSHDB on a per-test Redis database, or a key prefix per test, plus clearing any in-memory caches in the app.

9

Leaking containers

Starting containers with plain docker run in a script, then the script fails before docker stop. Do it a few hundred times and the CI machine runs out of disk or memory. Fix: let Testcontainers own the lifecycle — cleanup is registered right after start, and Ryuk removes leftovers if the process dies. If you disabled Ryuk for reuse (§10), clean up yourself.

10

Integration-testing everything

Once the database is easy to reach, it's tempting to test every price rule through POST /orders. Each test is now 10–100× slower than it needs to be, and failures point at "somewhere in the stack". Fix: logic in fast unit tests; integration tests for the seams — SQL, constraints, transactions, the HTTP contract (Why & What to Test discusses the balance).

Practitioner§13

Check yourself

Predict the answer before you click. Q1 is covered in §06, Q2 in §07, Q3 in §04, Q4 in §06 and §10, Q5 in §02.

QUESTION 1 / 5
Which test fails?

Both tests use one shared Postgres with no cleanup, and run in this order.

func TestCreateUser(t *testing.T) {
    store.CreateUser(ctx, "ada@example.com", "Ada")
}
func TestNoUsersYet(t *testing.T) {
    n, _ := store.CountUsers(ctx)
    if n != 0 { t.Fatalf("count = %d, want 0", n) }
}
QUESTION 2 / 5
Rolled back, but…

Each test runs in a transaction that is rolled back. Test A inserts a user and gets id = 1. Then test B inserts a user. What id does B get?

QUESTION 3 / 5
Why wait for the log line twice?

The Postgres wait strategy waits until database system is ready to accept connections appears twice. Why?

QUESTION 4 / 5
TRUNCATE in parallel

Tests share one database, call t.Parallel(), and each TRUNCATEs the tables in its cleanup. What happens?

QUESTION 5 / 5
Which test needs a real database?

You can only afford one integration test this week. Which bug most needs one?

Answered 0 / 5
Foundations§14

Cheat sheet

🎭
Real props for the seamsTest your SQL, constraints, transactions and HTTP contract against the real database. Keep logic in unit tests; keep third-party APIs behind stand-ins.
🐳
A fresh container, not a shared DBTestcontainers starts the same image as production in ~2 s and removes it afterwards. Same engine, same major version.
⏳
Wait for a signal, never sleepUse the module's wait strategy (log line ×2 + port for Postgres). Never time.Sleep.
🔌
Never hard-code portsAsk the container for its connection string; the host port is random.
📜
Real migrationsBuild the test schema with the same migration files production runs.
↩️
Transaction per testBEGIN → hand the code the transaction → ROLLBACK in cleanup. ~1 ms, parallel-safe.
🧱
Builders, unique by defaultnewUser(t, db) with defaults; tests override only what they assert on.
⚡
Start onceOne container per package (TestMain) or per run (Vitest globalSetup). Container per test was ~50× slower.
I want to…Use
Start Postgres from a Go testpostgres.Run(ctx, "postgres:16-alpine", postgres.BasicWaitStrategies()) + testcontainers.CleanupContainer(t, pg)
Start Postgres from a JS testawait new PostgreSqlContainer("postgres:16-alpine").start() → getConnectionUri()
One container for all testsGo: TestMain(m) → m.Run() · Vitest: globalSetup + project.provide / inject
Isolate tests cheaplyTransaction per test, rolled back in t.Cleanup / afterEach
Code under test COMMITsDatabase per test: CREATE DATABASE t_N TEMPLATE shop, or snapshot/restore (serial)
Expect an error mid-testWrap it in a savepoint (tx.Begin in pgx), roll back to it, continue
Test the HTTP stackGo: httptest.NewServer(router) · Node: server.listen(0) + fetch
Check resultsStatus code → body fields clients use → row in the database
Run in CITestcontainers on ubuntu-latest, or a service container + TEST_DATABASE_URL
Skip them while editinggo test -short ./... with testing.Short() in TestMain
Find order-dependent testsgo test -shuffle=on, vitest --sequence.shuffle
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in TestingEnd-to-End Testing
Read →