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

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.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.