Integration and System Testing

Updated

September 13, 2026

Integration and System Testing

An integration test runs several real pieces at once: an HTTP server plus a client, or a clock plus opening hours. It still lives in _test.go. The boring defaults: httptest.NewServer for HTTP, a fake clock for time, and a build tag so slow tests do not run on every go test.

Mental model

  • Unit test: one function, fakes at the edge if needed.
  • Integration test: two or more collaborating packages or a real HTTP stack. Still no production database unless you meant to pay for it.
  • httptest.NewServer(h) listens on 127.0.0.1:0, serves h, returns a URL. defer ts.Close().
  • Time in tests: do not time.Sleep and hope. Pass a Clock interface. Production uses time.Now. Tests pass a struct whose Now() returns Tuesday 09:00.
  • //go:build integration on a file. go test skips it. go test -tags=integration includes it.

Worked examples

Case 1: A real test server

The handler is the same idea as the last chapter. The test speaks HTTP over a loopback port, which catches mistakes ResponseRecorder will not (redirects, client headers).

Empty directory:

go mod init desk

Save as server.go:

// server.go
package desk

import (
    "fmt"
    "net/http"
)

func NewMux() http.Handler {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /menu", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "soup 400\n")
    })
    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "ok")
    })
    return mux
}

Save as server_test.go:

// server_test.go
package desk

import (
    "io"
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestMenuServer(t *testing.T) {
    ts := httptest.NewServer(NewMux())
    defer ts.Close()

    res, err := http.Get(ts.URL + "/menu")
    if err != nil {
        t.Fatalf("GET /menu: %v", err)
    }
    defer res.Body.Close()
    body, err := io.ReadAll(res.Body)
    if err != nil {
        t.Fatalf("read: %v", err)
    }
    if res.StatusCode != http.StatusOK {
        t.Fatalf("status %d", res.StatusCode)
    }
    if string(body) != "soup 400\n" {
        t.Fatalf("body %q", body)
    }
}

Run:

go test

Output (the duration varies):

PASS
ok      desk    0.004s

GET /menu in HandleFunc is Go’s method-aware mux (1.22+). A GET to /health is a second route; add a test when you care. Always Close the server and the body.

Case 2: A fake clock

The desk is open from 08:00 to 16:00 local. Tests must not depend on the wall clock.

Save as hours.go:

// hours.go
package desk

import "time"

type Clock interface {
    Now() time.Time
}

type realClock struct{}

func (realClock) Now() time.Time { return time.Now() }

func IsOpen(c Clock, day time.Time) bool {
    now := c.Now()
    open := time.Date(day.Year(), day.Month(), day.Day(), 8, 0, 0, 0, day.Location())
    close := time.Date(day.Year(), day.Month(), day.Day(), 16, 0, 0, 0, day.Location())
    return !now.Before(open) && now.Before(close)
}

func OpenNow() bool {
    n := time.Now()
    return IsOpen(realClock{}, n)
}

Save as hours_test.go:

// hours_test.go
package desk

import (
    "testing"
    "time"
)

type fakeClock struct{ t time.Time }

func (f fakeClock) Now() time.Time { return f.t }

func TestIsOpen(t *testing.T) {
    loc := time.UTC
    day := time.Date(2026, 3, 10, 0, 0, 0, 0, loc)
    tests := []struct {
        name string
        at   time.Time
        want bool
    }{
        {name: "morning", at: time.Date(2026, 3, 10, 9, 0, 0, 0, loc), want: true},
        {name: "just before close", at: time.Date(2026, 3, 10, 15, 59, 0, 0, loc), want: true},
        {name: "at close", at: time.Date(2026, 3, 10, 16, 0, 0, 0, loc), want: false},
        {name: "dawn", at: time.Date(2026, 3, 10, 7, 59, 0, 0, loc), want: false},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := IsOpen(fakeClock{t: tt.at}, day)
            if got != tt.want {
                t.Fatalf("IsOpen(%s) = %t, want %t", tt.at.Format(time.Kitchen), got, tt.want)
            }
        })
    }
}

Run:

go test

Output (the duration varies):

PASS
ok      desk    0.003s

OpenNow is untested on purpose: it touches the real clock. Production main calls OpenNow. Tests call IsOpen with fakeClock. Do not sleep until 16:00.

Case 3: Build tags so integration tests stay off the default path

A test that hits the server and checks /health is slower and more coupled. Tag it. Default go test stays fast.

Save as health_integration_test.go:

// health_integration_test.go
//go:build integration

package desk

import (
    "io"
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestHealthIntegration(t *testing.T) {
    ts := httptest.NewServer(NewMux())
    defer ts.Close()

    res, err := http.Get(ts.URL + "/health")
    if err != nil {
        t.Fatalf("GET /health: %v", err)
    }
    defer res.Body.Close()
    body, err := io.ReadAll(res.Body)
    if err != nil {
        t.Fatalf("read: %v", err)
    }
    if string(body) != "ok" {
        t.Fatalf("body %q", body)
    }
}

Run without the tag (Cases 1–2 still pass; this file is ignored):

go test

Output (the duration varies):

PASS
ok      desk    0.004s

Run with the tag:

go test -tags=integration

Output (the duration varies):

PASS
ok      desk    0.004s

Confirm the tag is doing work:

go test -tags=integration -v -run TestHealthIntegration

Output (the duration varies):

=== RUN   TestHealthIntegration
--- PASS: TestHealthIntegration (0.00s)
PASS
ok      desk    0.004s

Without -tags=integration, that -run matches nothing and go test still exits 0 (no tests to run is not a failure unless you pass -count tricks or use CI that checks coverage of that name). Your CI job for integration is the one that passes -tags=integration.

The trap

Sleeping to “wait for the server” or for a clock. Flaky on a loaded machine, slow on a fast one.

Save as sleepy_test.go — do not keep this as the real test:

// sleepy_test.go
package desk

import (
    "testing"
    "time"
)

func TestOpenNowFlaky(t *testing.T) {
    time.Sleep(50 * time.Millisecond)
    _ = OpenNow()
}

It “passes” and teaches nothing. Use httptest.NewServer (it is listening before it returns) and fakeClock. If you must wait for a condition, poll with t.Context() and a timeout — still not Sleep as the assertion.

The boring rule

  • httptest.NewServer for client/server tests. defer ts.Close().
  • Inject time through a tiny Clock. Fake it in tests.
  • Tag slow or environment-heavy tests: //go:build integration, then go test -tags=integration.
  • Default go test stays unit-fast.
  • No time.Sleep as a synchronization strategy.
  • Integration tests still use tables, t.Fatalf, and t.Helper. The extra machinery is the server or the clock, not a new framework.

Try this

  1. In TestMenuServer, GET /missing and assert 404.
  2. Add a TestIsOpen case at exactly 08:00. Decide if that instant is open (the code uses !Before, so yes).
  3. Move TestMenuServer behind //go:build integration and confirm plain go test no longer runs it. Restore or keep the tag on purpose.