# Testing in Go

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

Backend with Go roadmap · Production · 16 min read · https://levelcode.org/roadmaps/go-backend/testing-go

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.

> [!TIP]
> Test behaviour through the public surface of a package (the function signature, the HTTP response), not internal details. Tests coupled to implementation break on every refactor and stop being trusted.

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

> [!WARNING]
> A fake can drift from reality: it happily accepts an article with a duplicate slug that the real database would reject with a unique violation. Fakes are for testing *your* logic. Test the store itself against a real database.

## 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 ./...`.

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

## Quiz

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

   A. t.Errorf only logs a message; t.Fatalf marks the test as failed
   B. t.Errorf marks the test failed and continues; t.Fatalf marks it failed and stops the current test
   C. t.Fatalf stops the whole go test run, including other packages
   D. There is no difference; Fatalf is an alias kept for compatibility

   **Answer: B.** Both mark the test as failed. Errorf lets the test continue so you see more failures; Fatalf calls runtime.Goexit for the current test (or subtest) only. Other tests keep running.

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

   A. Calling an unexported method from a test is not allowed
   B. httptest.NewRecorder only works with a ServeMux
   C. Direct calls are slower because they bypass the cache
   D. The router fills in path values like {id} and enforces method matching, as in production

   **Answer: D.** r.PathValue("id") is only populated when the request is routed through a ServeMux pattern (or set manually with SetPathValue). Going through the router also tests 405 handling and route registration.

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?

   A. A stub: it returns canned responses so you can exercise a code path
   B. A mock: it verifies the expected sequence of calls
   C. A spy: it records every call for later assertions
   D. An integration test

   **Answer: A.** Returning a canned value or error to drive the code under test down a particular path is a stub. Mocks verify interactions; spies record them.

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?

   A. They make the test run in parallel with unit tests
   B. They let the test reuse production data safely
   C. go test ./... still passes without a database, and each test starts from a clean state
   D. They make the race detector ignore database code

   **Answer: C.** Skipping keeps the suite usable on machines without PostgreSQL, and rolling back means nothing a test writes is visible to the next test, without truncating tables.
