Integration and System Testing
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 on127.0.0.1:0, servesh, returns a URL.defer ts.Close().- Time in tests: do not
time.Sleepand hope. Pass aClockinterface. Production usestime.Now. Tests pass a struct whoseNow()returns Tuesday 09:00. //go:build integrationon a file.go testskips it.go test -tags=integrationincludes 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 deskSave 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 testOutput (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 testOutput (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.
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.NewServerfor 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, thengo test -tags=integration. - Default
go teststays unit-fast. - No
time.Sleepas a synchronization strategy. - Integration tests still use tables,
t.Fatalf, andt.Helper. The extra machinery is the server or the clock, not a new framework.
Try this
- In
TestMenuServer, GET/missingand assert404. - Add a
TestIsOpencase at exactly08:00. Decide if that instant is open (the code uses!Before, so yes). - Move
TestMenuServerbehind//go:build integrationand confirm plaingo testno longer runs it. Restore or keep the tag on purpose.