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.
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.
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:
| § | You'll learn |
|---|---|
| 02 | Which bugs only a real database can catch — and when a fake is still the right call |
| 03–05 | Just enough Docker, your first Testcontainers test in Go and JavaScript, and building the schema with real migrations |
| 06–08 | Keeping tests from trampling on each other's data, transactions that roll back, and test data builders |
| 09 | Testing the whole HTTP handler stack with httptest |
| 10–11 | Making it fast, and running it in CI |
| 12–14 | Pitfalls, a quiz and a cheat sheet |
What a fake can't tell you
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, aCHECKlike "total is never negative". Each of these is a constraint. - Types and precision: Postgres stores times to the microsecond, Go's
time.Timeholds nanoseconds; a JavaScriptDatekeeps 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:
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
}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 tests | Keep using a fake or stub |
|---|---|
| Your own database (Postgres, MySQL) — your schema, your SQL | Third-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 rules | Pure logic — prices, parsing, validation. Unit-test it without any database |
| Your own HTTP stack — router, middleware, JSON | Slow or random things — the real clock, random numbers. Inject them (Dependency Injection §07) |
Just enough Docker
Testcontainers drives Docker for you, so you only need three ideas.
Image
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.Container
Port mapping
Here is the whole idea done by hand in a terminal, with no library:
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 machineWhy 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:
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.
Your first Testcontainers test
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.
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.
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"
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 …
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:
=== 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.230sRedis 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.
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.
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
}Migrations: build the schema the real way
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:
-- 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)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.
//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.
Keeping tests out of each other's way
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:
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: TestManyThe 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.
BEGINborrows a connection and keeps it untilCOMMITorROLLBACK. Rows it writes are visible on that connection only; every other connection sees only committed data.
Four ways to clean up
| Strategy | How | Cost per test (measured) | In parallel? |
|---|---|---|---|
| Shared, no cleanup | Nothing | 0 | ✗ fails, serial too |
| TRUNCATE after each | Empty every table when the test ends | ~7 ms | ✗ one test wipes another's rows |
| Transaction per test | BEGIN before, ROLLBACK after — nothing is ever saved | ~1 ms | ✓ |
| Database per test | Copy 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:
What you should have seen:
- Shared, no cleanup fails even one-at-a-time. Run it twice and
T1crashes with a duplicate-key error: the first run'sada@example.comis 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
ROLLBACKthrows 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).
A transaction per test, in code
Step through one test first:
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) })56 store := shop.NewStore(tx)7 store.CreateUser(ctx, "ada@example.com", "Ada")89 n, _ := store.CountUsers(ctx)10 if n != 1 { t.Fatalf("count = %d, want 1", n) }11}
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.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:
// 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:
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 again1BEGIN;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;
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 includesBegin. In productions.db.Begin(ctx)on the pool starts a real transaction. In a test,s.dbis the test's transaction, and pgx turnsBegininto a Savepoint, and the code'sCommitinto "release the savepoint". The work stays inside the test transaction and is still rolled back at the end. (Checked for real: aDeleteUserthat 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.
Ids keep counting, and the clock stands still
- A
bigserialid 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 assertid == 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, twonow()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).
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.
Test data builders
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.
| Approach | What it looks like | What goes wrong |
|---|---|---|
| Shared seed file | One seed.sql with "user 1 is Ada, user 2 has 3 orders", loaded before all tests | Every test depends on it. Change Ada's email for one test and eleven others fail. Nobody dares touch it. |
| Copy-pasted INSERTs | Each test writes its own full rows | Add a NOT NULL column and you fix 200 tests. The 15 fields hide the 1 field the test is about. |
| Builders | newUser(t, db), overriding only the fields the test needs | Add 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:
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 doBuilders 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:
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)The whole handler stack with httptest
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:
1db := newTx(t)2srv := httptest.NewServer(shop.NewServer(shop.NewStore(db)))3defer srv.Close()45res, 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 database8if res.StatusCode != http.StatusCreated { t.Fatalf(…) }9json.NewDecoder(res.Body).Decode(&got)10db.QueryRow(ctx, `SELECT name FROM users WHERE id = $1`, got.ID)
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.
Making it fast
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:
| Setup | Isolation | 50 tests took | Notes |
|---|---|---|---|
| New container per test | (fresh database each time) | 1 min 49 s | ~2.2 s per test, almost all of it start-up |
| One container | TRUNCATE after each, serial | 2.1 s start + 0.36 s | 47 of 50 failed when run in parallel |
| One container | Database per test from a template | 2.1 s start + 4.8 s (3.8 s parallel) | ~95 ms per CREATE DATABASE |
| One container | Transaction per test, serial | 2.1 s start + 69 ms | |
| One container | Transaction per test, t.Parallel() | 2.1 s start + 28 ms | The 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:
| container start | 2.2 s |
| 200 tests × 1.40 ms | 280 ms |
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.
Running in CI
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:
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 itselfThis 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:
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):
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 CI | Service container | |
|---|---|---|
| Same setup as local | ✓ identical | ✗ CI-only YAML |
| Isolation between packages | Each package can have its own container | One shared database for the whole job — isolation (§06) matters more |
| Needs Docker access from the job | Yes | No (the CI system runs the container) |
| Extra services (Redis, Kafka…) | Add a module in code | Add a block in YAML |
Pitfalls gallery
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).
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).
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).
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.
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.
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.
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.
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.
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.
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).
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.
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) }
}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?
The Postgres wait strategy waits until database system is ready to accept connections appears twice. Why?
Tests share one database, call t.Parallel(), and each TRUNCATEs the tables in its cleanup. What happens?
You can only afford one integration test this week. Which bug most needs one?
Cheat sheet
time.Sleep.newUser(t, db) with defaults; tests override only what they assert on.| I want to… | Use |
|---|---|
| Start Postgres from a Go test | postgres.Run(ctx, "postgres:16-alpine", postgres.BasicWaitStrategies()) + testcontainers.CleanupContainer(t, pg) |
| Start Postgres from a JS test | await new PostgreSqlContainer("postgres:16-alpine").start() → getConnectionUri() |
| One container for all tests | Go: TestMain(m) → m.Run() · Vitest: globalSetup + project.provide / inject |
| Isolate tests cheaply | Transaction per test, rolled back in t.Cleanup / afterEach |
| Code under test COMMITs | Database per test: CREATE DATABASE t_N TEMPLATE shop, or snapshot/restore (serial) |
| Expect an error mid-test | Wrap it in a savepoint (tx.Begin in pgx), roll back to it, continue |
| Test the HTTP stack | Go: httptest.NewServer(router) · Node: server.listen(0) + fetch |
| Check results | Status code → body fields clients use → row in the database |
| Run in CI | Testcontainers on ubuntu-latest, or a service container + TEST_DATABASE_URL |
| Skip them while editing | go test -short ./... with testing.Short() in TestMain |
| Find order-dependent tests | go test -shuffle=on, vitest --sequence.shuffle |