Step 20 of 24

Testing in Go

Write table-driven tests, test handlers with httptest, swap dependencies for fakes, and run integration tests against PostgreSQL.

16 min 8 sections

Go ships with everything you need to test a backend: the testing package, the go test runner, a race detector, coverage, and net/http/httptest for HTTP. There's no framework to choose and no assertion library required. This lesson covers how Go tests are structured, the table-driven style the community has standardised on, testing handlers without a network, replacing dependencies with test doubles, and running slower integration tests against a real database.

Your first test

Tests live next to the code in files ending _test.go, in the same package, so they can reach unexported identifiers. A test is a function named TestXxx that takes a *testing.T. Here's a function worth testing, a parser for ?limit= query values:

go
// ParseLimit parses a ?limit= query value. Empty means def.
func ParseLimit(s string, def, max int) (int, error) {
	if s == "" {
		return def, nil
	}
	n, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("limit %q is not a number", s)
	}
	if n < 1 || n > max {
		return 0, fmt.Errorf("limit must be between 1 and %d", max)
	}
	return n, nil
}
go
func TestParseLimitDefault(t *testing.T) {
	got, err := ParseLimit("", 20, 100)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if got != 20 {
		t.Errorf("ParseLimit(\"\") = %d; want 20", got)
	}
}

There are no assertion helpers; you write an if and report a failure. t.Errorf marks the test failed and keeps going, so you see every problem at once. t.Fatalf marks it failed and stops the test immediately, which you want when continuing makes no sense (here, checking got after an error). Write messages as Func(input) = got; want expected so a failure explains itself without opening the test file.

The commands you'll use daily:

bash
go test ./...                       # every package in the module
go test -run TestParseLimit ./api   # only tests whose name matches the regex
go test -v -count=1 ./...           # verbose, and bypass the result cache
go test -race ./...                 # detect data races (use this in CI)
go test -cover ./...                # print statement coverage per package

go test caches passing results and skips packages whose code hasn't changed; -count=1 forces a re-run. -race instruments memory accesses and fails the test if two goroutines touch the same variable without synchronisation. It slows tests down, so run it in CI on every change.

Table-driven tests

Testing one input per function gets repetitive fast. The idiomatic Go answer is a table: a slice of cases, each with a name, inputs and expected outputs, and one loop that runs them all as subtests:

go
func TestParseLimit(t *testing.T) {
	tests := []struct {
		name    string
		in      string
		want    int
		wantErr bool
	}{
		{name: "empty uses default", in: "", want: 20},
		{name: "valid", in: "50", want: 50},
		{name: "maximum", in: "100", want: 100},
		{name: "zero", in: "0", wantErr: true},
		{name: "too large", in: "500", wantErr: true},
		{name: "not a number", in: "ten", wantErr: true},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			got, err := ParseLimit(tt.in, 20, 100)
			if tt.wantErr {
				if err == nil {
					t.Fatalf("ParseLimit(%q) = %d, nil; want an error", tt.in, got)
				}
				return
			}
			if err != nil {
				t.Fatalf("ParseLimit(%q) unexpected error: %v", tt.in, err)
			}
			if got != tt.want {
				t.Errorf("ParseLimit(%q) = %d; want %d", tt.in, got, tt.want)
			}
		})
	}
}

Adding a case is now one line, and edge cases are easy to see at a glance. t.Run gives each case its own name, and a Fatalf in one subtest stops only that case. If the range check had a bug, the output would name the failing case:

text
--- FAIL: TestParseLimit (0.00s)
    --- FAIL: TestParseLimit/too_large (0.00s)
        limit_test.go:31: ParseLimit("500") = 500, nil; want an error
FAIL

Spaces in names become underscores, so you can re-run one case with go test -run 'TestParseLimit/too_large'. t.Parallel() lets independent subtests run concurrently. Since Go 1.22 each loop iteration has its own tt variable, so the old tt := tt line is no longer needed.

Helpers, cleanup and test context

A few testing.T methods keep tests tidy:

  • t.Helper(): call it at the top of a helper function so failures report the caller's line, not the helper's.
  • t.Cleanup(fn): registers teardown that runs when the test finishes, even if it fails. Helpers can create a resource and clean it up themselves.
  • t.TempDir(): a fresh directory, deleted automatically.
  • t.Setenv(key, value): sets an environment variable for this test only.
  • t.Context() (Go 1.24): a context that is cancelled when the test ends, ready to pass to code under test.

A small generic helper covers most equality checks:

go
func assertEqual[T comparable](t *testing.T, got, want T) {
	t.Helper()
	if got != want {
		t.Errorf("got %v; want %v", got, want)
	}
}

For slices, maps and nested structs, reflect.DeepEqual works, though many teams use the github.com/google/go-cmp/cmp module, whose cmp.Diff(want, got) prints exactly which fields differ.

Testing HTTP handlers with httptest

You don't need a running server to test a handler. httptest.NewRequest builds an *http.Request, and httptest.NewRecorder is a ResponseWriter that records the status, headers and body. Send requests through your real router so method matching and path values like {id} behave exactly as in production.

Here's a handler that depends on a store through an interface:

go
var ErrNotFound = errors.New("not found")

type ArticleStore interface {
	Get(ctx context.Context, id int64) (Article, error)
}

type Server struct {
	Articles ArticleStore
}

func (s *Server) Routes() *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /v1/articles/{id}", s.getArticle)
	return mux
}

func (s *Server) getArticle(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil {
		http.Error(w, "invalid id", http.StatusBadRequest)
		return
	}
	a, err := s.Articles.Get(r.Context(), id)
	if errors.Is(err, ErrNotFound) {
		http.Error(w, "article not found", http.StatusNotFound)
		return
	}
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(a)
}

And its test:

go
func TestGetArticle(t *testing.T) {
	store := &fakeArticles{articles: map[int64]Article{42: {ID: 42, Title: "Hello"}}}
	srv := &Server{Articles: store}

	req := httptest.NewRequest(http.MethodGet, "/v1/articles/42", nil)
	rec := httptest.NewRecorder()
	srv.Routes().ServeHTTP(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d; want %d; body: %s", rec.Code, http.StatusOK, rec.Body)
	}
	var got Article
	if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
		t.Fatalf("decode body: %v", err)
	}
	assertEqual(t, got, Article{ID: 42, Title: "Hello"})
}

For code that makes HTTP calls, such as a client for a payment provider, httptest.NewServer(handler) starts a real server on a random local port. Point your client's base URL at srv.URL, make the handler return whatever response or error the case needs, and defer srv.Close().

Test doubles: fakes over mocks

The handler test above never touched a database because Server depends on the ArticleStore interface, not on *pgxpool.Pool. In the test, a fake, a simple working implementation backed by a map, stands in for the real store:

go
type fakeArticles struct {
	articles map[int64]Article
	err      error // if set, every call fails with it
}

func (f *fakeArticles) Get(_ context.Context, id int64) (Article, error) {
	if f.err != nil {
		return Article{}, f.err
	}
	a, ok := f.articles[id]
	if !ok {
		return Article{}, ErrNotFound
	}
	return a, nil
}

The err field turns the fake into a stub for failure paths: set it to errors.New("connection refused") and check that the handler returns 500 without leaking the message. Other kinds of double you'll hear about are spies, which record calls for later inspection, and mocks, which are pre-programmed with expected calls and fail if those calls don't happen.

Prefer hand-written fakes and stubs. They're plain Go, they test what your code does rather than which methods it called in which order, and they survive refactors. Two habits make them easy to write:

  • Define interfaces where they're used, with only the methods the consumer needs. A one-method ArticleStore is trivial to fake; a 30-method repository interface is not.
  • Inject dependencies through struct fields or constructor arguments, never through package-level globals.

Integration tests against a real database

SQL is code, and the only reliable way to test it is to run it against PostgreSQL. Keep these tests in the same packages, but make them skip cleanly when no database is configured, so go test ./... still works on a laptop:

go
func newTestTx(t *testing.T) pgx.Tx {
	t.Helper()
	dsn := os.Getenv("TEST_DATABASE_URL")
	if dsn == "" {
		t.Skip("TEST_DATABASE_URL not set; skipping integration test")
	}
	pool, err := pgxpool.New(context.Background(), dsn)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(pool.Close)

	tx, err := pool.Begin(t.Context())
	if err != nil {
		t.Fatal(err)
	}
	// Roll back everything the test wrote, so tests never see each other's data.
	t.Cleanup(func() { tx.Rollback(context.Background()) })
	return tx
}

Wrapping each test in a transaction that's always rolled back gives every test a clean database without truncating tables. It works when your store functions accept the DBTX interface from the migrations lesson, which both the pool and a pgx.Tx satisfy. In CI, start PostgreSQL as a service container, run your migrations, and set TEST_DATABASE_URL. (The testcontainers-go library can instead start a throwaway container from inside the test process.)

A balanced suite is mostly fast unit and handler tests with fakes, plus a smaller set of integration tests that prove the SQL and wiring are right.

Practice

Using the Server, getArticle handler and fakeArticles from this lesson, write TestGetArticleStatuses, a table-driven test that sends requests through Routes() and checks the status code for:

  1. An existing article (200).
  2. A missing article (404).
  3. A non-numeric ID such as /v1/articles/abc (400).
  4. A store that fails with an unexpected error (500), whose response body must not contain the store's error text.
  5. A POST to the same URL (405).

Run it with go test -race -run TestGetArticleStatuses -v ./....

Show solution
go
func TestGetArticleStatuses(t *testing.T) {
	found := &fakeArticles{articles: map[int64]Article{42: {ID: 42, Title: "Hello"}}}

	tests := []struct {
		name   string
		method string
		path   string
		store  *fakeArticles
		want   int
	}{
		{"found", http.MethodGet, "/v1/articles/42", found, http.StatusOK},
		{"missing", http.MethodGet, "/v1/articles/7", found, http.StatusNotFound},
		{"bad id", http.MethodGet, "/v1/articles/abc", found, http.StatusBadRequest},
		{"store error", http.MethodGet, "/v1/articles/42",
			&fakeArticles{err: errors.New("connection refused")}, http.StatusInternalServerError},
		{"wrong method", http.MethodPost, "/v1/articles/42", found, http.StatusMethodNotAllowed},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			srv := &Server{Articles: tt.store}
			rec := httptest.NewRecorder()
			srv.Routes().ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil))

			if rec.Code != tt.want {
				t.Fatalf("%s %s: status = %d; want %d", tt.method, tt.path, rec.Code, tt.want)
			}
			if strings.Contains(rec.Body.String(), "connection refused") {
				t.Errorf("response leaks internal error: %q", rec.Body)
			}
		})
	}
}

With -v, the end of the output lists each case (parallel subtests finish in any order):

text
--- PASS: TestGetArticleStatuses (0.00s)
    --- PASS: TestGetArticleStatuses/found (0.00s)
    --- PASS: TestGetArticleStatuses/wrong_method (0.00s)
    --- PASS: TestGetArticleStatuses/bad_id (0.00s)
    --- PASS: TestGetArticleStatuses/store_error (0.00s)
    --- PASS: TestGetArticleStatuses/missing (0.00s)
PASS

Sharing the found fake between parallel subtests is safe because they only read its map. A fake that records writes would need a mutex or a fresh instance per case.

Key takeaways

  • Tests are TestXxx(t *testing.T) functions in _test.go files; report failures with t.Errorf (continue) or t.Fatalf (stop).
  • Write table-driven tests with named t.Run subtests, and run go test -race ./... in CI.
  • Use t.Helper, t.Cleanup, t.TempDir and t.Context to keep setup and teardown next to the code that needs it.
  • Test handlers with httptest.NewRequest and NewRecorder through your real router; use httptest.NewServer for outbound HTTP clients.
  • Depend on small consumer-defined interfaces so hand-written fakes and stubs can replace databases and services.
  • Test SQL against a real PostgreSQL, skipping when unconfigured and rolling back a transaction per test.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.What is the difference between t.Errorf and t.Fatalf?

  2. 2.Why should a handler test send its request through srv.Routes().ServeHTTP rather than calling srv.getArticle directly?

  3. 3.Your fake store has an err field that, when set, makes every method return that error. What kind of test double is it acting as when you set err?

  4. 4.An integration test helper calls t.Skip when TEST_DATABASE_URL is empty and rolls back a transaction in t.Cleanup. What do these two choices achieve?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.