Day 61 — integration tests
Day 61 — integration tests
Stage VI · ~3h
Goal: Write integration tests that exercise real storage (and optionally HTTP+DB together) with reliable setup/teardown—using SQLite, Docker, or testcontainers when available.
Why this day exists
Unit tests with fakes are fast but lie about SQL dialects, constraints, and transactions. Integration tests catch:
- Wrong placeholders
- Migration mistakes
- Unique index conflicts
- Scan/NULL issues
- Timezone / TEXT affinity surprises on SQLite
Balance: keep unit tests for pure logic; integration for the repository and optionally the full handler stack. Stage VI gate expects evidence that storage is real.
Theory 1 — Test pyramid for services
/ e2e few \ (browser/k8s — later)
/ integration \ (DB, cache, optional network)
/ unit many \ (validation, mappers, pure)
Flags/tags to separate:
//go:build integration
package store_testgo test ./... # unit default
go test -tags=integration ./... # integrationOr environment gate:
if os.Getenv("INTEGRATION") == "" {
t.Skip("set INTEGRATION=1")
}Prefer tags for files that import heavy deps; prefer env for optional slow suites in already-built packages.
Theory 2 — Isolation strategies
| Strategy | How | Notes |
|---|---|---|
| In-memory SQLite | New DB per test | Fast; dialect ≠ Postgres |
| Temp file SQLite | t.TempDir() |
Easy cleanup |
| Transaction rollback | Begin tx; inject tx; rollback | Fast; limited if code uses *sql.DB only |
| Docker Postgres | testcontainers / compose | Closest to prod |
| Shared DB + truncate | Truncate tables | Parallelism hard |
Prefer fresh schema per test via migrations.
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
dsn := "file:" + filepath.Join(t.TempDir(), "test.db") + "?_pragma=foreign_keys(1)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
ctx := context.Background()
if err := migrate.Apply(ctx, db, migrations); err != nil {
t.Fatal(err)
}
return db
}Theory 3 — testcontainers awareness
// conceptual
req := testcontainers.ContainerRequest{
Image: "postgres:16-alpine",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_PASSWORD": "pass",
"POSTGRES_DB": "app",
},
WaitingFor: wait.ForListeningPort("5432/tcp"),
}Use when dialect fidelity matters. Skip if Docker unavailable:
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("docker not available")
}Do not make default CI red when Docker is missing—gate with tags (integration,postgres).
Theory 4 — HTTP+DB integration
func TestCreateAndGet(t *testing.T) {
db := openTestDB(t)
repo := store.NewItemRepo(db)
h := api.New(repo, log).Handler()
ts := httptest.NewServer(h)
t.Cleanup(ts.Close)
resp, err := ts.Client().Post(ts.URL+"/v1/items",
"application/json", strings.NewReader(`{"name":"alpha"}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status=%d", resp.StatusCode)
}
// decode id; GET; assert name; also SELECT from db for belt-and-suspenders
}This catches routing + serialization + SQL together.
Theory 5 — Cleanup and parallelism
t.Cleanupover manual defer for readability
t.Parallel()only with isolated DBs
- Do not share
*sql.DBwith mutable global schema across parallel tests without locking
Golden data builders
func seedUser(t *testing.T, repo *UserRepo, email, password string) string {
t.Helper()
id := newID()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
if err := repo.Create(context.Background(), id, email, hash); err != nil {
t.Fatal(err)
}
return id
}Use MinCost in tests for speed; production uses DefaultCost.
Theory 6 — What belongs where
| Kind | Examples | Location |
|---|---|---|
| Unit | validateCreateItem, DTO mapping |
_test.go no tag |
| Integration | Repo CRUD, migrations | //go:build integration |
| Contract HTTP | httptest + fake repo | unit-ish, fast |
| HTTP+DB | NewServer + real repo | integration tag |
Do not delete unit tests when you add integration—keep both.
Worked example — build-tagged integration test
// file: store/item_integration_test.go
//go:build integration
package store_test
import (
"context"
"testing"
"example.com/day61/store"
)
func TestItemRepoIntegration(t *testing.T) {
db := openTestDB(t)
repo := store.NewItemRepo(db)
ctx := context.Background()
if err := repo.Create(ctx, "id1", "alpha"); err != nil {
t.Fatal(err)
}
got, err := repo.Get(ctx, "id1")
if err != nil || got.Name != "alpha" {
t.Fatalf("%+v %v", got, err)
}
// unique constraint / PK conflict
if err := repo.Create(ctx, "id1", "dup"); err == nil {
t.Fatal("expected conflict")
}
if _, err := repo.Get(ctx, "missing"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("want not found, got %v", err)
}
}Lab 1 — Setup
mkdir -p ~/lab/90daysofx/01-go/day61
cd ~/lab/90daysofx/01-go/day61
go mod init example.com/day61Bring store + migrations + API from prior days (or minimal subset with create/get).
Lab 2 — Repository integration suite
Tests:
- Migrate fresh DB
- CRUD lifecycle
- Not found
- Conflict on PK/unique
- List limit
go test -tags=integration ./store/ -count=1 -raceLab 3 — API integration
httptest server + real repo + create/login/get protected route if auth present. Assert both HTTP status and a DB row when relevant.
func TestAPICreatePersists(t *testing.T) {
if os.Getenv("INTEGRATION") == "" {
t.Skip("INTEGRATION=1")
}
db := openTestDB(t)
repo := store.NewItemRepo(db)
ts := httptest.NewServer(api.New(repo, slog.Default()).Handler())
t.Cleanup(ts.Close)
resp, err := ts.Client().Post(ts.URL+"/v1/items",
"application/json", strings.NewReader(`{"name":"persist-me"}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status=%d", resp.StatusCode)
}
// decode id from JSON, then:
// got, err := repo.Get(context.Background(), id)
// assert name
}Lab 4 — CI-friendly skip
Document in README:
go test ./... # units
INTEGRATION=1 go test ./... -tags=integrationEnsure default go test ./... stays green without Docker and without -tags=integration.
Lab 5 — Optional Postgres container
If Docker works, one test file //go:build integration,postgres proving the same repo against Postgres DSN. Note placeholder differences ($1 vs ?)—use driver-specific SQL files or a small query abstraction.
If you skip: write two sentences on dialect risk and when you would invest in Postgres CI.
Common gotchas
| Gotcha | Fix |
|---|---|
| Tests depend on developer’s laptop DB state | Fresh migrate each test |
| Parallel + shared DB | Isolate or serial |
| Forgetting Cleanup close | t.Cleanup |
| Integration always on by default | Tags/env skip |
| Flaky sleeps | Wait on readiness, not blind sleep (containers) |
| Asserting only status, not DB rows | Read back from DB |
| Using production bcrypt cost in huge suites | MinCost in tests |
| SQLite-only CI, Postgres in prod | At least one dialect-fidelity path |
Checkpoint
- Integration tests for repo with real SQL engine
- Migrations applied in test setup
- Cleanup reliable
- Default unit tests don’t require Docker
- Optional HTTP+DB path
- README explains how to run
Commit
git add .
git commit -m "day61: integration tests for store and API"Write three personal gotchas before continuing.
Tomorrow
Day 62 — Stage VI gate: ship a service with data store + auth + tests—architecture paragraph included.