Testing in Depth: Fakes, httptest & synctest
Testing, Benchmarks & Fuzzing showed how to test pure functions: give an input, check the output. Real services are harder. They send SMS messages, call other APIs, wait for timers and talk to databases. You cannot send a real SMS every time someone runs go test, and you do not want a test that waits five real minutes.
This lesson shows the techniques Go teams use every day: fakes behind small interfaces, golden files, testing HTTP clients against a throwaway server, controlling time with testing/synctest, and keeping slow integration tests separate.
Fakes behind small interfaces
A test double is a stand-in for a real dependency. The simplest kind is a fake: a small, working implementation that keeps everything in memory. In Go you make code fakeable by depending on an interface, not on a concrete SMS client. Here an order service sends a confirmation message:
// order/order.go
package order
import (
"context"
"fmt"
)
// Notifier sends a message to a customer. The real one calls an SMS gateway.
type Notifier interface {
Notify(ctx context.Context, phone, msg string) error
}
type Service struct {
notifier Notifier
}
func NewService(n Notifier) *Service { return &Service{notifier: n} }
func (s *Service) Confirm(ctx context.Context, id int, phone string) error {
msg := fmt.Sprintf("Order #%d confirmed. Thank you!", id)
if err := s.notifier.Notify(ctx, phone, msg); err != nil {
return fmt.Errorf("confirm order %d: %w", id, err)
}
return nil
}The test file defines a fake that records messages, and can also pretend the gateway is down:
// order/order_test.go
package order
import (
"context"
"errors"
"slices"
"testing"
)
type fakeNotifier struct {
sent []string
err error
}
func (f *fakeNotifier) Notify(ctx context.Context, phone, msg string) error {
if f.err != nil {
return f.err
}
f.sent = append(f.sent, phone+": "+msg)
return nil
}
func TestConfirmSendsSMS(t *testing.T) {
fake := &fakeNotifier{}
if err := NewService(fake).Confirm(t.Context(), 42, "9800000001"); err != nil {
t.Fatalf("Confirm: %v", err)
}
want := []string{"9800000001: Order #42 confirmed. Thank you!"}
if !slices.Equal(fake.sent, want) {
t.Errorf("sent = %q, want %q", fake.sent, want)
}
}
func TestConfirmGatewayDown(t *testing.T) {
errDown := errors.New("gateway down")
err := NewService(&fakeNotifier{err: errDown}).Confirm(t.Context(), 42, "9800000001")
if !errors.Is(err, errDown) {
t.Fatalf("err = %v, want it to wrap %v", err, errDown)
}
}The second test checks the unhappy path, which is where real bugs hide. Notice that the interface has only one method. Small interfaces, defined by the package that uses them, keep fakes tiny. If an interface had twenty methods, every fake would need twenty methods too.
Golden files in testdata
Some outputs are long: an invoice, a report, a generated email. Writing them inside the test as a string is painful. A golden file stores the expected output in the testdata folder, which the Go tool ignores when building. A flag rewrites the file when the output changes on purpose:
var update = flag.Bool("update", false, "rewrite golden files")
func TestRenderInvoice(t *testing.T) {
got := RenderInvoice([]Line{{"Masala dosa", 2, 90}, {"Filter coffee", 2, 30}})
path := filepath.Join("testdata", "invoice.golden")
if *update {
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if got != string(want) {
t.Errorf("invoice changed\n got:\n%s\nwant:\n%s", got, want)
}
}Run go test ./billing -update once to create the file, read it carefully, and commit it. From then on a plain go test compares against it, and the Git diff of the golden file shows reviewers exactly what changed. Tests run with the package folder as the working directory, which is why the relative path testdata/... works.
Testing an HTTP client with httptest.NewServer
You met httptest.NewRecorder for testing handlers. For code that calls an API, httptest.NewServer starts a real server on a random local port and gives you its address in srv.URL. Here is a client for a train fare API:
// fares/client.go
func (c *Client) Fare(ctx context.Context, train string) (int, error) {
req, err := http.NewRequestWithContext(ctx, "GET", c.BaseURL+"/fares/"+train, nil)
if err != nil {
return 0, err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("fare %s: status %d", train, resp.StatusCode)
}
var body struct {
Rupees int `json:"rupees"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return 0, fmt.Errorf("fare %s: %w", train, err)
}
return body.Rupees, nil
}// fares/client_test.go
func TestFare(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/fares/12951" {
http.NotFound(w, r)
return
}
fmt.Fprint(w, `{"rupees": 1655}`)
}))
defer srv.Close()
c := &Client{BaseURL: srv.URL, HTTP: srv.Client()}
got, err := c.Fare(t.Context(), "12951")
if err != nil {
t.Fatal(err)
}
if got != 1655 {
t.Errorf("Fare = %d, want 1655", got)
}
if _, err := c.Fare(t.Context(), "00000"); err == nil {
t.Error("want an error for an unknown train")
}
}The client is tested end to end, including the real HTTP request, status handling and JSON decoding, with no internet connection. This is why the client takes BaseURL as a field instead of a hard-coded address.
Step by step: testing time with synctest
Code that waits is slow to test. A cache that forgets entries after five minutes would need a five-minute test. The testing/synctest package (stable since Go 1.25) runs a test inside a bubble with a fake clock. Inside the bubble, time.Now starts at midnight UTC on 1 January 2000, and the clock jumps forward instantly whenever every goroutine in the bubble is blocked, for example in time.Sleep.
- Write the code normally, using
time.Nowand timers; it needs no special clock parameter. - In the test, wrap the body in
synctest.Test(t, func(t *testing.T) { ... }). - Use
time.Sleepto move the fake clock forward, then check the result. - If background goroutines must finish first, call
synctest.Wait(), which waits until all of them are blocked.
func TestCacheExpiry(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
c := cache.New(5 * time.Minute)
c.Set("pnr:4521", "confirmed")
time.Sleep(4 * time.Minute)
if _, ok := c.Get("pnr:4521"); !ok {
t.Fatal("entry expired too early")
}
time.Sleep(2 * time.Minute)
if _, ok := c.Get("pnr:4521"); ok {
t.Fatal("entry should have expired after 6 minutes")
}
})
}=== RUN TestCacheExpiry --- PASS: TestCacheExpiry (0.00s) PASS ok example.com/cache 0.004s
Six minutes of fake time passed in a few microseconds of real time. Go 1.27 added synctest.Sleep, which sleeps and then waits for the other goroutines to settle, and httptest.NewTestServer, an in-memory test server that works inside a bubble.
Integration tests and useful flags
Tests against a real PostgreSQL database are slower and need setup, so keep them separate. One common way is to skip them in short mode:
func TestStoreWithPostgres(t *testing.T) {
if testing.Short() {
t.Skip("needs PostgreSQL; skipped with -short")
}
// connect using os.Getenv("TEST_DATABASE_URL") ...
}Developers run go test -short ./... on their laptop and the CI server runs everything. Another way is a build constraint line //go:build integration at the top of the file, so it compiles only with go test -tags=integration ./....
| Flag | What it does |
|---|---|
-race | Runs tests with the race detector |
-count=1 | Ignores cached results and runs again |
-shuffle=on | Runs tests in random order to catch hidden dependencies |
-short | Sets testing.Short() so slow tests can skip |
-run Name/sub | Runs only matching tests and subtests |
Common mistakes
- Huge interfaces just for testing. Define a small interface where it is used, with only the methods you call.
- Tests that sleep for real. Use
synctestinstead oftime.Sleep(2 * time.Second)in tests. - Calling real external services from unit tests. Use a fake or
httptest.NewServer; keep real calls in integration tests. - Updating golden files without reading them. Review the diff, or you will approve your own bug.
Key points
- Depend on small interfaces so tests can pass in fakes that record calls or return errors.
- Golden files in
testdatastore long expected outputs; an-updateflag rewrites them. httptest.NewServergives HTTP client code a real local server to call.synctest.Testruns a test with a fake clock, so time-based code is tested instantly.- Separate slow integration tests with
testing.Short()or a build tag, and run tests with-raceand-shuffle=onin CI.
Exercise
Write a test for the Fare client that checks it returns an error when the API answers 503 Service Unavailable, and that the error message contains status 503. Use httptest.NewServer with a handler that always returns that status.
Show solution
func TestFareServerDown(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "maintenance", http.StatusServiceUnavailable)
}))
defer srv.Close()
c := &Client{BaseURL: srv.URL, HTTP: srv.Client()}
_, err := c.Fare(t.Context(), "12951")
if err == nil {
t.Fatal("want an error, got nil")
}
if !strings.Contains(err.Error(), "status 503") {
t.Errorf("error = %q, want it to mention status 503", err)
}
}http.Error writes the status code and a short text body. The client sees a status other than 200 and returns its error before trying to decode JSON, which is exactly the behaviour the test protects.
Quick Quiz
1. Why does Service take a Notifier interface instead of a concrete SMS client?
Depending on an interface lets you swap in a fake during tests.
2. Where should golden files live?
The Go tool ignores testdata when building, and tests can read it with relative paths.
3. What happens to time.Sleep(4 * time.Minute) inside synctest.Test?
When every goroutine in the bubble is blocked, the fake clock advances.
4. Which flag lets slow tests skip themselves by checking testing.Short()?
In short mode, testing.Short() returns true.
Learn Go with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
