Table-Driven Testing in Go

In the realm of software development, particularly in Go, testing plays a pivotal role. One of the most effective and popular testing paradigms in the Go community is "Table-Driven Testing". Let's delve into what it is, its benefits, and how to effectively employ it.

What is Table-Driven Testing?

Table-Driven Testing (TDT) is an approach where the test cases (input and expected output) are organized in a table format. Instead of writing separate test functions for each scenario, a single loop can iterate over the table to test multiple scenarios.

Benefits of Table-Driven Testing in Go

  1. Readability: Since the tests are organized in a tabular form, it's easier to comprehend multiple test scenarios at a glance.

  2. Maintainability: Adding new test cases merely requires adding a new row to the table, reducing the need for repetitive code.

  3. Scalability: TDT scales well with growing test cases. You don’t need to alter the test logic, just the table entries.

  4. Consistency: Using the same testing structure across various tests ensures uniformity and better code quality.

Implementing Table-Driven Testing in Go

Here are simple illustrations using Go's built-in testing framework:

1. Testing a String Manipulation Function

Suppose we have a function Reverse(s string) string that returns the reverse of a string:

func Reverse(s string) string {
    runes := []rune(s)
    for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
        runes[i], runes[j] = runes[j], runes[i]
    }
    return string(runes)
}

Table-Driven Test:

func TestReverse(t *testing.T) {
    tests := []struct {
        input  string
        want   string
    }{
        {"golang", "gnalog"},
        {"hello", "olleh"},
        {"", ""}, // edge case: empty string
    }

    for _, tt := range tests {
        t.Run(tt.input, func(t *testing.T) {
            got := Reverse(tt.input)
            if got != tt.want {
                t.Errorf("Reverse(%q) = %q; want %q", tt.input, got, tt.want)
            }
        })
    }
}

2. Testing a Mathematical Function

Suppose we have a function Factorial(n int) int:

func Factorial(n int) int {
    if n == 0 {
        return 1
    }
    return n * Factorial(n-1)
}

Table-Driven Test:

func TestFactorial(t *testing.T) {
    tests := []struct {
        input  int
        want   int
    }{
        {0, 1},
        {1, 1},
        {5, 120},
        {7, 5040},
    }

    for _, tt := range tests {
        t.Run(fmt.Sprintf("Factorial(%d)", tt.input), func(t *testing.T) {
            got := Factorial(tt.input)
            if got != tt.want {
                t.Errorf("Factorial(%d) = %d; want %d", tt.input, got, tt.want)
            }
        })
    }
}

3. Testing Error Scenarios

Suppose we have a function Divide(a, b float64) (float64, error):

func Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

Table-Driven Test:

func TestDivide(t *testing.T) {
    tests := []struct {
        a, b  float64
        want  float64
        err   error
    }{
        {10, 2, 5, nil},
        {9, 3, 3, nil},
        {10, 0, 0, errors.New("division by zero")},
    }

    for _, tt := range tests {
        t.Run(fmt.Sprintf("Divide(%f, %f)", tt.a, tt.b), func(t *testing.T) {
            got, err := Divide(tt.a, tt.b)
            if err != nil && err.Error() != tt.err.Error() || got != tt.want {
                t.Errorf("Divide(%f, %f) = %f, %v; want %f, %v", tt.a, tt.b, got, err, tt.want, tt.err)
            }
        })
    }
}

In each of these examples, notice the pattern of defining the table of tests, iterating over the table, and using subtests (t.Run) to test each scenario. This structure provides a consistent and scalable way to handle a variety of test cases in Go.

Tips for Effective Table-Driven Tests:

  1. Descriptive Names: Always provide descriptive names for your test scenarios. This helps when a test fails, as the name can give insights into what scenario or condition might have caused the failure.

  2. Keep It DRY (Don’t Repeat Yourself): Maximize the use of your test table. If you find yourself writing repetitive test logic, consider if it can be included in the table instead.

  3. Wide Coverage: Ensure that your test table includes both typical scenarios and edge cases. This provides a comprehensive test coverage for your function or method.

  4. Anonymous Structs: Using anonymous structs, as shown in the example, keeps the code concise. However, for more complex scenarios, you might want to define a named struct for clarity.

Conclusion

Table-Driven Testing in Go offers a systematic, scalable, and maintainable approach to writing tests. Whether you're a seasoned Go developer or just starting out, embracing this paradigm can significantly enhance the quality and reliability of your code. Happy coding!

Previous
Previous

Go Testing with Fake HTTP Requests and Responses

Next
Next

Swift's POP Revolution: Understanding Protocol-Oriented Programming