Skip to Content
Course content
Click on the "Edit" button in the top corner of the screen to edit your slide content.

We've spent a lot of time on table-driven tests. They're the gold standard in Go for a reason: they're clean, easy to read, and they cover the "happy paths" and the edge cases you've already anticipated. But here is the cold truth: table-driven tests only prove that your code works for the inputs you were smart enough to think of. As someone who has spent a few too many weekends debugging production panics caused by a single unexpected null byte or a weird UTF-8 sequence, I can tell you that the "unknown unknowns" are where the real bugs hide.

The Trap of the Representative Sample

Imagine you've written a small utility function that parses a custom configuration string in the format key=value. You're feeling confident because you wrote a great test suite. It looks something like this:

func TestParseConfig(t *testing.T) {
    tests := []struct {
        input    string
        expected string
    }{
        {"port=8080", "8080"},
        {"timeout=30s", "30s"},
        {"", ""},
        {"empty_val=", ""},
    }

    for _, tc := range tests {
        got := ParseConfig(tc.input)
        if got != tc.expected {
            t.Errorf("ParseConfig(%q) = %q; want %q", tc.input, got, tc.expected)
        }
    }
}

This looks solid. You covered the empty string and the missing value. But this approach is fundamentally naive because it relies on your imagination. What happens if the input is "=value"? Or what if the input contains a null character, or a multi-byte emoji that throws off your index calculations? If you're using strings.Index and slicing the string based on that result without a sanity check, you're one weird input away from an index out of range panic that brings down your entire service.

Letting the Engine Find the Holes

This is where fuzzing comes in. Instead of you trying to guess every possible malicious or malformed string, you tell Go: "Here is the type of data I expect, and here is a property that should always hold true. Now, go try to break it."

In Go, we do this by switching from func TestXxx(t *testing.T) to func FuzzXxx(f *testing.F). I like to think of the f.Add calls as the "seed corpus"—you're giving the fuzzer a head start by providing the examples you already know about. Then, you provide a fuzz target function that the Go toolchain will call millions of times with mutated variations of those seeds.

func FuzzParseConfig(f *testing.F) {
    // Seed corpus: give it some starting points
    f.Add("port=8080")
    f.Add("timeout=30s")
    f.Add("")

    f.Fuzz(func(t *testing.T, input string) {
        // We aren't necessarily checking for a specific output here,
        // but we are checking that the function NEVER panics.
        ParseConfig(input)
    })
}

When you run go test -fuzz=Fuzz, Go doesn't just throw random garbage at your function. It uses coverage-guided fuzzing. This means the engine tracks which code paths are being hit; if a specific mutation reaches a new branch of your if/else logic, the fuzzer recognizes that as "interesting" and starts mutating that specific input further to dig deeper into that branch. It's essentially an automated bug-hunter that doesn't get tired.

CPU Cycles vs. Sleep Quality

Now, I'm not suggesting you fuzz every single function in your codebase. Fuzzing is computationally expensive. It will pin your CPU to 100% and can run for hours, days, or indefinitely. If you're just adding two integers, a standard unit test is plenty.

However, if your code touches any of the following, you should be fuzzing:

  • Parsing logic (JSON, XML, custom protocols).
  • String manipulation that involves slicing or indexing.
  • Decryption or decompression routines.
  • Any input that comes directly from a user or an external API.

The trade-off is simple: you spend a few hours of CPU time now so you don't spend a few hours of your own life at 3 AM fixing a production crash. When the fuzzer finds a failure, it writes the exact input that caused the crash to a file in testdata/fuzz. You can then take that specific string, add it to your regular table-driven tests, fix the bug, and ensure it never regresses.




📋 Practical Task

Fixing the Fragile Log Parser

You have been handed a legacy function called ParseLogLevel that extracts the log level from a string (e.g., "[INFO] System started" should return "INFO"). The current implementation is brittle and crashes on certain inputs.

Your task:

  1. Implement a Fuzz test FuzzParseLogLevel(f *testing.F) in a file named parser_test.go.
  2. Provide a seed corpus with at least three examples: a valid log line, an empty string, and a string with no brackets.
  3. Run the fuzzer using go test -fuzz=Fuzz until it finds a crash (it will happen quickly).
  4. Analyze the crashing input in testdata, fix the ParseLogLevel function to handle that case without panicking, and verify the fix by running the fuzzer again.

Starter Code:

package main

import "strings"

// ParseLogLevel extracts the content between the first set of square brackets.
// TODO: This is currently fragile!
func ParseLogLevel(input string) string {
    start := strings.Index(input, "[")
    end := strings.Index(input, "]")
    return input[start+1 : end]
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.