DEV Community

Cover image for The Secret Life of Go: Testing
Aaron Rose
Aaron Rose

Posted on

The Secret Life of Go: Testing

Chapter 13: The Table of Truth


The Wednesday rain beat a steady rhythm against the archive windows, blurring the Manhattan skyline into smears of gray and slate. Inside, the air smelled of old paper and the fresh, sharp scent of lemon.

Ethan stood at the long oak table, surrounded by scraps of paper. He was typing furiously, running a command, frowning, deleting a line, and running it again.

"Lemon poppyseed muffin," he said, sliding a white bag across the desk without looking up. "And a London Fog. Earl Grey, vanilla syrup, steamed milk."

Eleanor accepted the tea. "You seem... agitated, Ethan."

"I'm fixing a bug in the username validator," Ethan muttered. "I fix one case, break another. I fix that one, break the first one. I've been running go run main.go for an hour, just changing the input variable manually to see what happens."

Eleanor set her tea down slowly. "You are testing by hand?"

"How else do I do it?"

"Ethan, you are a human being. You are creative, intuitive, and prone to boredom. You are terrible at repetitive tasks." She opened her laptop. "Computers are uncreative and boring, but they never get tired. We do not test by hand. We write code to test our code."

The First Test

"Go does not require you to install a heavy testing framework," Eleanor began. "It is built in. You simply create a file ending in _test.go next to your code."

She created a file named validator_test.go.

package main

import "testing"

func TestIsValidUsername(t *testing.T) {
    result := IsValidUsername("admin")
    expected := true

    if result != expected {
        t.Errorf("IsValidUsername('admin') = %v; want %v", result, expected)
    }
}

Enter fullscreen mode Exit fullscreen mode

"The function must start with Test and take a pointer to testing.T. This t is your control panel. You use it to report failures."

She ran go test in the terminal.
PASS

"Okay," Ethan said. "But I have twenty different cases. Empty strings, symbols, too long, too short..."

"So you write twenty assertions?" Eleanor asked. "Copy and paste the same if block twenty times?"

"I guess?"

"No." Eleanor shook her head. "That is how you drown in boilerplate. In Go, we use a specific idiom. We treat test cases as data, not code. We build a Table of Truth."

The Table-Driven Test

She wiped the screen and began typing a structure that looked less like a script and more like a ledger.

package main

import "testing"

func TestIsValidUsername(t *testing.T) {
    // 1. Define the table
    // An anonymous struct slice containing all inputs and expected outputs
    tests := []struct {
        name     string // A description of the test case
        input    string // The input to the function
        expected bool   // What we expect to get back
    }{
        {"Valid user", "ethan_rose", true},
        {"Too short", "ab", false},
        {"Too long", "this_username_is_way_too_long_for_our_system", false},
        {"Empty string", "", false},
        {"Contains symbols", "ethan!rose", false},
        {"Starts with number", "1player", false},
    }

    // 2. Loop over the table
    for _, tt := range tests { // tt = "test table" entry
        // 3. Run the subtest
        t.Run(tt.name, func(t *testing.T) {
            got := IsValidUsername(tt.input)

            if got != tt.expected {
                // We use Errorf, NOT Fatalf.
                // Errorf marks failure but continues to the next case.
                t.Errorf("IsValidUsername(%q) = %v; want %v", tt.input, got, tt.expected)
            }
        })
    }
}

Enter fullscreen mode Exit fullscreen mode

"Look at this structure," Eleanor said, tracing the slice with her finger. "The logic—the if check, the execution—is written exactly once. The complexity of the test is separated from the complexity of the data."

Ethan stared. "It's... a spreadsheet."

"Precisely. It is a table. If you find a new bug—say, usernames can't end with an underscore—you don't write a new function. You just add one line to the struct slice."

She typed:
{"Ends with underscore", "ethan_", false},

"And you are done. The harness handles the rest. Note that I used t.Errorf, not t.Fatalf. If I used Fatal, the first failure would stop the entire test. With Error, we see all the failures at once."

The Power of t.Run

"Notice the t.Run line," Eleanor pointed out. "This creates a Subtest. If the 'Empty string' case fails, Go will tell you exactly which one failed by name."

She intentionally broke the code to demonstrate.

--- FAIL: TestIsValidUsername (0.00s)
    --- FAIL: TestIsValidUsername/Empty_string (0.00s)
        validator_test.go:26: IsValidUsername("") = true; want false
FAIL

Enter fullscreen mode Exit fullscreen mode

"It gives you the context immediately. You fix that specific case, run the tests again, and see the green PASS. It is a feedback loop. Write a failing case in the table. Fix the code. Watch it pass. Repeat."

Ethan rubbed his eyes. "This would have saved me three hours this morning."

"It will save you three years over your career," Eleanor said, taking a bite of the lemon poppyseed muffin. "The table-driven pattern forces you to think about edge cases. When you see the table, your brain naturally asks: 'What is missing? Did I check negative numbers? Did I check nil?'"

Testing the Unhappy Path

"Does this work for errors too?" Ethan asked. "Like the error handling we talked about last time?"

"It shines for errors," Eleanor smiled.

func TestParseConfig(t *testing.T) {
    tests := []struct {
        name        string
        filename    string
        wantErr     bool  // Simple boolean check: did we get an error?
    }{
        {"Valid file", "config.json", false},
        {"File not found", "missing.json", true},
        {"Bad permissions", "root_only.json", true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            _, err := ParseConfig(tt.filename)

            // If we expected an error (true) and got none (nil)... failure.
            if (err != nil) != tt.wantErr {
                t.Errorf("ParseConfig() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

Enter fullscreen mode Exit fullscreen mode

"Here, wantErr is a simple boolean. We don't always need to check the exact error message text—often, just knowing that it failed is enough for the logic check. If you need to check for a specific error type, you would use errors.Is inside the loop."

Ethan closed his eyes, visualizing his messy main.go. "So the test file is basically the specification for the program."

"Yes. It is the documentation that cannot lie. Comments can become outdated. Diagrams can be wrong. But if the test passes, the code works."

She finished her tea. "There is an old Russian proverb: Doveryay, no proveryay."

"Trust, but verify?"

"Exactly. Trust that you wrote good code. But verify it with a table."

Ethan opened a new file named user_test.go. He started typing tests := []struct.

"Eleanor?"

"Yes?"

"This muffin is pretty good."

"It is acceptable," she said, though the corner of her mouth quirked upward. "Now, add a test case for a username with emojis. I suspect your validator will fail."


Key Concepts from Chapter 13

The testing Package: Go's built-in framework. No external libraries required.

File Naming: Test files must end in _test.go (e.g., user_test.go). They are ignored by the compiler when building the regular application, but picked up by go test.

Test Functions: Must start with Test and take a single argument: t *testing.T.

Table-Driven Tests: The idiomatic Go way to test.

  1. Define a slice of anonymous structs containing input, expected, and name.
  2. Iterate over the slice using range.
  3. Execute the logic once inside the loop.

Error vs. Fatal:

  • t.Errorf: Records a failure but continues running the test function. Preferred for tables, so you can see multiple failures.
  • t.Fatalf: Records a failure and stops the test function immediately. Use only when the test cannot proceed (e.g., setup failed).

Subtests (t.Run): Allows you to label each iteration of the loop. If one case fails, go test reports the specific name of the failed case.

Running Tests:

  • go test runs all tests in the package.
  • go test -v gives verbose output (shows every subtest).
  • go test -run TestName runs only a specific test function.

Mental Model: Tests are not a chore; they are a Table of Truth. They separate the data (test cases) from the execution logic (the harness).


Next chapter: Interfaces in Practice. What happens when your validator needs different rules for admins versus regular users? Ethan learns that "accept interfaces, return structs" is the key to flexible design.


Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.

Top comments (1)

Collapse
 
hadil profile image
Hadil Ben Abdallah

This was a fantastic read. I love how you turned an idiomatic Go concept into a story that actually teaches without feeling like a tutorial. 👏🏻