10 - Testing
📋 Jump to TakeawaysGo has testing built into the toolchain. No Jest, no pytest, no JUnit. Just go test. The testing package is minimal on purpose. You write functions, not classes. You call t.Error, not assert.Equal. It's less magic, more control.
t.Error vs t.Fatal
Two ways to fail a test:
t.Errorf(...)marks the test as failed but keeps running. Use it when you want to check multiple things in one test.t.Fatalf(...)marks the test as failed and stops immediately. Use it when continuing makes no sense — like when setup fails or a value you need for the rest of the test is nil.
Rule of thumb: use t.Fatalf for preconditions, t.Errorf for assertions.
Other useful *testing.T methods
you'll see the following methods in this lesson:
t.Skip(reason)— skip the test entirely. Use it when a precondition isn't met (e.g., no test database available). The test shows as "skipped," not "failed."t.Cleanup(func())— register a function that runs when the test finishes, even if it fails. Likedefer, but tied to the test lifetime, not the function scope. Useful for closing connections in helper functions.t.Helper()— marks a function as a test helper. When the test fails inside the helper, Go reports the caller's line, not the helper's line.
The Basics
Test files end in _test.go. Test functions start with Test and take *testing.T:
// store_test.go
package main
import "testing"
func TestValidateURL(t *testing.T) {
err := validateURL("https://go.dev")
if err != nil {
t.Errorf("expected no error, got %v", err)
}
}Run it:
go test -v ./...-v for verbose output. ./... runs tests in all packages.
Table-Driven Tests
The Go pattern for testing multiple cases. Instead of writing five test functions, write one with a table:
func TestValidateURL(t *testing.T) {
tests := []struct {
name string
url string
wantErr bool
}{
{"valid https", "https://go.dev", false},
{"valid http", "http://example.com", false},
{"empty", "", true},
{"no scheme", "go.dev", true},
{"ftp scheme", "ftp://files.example.com", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateURL(tt.url)
if (err != nil) != tt.wantErr {
t.Errorf("validateURL(%q) error = %v, wantErr %v", tt.url, err, tt.wantErr)
}
})
}
}t.Run creates subtests. Each case gets its own name in the output. You can run a single case with:
go test -v -run TestValidateURL/emptyTesting HTTP Handlers with httptest
net/http/httptest lets you test handlers without starting a real server:
func TestListBookmarks(t *testing.T) {
store := &BookmarkStore{db: setupTestDB(t)}
mux := http.NewServeMux()
registerBookmarkRoutes(mux, store)
req := httptest.NewRequest("GET", "/bookmarks", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
var bookmarks []Bookmark
if err := json.NewDecoder(rec.Body).Decode(&bookmarks); err != nil {
t.Fatalf("decode response: %v", err)
}
}httptest.NewRequest creates a fake request. httptest.NewRecorder captures the response. No network, no ports, no flakiness.
Testing POST Handlers
func TestCreateBookmark(t *testing.T) {
store := &BookmarkStore{db: setupTestDB(t)}
mux := http.NewServeMux()
registerBookmarkRoutes(mux, store)
body := strings.NewReader(`{"url":"https://go.dev","title":"Go"}`)
req := httptest.NewRequest("POST", "/bookmarks", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Errorf("status = %d, want %d", rec.Code, http.StatusCreated)
}
var bookmark Bookmark
json.NewDecoder(rec.Body).Decode(&bookmark)
if bookmark.URL != "https://go.dev" {
t.Errorf("url = %q, want %q", bookmark.URL, "https://go.dev")
}
}Test Helpers
Reduce boilerplate with helpers. Call t.Helper() at the start — it's a stdlib method on *testing.T that tells Go "this is a helper function, not a test." Without it, when a test fails inside the helper, Go reports the helper's file and line. With it, Go skips the helper and reports the line in the actual test that called it. Much easier to find the failing test.
func assertStatus(t *testing.T, got, want int) {
t.Helper()
if got != want {
t.Errorf("status = %d, want %d", got, want)
}
}
func doRequest(t *testing.T, mux http.Handler, method, path string, body io.Reader) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, body)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}Now tests read cleaner:
func TestGetBookmarkNotFound(t *testing.T) {
store := &BookmarkStore{db: setupTestDB(t)}
mux := http.NewServeMux()
registerBookmarkRoutes(mux, store)
rec := doRequest(t, mux, "GET", "/bookmarks/9999", nil)
assertStatus(t, rec.Code, http.StatusNotFound)
}Testing the BookmarkStore
Test the store against a real database. Use t.Cleanup() for automatic teardown — Go runs cleanup functions when the test finishes, even if it fails:
func setupTestDB(t testing.TB) *sql.DB {testing.TB is an interface that both *testing.T and *testing.B satisfy. By using TB instead of *testing.T, this helper works in both tests and benchmarks (we'll use it in the profiling lesson).
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() }) // Close the connection when the test finishes
// Clean slate — delete old data before each test, not after
_, err = db.Exec("DELETE FROM bookmarks")
if err != nil {
t.Fatalf("clean table: %v", err)
}
return db
}t.Skip skips the test if no database is available. CI runs with a database, local dev can skip. t.Cleanup runs after the test finishes, even if it fails.
func TestBookmarkStore_Create(t *testing.T) {
db := setupTestDB(t)
store := NewBookmarkStore(db)
b, err := store.Create(context.Background(), "https://go.dev", "Go")
if err != nil {
t.Fatalf("create: %v", err)
}
if b.URL != "https://go.dev" {
t.Errorf("url = %q, want %q", b.URL, "https://go.dev")
}
if b.ID == 0 {
t.Error("expected non-zero ID")
}
}
func TestBookmarkStore_GetByID_NotFound(t *testing.T) {
db := setupTestDB(t)
store := NewBookmarkStore(db)
_, err := store.GetByID(context.Background(), 9999)
if !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}Testing Middleware
Middleware is just a function that wraps a handler. Test it by wrapping a dummy handler:
func TestRecoveryMiddleware(t *testing.T) {
panicking := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
panic("something broke")
})
handler := Recovery(panicking)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
handler.ServeHTTP(rec, req)
assertStatus(t, rec.Code, http.StatusInternalServerError)
}
func TestRequestIDMiddleware(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := GetRequestID(r.Context())
if id == "" {
t.Error("expected request ID in context")
}
w.WriteHeader(http.StatusOK)
})
handler := RequestID(inner)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
handler.ServeHTTP(rec, req)
if rec.Header().Get("X-Request-ID") == "" {
t.Error("expected X-Request-ID header in response")
}
}Running Tests
# All tests, verbose
go test -v ./...
# Specific test function
go test -v -run TestCreateBookmark ./api
# Specific subtest
go test -v -run TestValidateURL/empty ./api
# With coverage
go test -cover ./...
# Coverage report as HTML
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.outThe -run flag takes a regex. TestBookmarkStore matches TestBookmarkStore_Create and TestBookmarkStore_GetByID_NotFound.
Applying to Our Project
Create handler_test.go and store_test.go in the api/ directory. Test the happy path and the error cases:
api/
├── handler.go
├── handler_test.go ← handler tests with httptest
├── store.go
├── store_test.go ← store tests with real DB
├── middleware.go
├── middleware_test.go ← middleware tests
└── helpers_test.go ← shared test helpersRun go test -v ./api during development. Run go test -cover ./... before committing. Aim for coverage on the paths that matter: error handling, edge cases, business logic. Don't chase 100% coverage for the sake of a number.
Why Not testify?
You'll see many Go projects using testify — it gives you assert.Equal, require.NoError, and other helpers that make tests shorter. It's popular (40,000+ imports on pkg.go.dev).
So why not use it?
- It hides what's being tested.
assert.Equal(t, got, want)is shorter than anif+t.Errorf, but when a test fails, the stdlib version shows you exactly what was checked and why. With testify, you read the assertion; with stdlib, you read the intent. - It's a dependency. testify pulls in several sub-packages. For a test utility, that's a lot of code you don't control. The stdlib has zero dependencies.
- Go's testing package is enough.
t.Errorf,t.Fatalf, table-driven tests, and a few helper functions like ourassertStatuscover everything you need. The Go team intentionally kepttestingminimal.
If you join a team that uses testify, that's fine — learn it. But for your own projects, start with the stdlib. You can always add testify later if you feel the need. You can't easily remove it once it's everywhere.
Key Takeaways
- Test files end in
_test.go. Test functions start withTestand take*testing.T - Table-driven tests with
t.Runkeep multiple cases organized and individually runnable httptest.NewRequestandhttptest.NewRecordertest handlers without a real server- Mark helpers with
t.Helper()so errors point to the right line t.Skipfor tests that need external resources (databases, APIs)t.Cleanupfor automatic teardown, runs even on failure- Test middleware by wrapping dummy handlers
go test -coverfor coverage,-runfor filtering,-vfor verbose output