10 - Testing

📋 Jump to Takeaways

Go 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. Like defer, 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/empty

Testing 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.out

The -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 helpers

Run 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 an if + 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 our assertStatus cover everything you need. The Go team intentionally kept testing minimal.

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 with Test and take *testing.T
  • Table-driven tests with t.Run keep multiple cases organized and individually runnable
  • httptest.NewRequest and httptest.NewRecorder test handlers without a real server
  • Mark helpers with t.Helper() so errors point to the right line
  • t.Skip for tests that need external resources (databases, APIs)
  • t.Cleanup for automatic teardown, runs even on failure
  • Test middleware by wrapping dummy handlers
  • go test -cover for coverage, -run for filtering, -v for verbose output

🚀 Ready to run?

Complete examples for this lesson. Copy and run locally.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

© 2026 ByteLearn.dev. Free courses for developers. · Privacy