Table-driven tests are arguably the most ubiquitous unit-test pattern in use throughout the Go community. Combined with Go’s support of first-class functions, we can apply the open-closed principle to ensure our tests are concise, easy to read, and trivial to extend.

The Effectiveness of Table Driven Tests

The pattern starts by framing a “table” that defines each test, grouping sets of input and expected outputs to names. A loop will iterate over each test in the table, creating a subset for each that will execute the tests.

func Test_LossyDistance(t *testing.T) {
    tests := map[string]struct{
        p0:      Point
        p1:      Point
        expected int
    }{
        "no distance": { newPoint(1, 1, 1), newPoint(1, 1, 1), 0 },
        "x-axis only": { newPoint(1, 1, 1), newPoint(100, 1, 1), 99 },
        //…
    }

    for name, test := range tests {
        t.Run(name, func(t *testing.T) {
            actual := LossyDistance(test.p0, test.p1)

            if actual != test.expected {
                t.Errorf("Distance from %v to %v should have been %d but was %d",
                    test.p0, test.p1, test.expected, actual)
            }
        })
    }
}

Because we’ve separated the concerns of the tests and the code that performs them, future maintainers can easily navigate and extend the seat of tests. For example, should a bug in LossyDistance() be identified, adding a missing test is as easy as adding an entry to the table.

//…
"81.4 should round down to 81": { newPoint(1, 1, 1), newPoint(48, 48, 48), 81 },

The Open-Closed Principle Through First-Class Functions

Go supports first-class functions, which means we can treat functions like any other variable, but still invoke them.

// hello is a variable containing a function
hello := func() {
    fmt.Println("Hello World!")
}

hello()

This support is handy for implementing the open-closed principle, which states that software entities should be open for extension but closed for modification. For example, if we were building a chatbot package for others to use, we would want them to be able to extend it without modifying our package directly.

We could make our chat bot’s initializer accept a slice of functions, which in turn could extend the behavior of the chatbot without requiring its code to be modified.

// NewChatBot can be extended, by injected compatible functions
func NewChatBot(extenders ...func(c ChatHandler) c ChatHandler) (ChatBot, error) {
    //…
}

// User defined function that encapsulates 'dad joke' support logic
dadJokes := func(c ChatHandler) c ChatHandler { /* … */ }

// Create a new chat bot, extending it with 'dad joke' functionality.
myChatBot, error := NewChatBot(dadJokes)

Go’s support of first-class functions isn’t limited to just our compiled applications. We can use these functions to create table-driven tests that can be extended without modification, have their system under test manipulated, and be optimized for high readability.

Extendable Unit Tests

sometimes the function you’re testing isn’t a simple

sometimes you have tests where the input can be so different from test to test, that you can’t easily have a value-based input variables create it without lots of branching logic. So, rather than having lots of input values in your table to create the tests’ real input, just create the real input directly

Like the open-closed principle, sometimes it’s better to extended a test w/o modifying it. For example, if we wrote some HTTP middleware that changed a status code,

func Test_SwapStatusCode(t *testing.T) {
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "Hello, client")
    }))

    defer ts.Close()
}

Manipulating the System Under Test

For testing validation logic I gravitate towards the ‘Mutator-Table Driven Test’ pattern; where the table is a collection of functions that mutate a target struct, making it invalid.

It is important that a valid target be created for each mutator, to ensure that the test struct only contains one validation that should cause the targeted test function to return an error.

func Test_OrderRequest_Validate(t *testing.T) {
    // Collection of mutators that invalidate the supplied struct
    mutators := map[string]func(s *OrderRequest){
        "empty Address":       func(s *OrderRequest) { s.Address = Address{} },
        "whitespace FullName": func(s *OrderRequest) { s.Address.FullName = "\t" },
        "no line items":       func(s *OrderRequest) { s.LineItems = nil },
        //…
    }

    happyPathChecked := false

    for name, mutator := range mutators {
        // A valid system under test (SUT) is created for every mutator
        sut := &OrderRequest{
            Address: Address{
                FullName: "H. J. Farnsworth",
                Address1: "57th Street",
                Address2: "The Angry Dome",
            },
            LineItems: []LineItem{
                LineItem{SKU: "POPPLER-BRE", Quantity: 92},
                LineItem{SKU: "L-UNIT", Quantity: 1},
            },
        }

        // On the first pass, validate the SUT to check Validate() recognizes it as error-free.
        if !happyPathChecked {
            if err := sut.Validate(); err != nil {
                t.Fatalf("Valid sut should have passed but got error: %q", err)
            }

            happyPathChecked = true
        }

        // Run through the mutators, checking that each one causes Validate() to return an error.
        t.Run(name, func(t *testing.T) {
            mutator(sut)

            if err := sut.Validate(); err == nil {
                t.Error("Validation should have failed with an error, but got `nil`.")
            }
        })
    }
}

See also: example code on GitHub.

Behavior-Table Driven Test Pattern

Final Thoughts

This was just two examples on how first-class functions can be used to create meaningful table-driven tests. As always, I recommend optomizing test for human-readability and understanding; never know when someone is going to be fighting it at 2am

See Also