Go
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Functions and Methods
-
Section 4: Concurrency
-
Section 5: Packages and Tooling
-
Section 6: More Standard Library
-
Section 7: Building Services
-
Section 8: Advanced Go
-
Section 9: Go in the Cloud-Native Ecosystem
-
Section 10: Data Structures and Algorithms in Go
-
Section 11: Testing and Deployment
-
Section 12: Practical Projects
-
Section 13: More Standard Library Practice
-
Section 14: More Practice Projects
-
Section 15: Design Patterns in Go
-
Section 16: Interview Practice
-
Section 17: Package fmt In Depth
-
Section 18: Package strings and strconv
-
Section 19: Package os and io
-
Section 20: Package time
-
Section 21: Package sort and container
-
Section 22: Package encoding
-
Section 23: Package net/http In Depth
-
Section 24: Package context
-
Section 25: Package regexp and bytes
-
Section 26: Package errors In Depth
-
Section 27: Package crypto and hash
-
Section 28: Package flag and log
-
Section 29: Package sync In Depth
-
Section 30: More Practice Exercises
-
Section 31: Go Modules and Workspaces In Depth
-
Section 32: Generics Deep Dive (Go 1.18+)
-
Section 33: Testing Package In Depth
-
Section 34: More Interview and Whiteboard Practice
-
Section 35: Package math and unicode
-
Section 36: Package path and filepath
-
Section 37: Package database/sql
-
Section 38: Package text/template and html/template
-
Section 39: Package archive and compress
-
Section 40: Lower-Level net Package
-
Section 41: Package runtime and reflect
-
Section 42: Package embed and io/fs
-
Section 43: Go Assembly and CGO Basics
-
Section 44: Building CLIs and TUIs
-
Section 45: Go Performance Tuning
-
Section 46: More Real-World Projects
-
Section 47: Go in Production
-
Section 48: Go Design Patterns
-
Section 49: Go Interfaces Deep Dive
-
Section 50: Final Practice and Review
76: Table-Driven Tests
If you've been writing tests in Go for a while, you've probably noticed a pattern: you write a test for a success case, then you copy-paste the whole function to test a failure case, and then you do it again for an edge case. It gets tedious and your test files become massive. That's where table-driven tests come in.
Why is this better than just writing separate test functions?
Honestly? It's all about the signal-to-noise ratio. When you write five different functions to test one piece of logic, you're repeating the setup and the assertion logic over and over. If you decide to change the function signature of the code you're testing, you now have to update five different places.
With a table-driven approach, you separate the test data from the test logic. You define a slice of inputs and expected outputs (the "table"), and then you write a single loop that runs the logic against every row in that table. If you need to test a new edge case, you just add one line to your slice. It's much cleaner.
How do I actually structure the "table"?
The idiomatic way to do this in Go is using a slice of anonymous structs. I prefer anonymous structs because you usually don't need that test-case type anywhere else in your package, so why clutter the namespace?
Let's look at a real example. Suppose we have a function that validates if a password meets certain security criteria:
func IsStrongPassword(p string) bool {
if len(p) < 8 {
return false
}
// Imagine more complex logic here (checking digits, symbols, etc.)
return true
}
Here is how I'd write the table-driven test for it:
func TestIsStrongPassword(t *testing.T) {
tests := []struct {
name string // A description of the test case
password string // The input
want bool // The expected result
}{
{"too short", "12345", false},
{"just right", "password123", true},
{"empty string", "", false},
{"long enough", "thisisalongpassword", true},
}
for _, tc := range tests {
// I'll explain t.Run in the next section
t.Run(tc.name, func(t *testing.T) {
got := IsStrongPassword(tc.password)
if got != tc.want {
t.Errorf("IsStrongPassword(%q) = %v; want %v", tc.password, got, tc.want)
}
})
}
}
Wait, why am I using t.Run() inside the loop?
You might be tempted to just put the if got != tc.want check directly inside the for loop. Don't do that. Use t.Run to create "subtests".
There are two big reasons for this. First, if one case fails, t.Run allows the other cases to keep running. If you don't use subtests and call t.Fatalf, the whole test function stops at the first failure, and you won't know if your other ten cases would have passed or failed.
Second, it makes debugging a breeze. Because we gave each test case a name, Go reports exactly which one failed. You'll see TestIsStrongPassword/too_short in your console instead of just a generic line number. You can even run a specific subtest from your terminal using the -run flag: go test -run=TestIsStrongPassword/too_short.
What if my test cases need different types of assertions?
Sometimes a simple want bool isn't enough. Maybe one case should return a specific error, and another should return a value. In those cases, you can add more fields to your struct or even add a function field to the struct itself for custom validation.
I've seen people add a check func(t *testing.T, got interface{}) field to their test structs. It's a bit more advanced, but it allows you to handle complex assertions (like checking if a map contains specific keys) while still keeping the table structure. For 90% of your work, though, just adding an expectedErr error field to your struct will do the trick.
📋 Practical Task
Implement a Table-Driven Test for an Email Domain Validator
You are building a system that only allows emails from a specific set of corporate domains. You have been provided with the following function:
func IsCorporateEmail(email string) bool {
allowedDomains := []string{"@company.com", "@subsidiary.io", "@partner.net"}
for _, domain := range allowedDomains {
if strings.HasSuffix(email, domain) {
return true
}
}
return false
}
Your Task: Write a comprehensive table-driven test for IsCorporateEmail. Your test must:
- Use a slice of anonymous structs to define your test cases.
- Include at least five test cases: two that should pass (valid domains), one with a valid domain but incorrect casing (e.g., @COMPANY.COM), one with a similar but invalid domain (e.g., @notcompany.com), and one with an empty string.
- Use
t.Run()to execute each test case as a subtest. - Provide clear, descriptive names for each test case so that failures are easy to identify.
There are no comments for now.