De-Duplicating Unit Test Setup

Go (or if you are using a search engine, Golang) has great unit testing functionality built directly into the language. Create a file ending in _test.go and define a function with the signature func TestMyFunction(*testing.T). Executing go test will now run any tests in the current package. Very easy.

As an application grows however, the number of tests explodes and it maintaining organization becomes difficult. To solve this problem, while developing the new REST API at ExtraHop I began using GoConvey from the very gracious developers at SmartyStreets. GoConvey uses nested “convey” statements to build up a tree of behaviors and tests within a single test function.

func TestSpec(t *testing.T) {
    Convey("Given some integer with a starting value", t, func() {
        x := 1

        Convey("When the integer is incremented", func() {
            x++

            Convey("The value should be greater by one", func() {
                So(x, ShouldEqual, 2)
            })
        })

        Convey("When the integer is decremented", func() {
            x--

            Convey("The value should be smaller by one", func() {
                So(x, ShouldEqual, 0)
            })
        })
    })
}

After completing a leaf node (convey statement), the test execution returns to the root (outermost) statement and proceeds through the tree to the next leaf node. This behavior ensures that every test has a clean starting state, however it can silently greatly increase the number of operations for large trees.

At ExtraHop we have a couple hundred of these test functions, each one containing a fair amount of setup followed by 10 or 15 leaf nodes in the convey statements. Running the full set of tests was slowly creeping up to over a minute. GoConvey has a fantastic UI which automatically runs the unit tests after saving a file, but when the tests take up to a minute this really slows down development.

// TestUsersResource performs full coverage tests over the User HTTP resource.
func TestUsersResource(t *testing.T) {
    Convey("Given a web server", t, func() {
        server := performServerSetup() // Creates all HTTP Handlers

        Convey("When testing GET requests", func() {
            ...
        })

        Convey("When testing POST requests", func() {
            ...
        })

        ...
    })
}

The example above demonstrates how functions group each HTTP resource (Users, Widgets, etc.) into functions and each test builds an HTTP server in the first convey statement to use in subsequent tests. The number of HTTP servers created by this function is not immediately obvious however; Remember the outer-most convey statement runs once for every leaf-node.

Good news though, we can avoid recreating the HTTP server for every test because it has no mutable state. Let’s change the tests to fix that.

var server TestServerWrapper // TestServerWrapper adds util functions to the httptest server.

// TestMain is called just once before executing any tests.
func TestMain(m *testing.M) {
    server = performServerSetup() // Creates all HTTP Handlers

    // Execute the actual tests
    os.Exit(m.Run())
}

// TestUsersResource performs full coverage tests over the User HTTP resource.
func TestUsersResource(t *testing.T) {
    Convey("Given a web server", t, func() {
        // No need to create a new server here anymore

        Convey("When testing GET requests", func() {
            ...
        })

        Convey("When testing POST requests", func() {
            ...
        })

        ...
    })
}

With these changes the tests drop from nearly 60 seconds to just 6. You may not find the same dramatic results, depending on the number of handlers and routes being setup. For us however this was a welcome and surprisingly easy change with great performance improvements.