Day 61 — integration tests

Updated

July 30, 2026

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_test
go test ./...                        # unit default
go test -tags=integration ./...      # integration

Or 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.Cleanup over manual defer for readability
  • t.Parallel() only with isolated DBs
  • Do not share *sql.DB with 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/day61

Bring store + migrations + API from prior days (or minimal subset with create/get).


Lab 2 — Repository integration suite

Tests:

  1. Migrate fresh DB
  2. CRUD lifecycle
  3. Not found
  4. Conflict on PK/unique
  5. List limit
go test -tags=integration ./store/ -count=1 -race

Lab 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=integration

Ensure 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.