The Secret Life of Go: Testing
Source: Dev.to
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.gofor 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.”
“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.gonext 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)
}
}
“The function must start with Test and take a pointer to
testing.T. Thistis 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
ifblock 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.”
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)
}
})
}
}
“Look at this structure,” Eleanor said, tracing the slice with her finger. “The logic—the
ifcheck, 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, nott.Fatalf. If I usedFatal, the first failure would stop the entire test. WithError, we see all the failures at once.”
“Notice the
t.Runline,” 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
“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?’”
“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)
}
})
}
}
“Here,
wantErris a simple boolean. We don’t always need to …”
(The conversation continues.)
Conversation Excerpt
“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.Isinside 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 and started typing:
tests := []struct {
// fields for each test case
}{ ... }
“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.”
Go Testing Primer
The Testing Package
- Go’s built‑in testing 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. - Signature:
func TestXxx(t *testing.T)
Table‑Driven Tests (Idiomatic Go)
- Define a slice of anonymous structs containing the input, expected output, and a name.
- Iterate over the slice using
range. - Execute the logic once inside the loop.
func TestValidateUsername(t *testing.T) {
cases := []struct {
name string
input string
wantErr bool
}{
{"valid‑ascii", "john_doe", false},
{"invalid‑space", "john doe", true},
{"emoji‑username", "😀user", true},
}
for _, tc := range cases {
tc := tc // capture range variable
t.Run(tc.name, func(t *testing.T) {
err := ValidateUsername(tc.input)
if (err != nil) != tc.wantErr {
t.Errorf("ValidateUsername(%q) error = %v, wantErr %v", tc.input, err, tc.wantErr)
}
})
}
}
Error vs. Fatal
| Function | Behaviour |
|---|---|
t.Errorf | Records a failure but continues running the test function. Preferred for table‑driven tests 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 testreports the specific name of the failed case.
Running Tests
| Command | Description |
|---|---|
go test | Runs all tests in the package. |
go test -v | 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.
About the Author
Aaron Rose – software engineer and technology writer at tech‑reader.blog; author of Think Like a Genius.