Beyond String Checks: Rethinking Error Handling in Go Tests
Share this article
In Go, errors act as unambiguous stop signs—critical signals that demand attention before proceeding. Yet many developers undermine this clarity when writing tests by overcomplicating error validation. A common anti-pattern? Comparing raw error strings, which creates fragile tests tightly coupled to ever-changing error messages.
Consider this real-world scenario validating Kubernetes-like resources:
func TestValidateDosProtectedResource(t *testing.T) {
tests := []struct {
protected *v1beta1.DosProtectedResource
expectErr string // Problem: Hardcoded error string
}{{
protected: &v1beta1.DosProtectedResource{},
expectErr: "error validating DosProtectedResource: missing value for field: name",
}}
for _, test := range tests {
err := ValidateDosProtectedResource(test.protected)
if err != nil {
if test.expectErr == "" { /* unexpected error */ }
if test.expectErr != err.Error() { /* string mismatch */ }
} else {
if test.expectErr != "" { /* missing error */ }
}
}
}
This approach introduces four significant problems:
1. Brittleness: Changing punctuation or wording in errors breaks tests
2. Complexity: Nested conditionals obscure test intentions
3. Localization issues: Translated error messages fail tests
4. Misplaced precision: Tests validate formatting instead of behavior
The Refactoring Breakthrough
Instead of treating errors as strings to dissect, honor their role as binary signals. Split tests into two focused suites:
1. Invalid Input Suite
Only check that invalid structs return any error:
func TestRejectsInvalidInputs(t *testing.T) {
invalidCases := []*v1beta1.DosProtectedResource{
{}, // Empty struct
{Spec: v1beta1.DosProtectedResourceSpec{/* missing required fields */}},
}
for _, input := range invalidCases {
err := ValidateDosProtectedResource(input)
if err == nil {
t.Fatalf("expected error for input: %+v", input)
}
}
}
2. Valid Input Suite
Only verify valid structs return nil errors:
func TestAcceptsValidInputs(t *testing.T) {
validCases := []*v1beta1.DosProtectedResource{
{Spec: v1beta1.DosProtectedResourceSpec{Name: "app"}},
{Spec: v1beta1.DosProtectedResourceSpec{Name: "db", ApDosMonitor: &v1beta1.ApDosMonitor{URI: "monitor.local"}}},
}
for _, input := range validCases {
if err := ValidateDosProtectedResource(input); err != nil {
t.Error(err)
}
}
}
Why This Works
- Decouples logic from messaging: Tests survive error text refactors
- Eliminates branching complexity: Each test does one thing
- Aligns with Go philosophy: Errors are signals, not data containers
- Improves failure diagnostics: Clear pass/fail criteria per case
When to Go Deeper
If business logic requires specific error types (e.g., ErrInvalidCertificate vs ErrExpiredToken), use errors.Is() or errors.As()—but only when callers actually branch on error types. For validation functions where the mere presence of an error suffices? Simplicity wins.
The Last Mile
This pattern extends beyond validation. Apply it anywhere functions return errors primarily to signal failure rather than convey nuanced data. Your tests become documentation: "Given invalid X, we get an error. Given valid Y, we proceed." No more deciphering string-matching conditionals—just clean, resilient checks that respect Go's error ethos.
As the STOP sign reminds us: Some signals demand unambiguous obedience, not interpretation.