>_
GolangStepByStep
Software Engineer

Testing Advanced

Mocks/fakes, httptest, golden tests, fuzz testing, benchmarks

# The Crash Test Facility

Imagine you design cars for a living. To guarantee a car is safe, you don't just ask the engineer "Does it look okay?" You put it in a Crash Test Facility.

At first, you might just drive it into a wall (Basic Unit Tests). But what if you want to test the airbag? You shouldn't use a real human—you use a Crash Test Dummy (a Mock). What if you want to know how the car handles 10,000 random steering wheel jerks in the rain? You use a factory robot (Fuzz Testing). What if you want to measure exactly how fast it hits 60mph down to the millisecond? You install speed sensors (Benchmarking).

Go's testing package is world-class because it provides the Dummies, the Robots, and the Speed Sensors built right into the language out of the box. No heavy third-party installations required.

# Level 1: Mocks and Fakes via Interfaces (Beginner)

When testing a function that queries a Database, we have a major problem: running a test should not require setting up PostgreSQL locally! If the database is down, the test fails, which makes the test flaky and useless.

We fix this by using Interfaces to create Fakes (Mocks).

// THE INTERFACE: Our function doesn't care if this is a DB or a Fake!
type UserRepository interface {
    GetEmail(id int) string
}

// THE BUSINESS LOGIC: We inject the interface
func SendWelcomeEmail(repo UserRepository, userID int) string {
    email := repo.GetEmail(userID)
    return "Email sent to " + email
}

// ====== IN THE TEST FILE (_test.go) ======

// 1. Create a Fake Struct
type FakeDB struct{}

// 2. Implement the interface with hardcoded, instant data
func (f FakeDB) GetEmail(id int) string { return "test@example.com" }

func TestSendWelcomeEmail(t *testing.T) {
    fake := FakeDB{} // The Fake!
    
    // We pass the fake into our real function!
    result := SendWelcomeEmail(fake, 1)
    
    if result != "Email sent to test@example.com" {
        t.Errorf("Unexpected result: %s", result)
    }
}

Why this rules:

The test runs in an instantaneous, isolated bubble. You aren't wasting 200 milliseconds logging into MySQL. You can test your core logic 10,000 times a second.

# Level 2: Testing HTTP Handlers (Intermediate)

Testing an API router without actually booting up a local server on port 8080 seems hard. Go's net/http/httptest package provides a "Fake Browser" and a "Fake Server" to make this magically easy.

func PingHandler(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("pong"))
}

// ====== IN THE TEST FILE ======

func TestPingHandler(t *testing.T) {
    // 1. Create a fake incoming HTTP Request
    req := httptest.NewRequest(http.MethodGet, "/ping", nil)
    
    // 2. Create a ResponseRecorder (Acts like the ResponseWriter)
    recorder := httptest.NewRecorder()
    
    // 3. Just call the function directly! (No network needed)
    PingHandler(recorder, req)
    
    // 4. Assert against the recorder's captured output
    res := recorder.Result()
    
    if res.StatusCode != http.StatusOK {
        t.Errorf("Expected 200 OK, got %d", res.StatusCode)
    }
    
    body, _ := io.ReadAll(res.Body)
    if string(body) != "pong" {
        t.Errorf("Expected pong, got %s", body)
    }
}

httptest.NewRecorder() acts like a high-tech sponge. It soaks up every header, status code, and byte written by the handler so your test framework can squeeze it out and verify it.

# Level 3: Benchmarking (Advanced)

Sometimes code works, but you need to know if it's fast. Go's benchmark tool runs your code in a loop until it achieves statistical significance to tell you precisely how many nanoseconds your function takes.

// A slow way to concatenate strings
func ConcatSlow(words []string) string {
    var result string
    for _, w := range words {
        result += w // Creates a new string in memory every loop
    }
    return result
}

// ====== IN THE TEST FILE ======

// Benchmark functions MUST start with "Benchmark" and take *testing.B
func BenchmarkConcatSlow(b *testing.B) {
    words := []string{"Hello", "World", "Go", "Is", "Fast"}
    
    // b.ResetTimer() prevents setup time from ruining stats
    b.ResetTimer() 
    
    // The benchmark framework decides what "b.N" should be!
    for i := 0; i < b.N; i++ {
        ConcatSlow(words)
    }
}

Running go test -bench=Concat outputs:

BenchmarkConcatSlow-8    12345678     95.2 ns/op     32 B/op

This tells us the function ran 12 million times, averaging 95.2 nanoseconds per operation, and allocated 32 bytes of memory each time. Benchmarking is strictly empirical—there's no guessing.

# Level 4: Fuzz Testing (Expert)

You write a test for an Age parser. You test 25, and you test -1. They pass. But what happens if the user inputs a Chinese character? Or a 5-gigabyte string? Does your server panic and crash?

Fuzzing automatically generates thousands of randomized, chaotic inputs to try and break your code.

func ParseAge(ageStr string) int {
    if ageStr == "broken" { // A specific hidden bug!
        panic("SYSTEM CRASH!") 
    }
    val, _ := strconv.Atoi(ageStr)
    return val
}

// ====== IN THE TEST FILE ======

func FuzzParseAge(f *testing.F) {
    f.Add("25") // Seed a starting value
    
    f.Fuzz(func(t *testing.T, randomString string) {
        // We just run our function with extreme chaos
        // If the function panics, the Fuzzer catches it and permanently
        // saves the failing bad string into a "testdata" folder!
        ParseAge(randomString) 
    })
}

When run via go test -fuzz=Fuzz, Go throws random garbage at randomString for hours. If the fuzzer accidentally discovers the string "broken", it will crash, log the exact string that broke the system, and save it so it never happens again. Fuzzing finds edge cases human brains literally cannot imagine.

practice & review