Unit Tests and Go

Part of moving quickly with software is having a safety net. At randrr, that safety net consists of several layers of test coverage. Following Mike Cohn's Test Pyramid, the foundation of our strategy is automated unit testing.

I know many developers are groaning at this. The common refrains are that "it takes too long" or that "it's unnecessary." Hopefully I can convince you that it's extremely quick and easy (at least using Go), and that it's absolutely necessary.

TDD Not Required

Just a quick note about Test-Driven Development (TDD). In my experience, it typically doesn't matter if you write code first or tests first, as long as the result is the same. My personal process is to rough out some code, write some tests, then refine the code and add more tests. And remember, unit tests are for testing logic, not integrations.

Go Tests

Unlike most languages, Go has unit testing built-in. Running unit tests in code is as simple as:

$ go test

 To follow along with this article, clone the repo at github. For the purposes of this article, I will assume your projects are run from the following path:

$GOPATH/src/github.com/randrr/go-test

The code in this repo is for a web service that returns sunset data for a given location. Running go test from the the root directory of your project produces the following output:

$ go test
?       github.com/randrr/go-test       [no test files]

Taking a closer look at the project you will notice the following types:

type sunsetResult struct {
    Sunset    string    `json:"sunset"`
    Timestamp time.Time `json:"timestamp"`
}
type sunsetFinder interface {
    Query(location string) (sunsetResult, error)
}

All sunset queries will be handled by a type that implements the sunsetFinder interface. Anytime you have code that will talk to a system outside of the one you're in, consider an interface. It allows you to abstract away all the implementation and provide test mocks. If done properly, it can protect the rest of your code from changes in the underlying implementation.

Go refresher: types are exported from a Go package by capitalizing the first letter. Since this application is self-contained, we don't need to export any types, thus lowercase type names.

In handler.go you will see one function as follows:

func getHandler(sf sunsetFinder) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
        if req.Method != http.MethodGet {
            w.WriteHeader(http.StatusMethodNotAllowed)
            return
        }
        location := req.URL.Query().Get("location")
        if location == "" {
            w.WriteHeader(http.StatusBadRequest)
            w.Write([]byte("location must be set"))
            return
        }
        result, err := sf.Query(location)
        if err == errNotFound {
            w.WriteHeader(http.StatusNotFound)
            w.Write([]byte(fmt.Sprintf("Location '%s' not found", location)))
        } else if err != nil {
            w.WriteHeader(http.StatusBadGateway)
        }
        // for example purposes only, just assume
        // this won't fail
        b, _ := json.Marshal(result)
        w.Write(b)
    })
}

This function takes in a sunsetFinder dependency and returns an http.Handler to be used for our search route. This function has the logic we care about testing such as input validation and error handling. We'll focus our unit tests on this method.

To create a test file in Go, simply add a file with a _test.go suffix such as handler_test.go

The first view things you'll see in this file are the MockSunsetFinder and our first test.

type MockSunsetFinder struct {
    queryFunc func(location string) (sunsetResult, error)
}
func (msf *MockSunsetFinder) Query(location string) (sunsetResult, error) {
    return msf.queryFunc(location)
}

Having a mock object that has a func we can assign to provide the implementation of our interface is a good technique to make it easy to test specific functionality.

Our first test doesn't use our mock but it illustrates some of the nice built-in features of testing in Go.

func Test_LocationNotSet(t *testing.T) {
    req, _ := http.NewRequest("GET", "/?", nil)
    w := httptest.NewRecorder()
    h := getHandler(nil)
    h.ServeHTTP(w, req)
    if w.Code != http.StatusBadRequest {
        t.Errorf("code should be %v", http.StatusBadRequest)
    }
}

Go has a package, httptest that provides lots of helpers for testing http handlers without running a web server. Here we use an http.ResponseRecorder that implements http.ResponseWriter

One criticism of Go's built-in testing is that it deviates from all the xUnit style test frameworks by not having assertions. Essentially testing in Go is no different than regular Go code. You check for error conditions and "raise" an error instead of asserting a condition is met or not.

Let's checkout another test.

func Test_LocationNotFound(t *testing.T) {
    req, _ := http.NewRequest("GET", "/?location=Notown,+AA", nil)
    w := httptest.NewRecorder()
    msf := &MockSunsetFinder{}
    msf.queryFunc = func(location string) (sunsetResult, error) {
        return sunsetResult{Sunset:"foobar"}, errNotFound
    }
    h := getHandler(msf)
    h.ServeHTTP(w, req)
    if w.Code != http.StatusNotFound {
        t.Errorf("code should be %v", http.StatusNotFound)
    }
}

Here we use our mock by simply creating an instance and providing a basic function that will always return a bogus result and an errNotFound error.

The final test checks for success.

func Test_LocationLookupWorks(t *testing.T) {
    req, _ := http.NewRequest("GET", "/?location=Jacksonville,+FL", nil)
    w := httptest.NewRecorder()
    tm := time.Now()
    msf := &MockSunsetFinder{}
    msf.queryFunc = func(location string) (sunsetResult, error) {
        return sunsetResult{Sunset: "5:30 PM", Timestamp: tm}, nil
    }
    h := getHandler(msf)
    h.ServeHTTP(w, req)
    if w.Code != http.StatusOK {
        t.Errorf("code should be %v", http.StatusOK)
    }
    body, _ := ioutil.ReadAll(w.Body)
    var result sunsetResult
    json.Unmarshal(body, &result)
    if result.Sunset != "5:30 PM" {
        t.Error("Didn't write result properly")
    }
}

This test guarantees that our success response always conforms to the sunsetResult type.

Code coverage

Go also has built in support for code coverage. Run the following:

$ go test -cover
PASS
coverage: 34.9% of statements
ok      github.com/randrr/go-test       0.008s

Passing the cover flag tells the Go tool to instrument the code and measure coverage. 34.9% is not great. What do we not have under test? Let's find out.

$ go test -coverprofile=cover.out; go tool cover -html=cover.out
PASS
coverage: 34.9% of statements
ok      github.com/randrr/go-test       0.008s

The first command generates a report of missing coverage and the second command gives a nice HTML report like below.

Here you can see we missed a test for the HTTP method. You can also toggle between files with  the drop down at the top. Let's add that missing test.

func Test_OnlyGetSupported(t *testing.T) {
    for _, method := range [...]string{http.MethodDelete,
        http.MethodPatch,
        http.MethodPost,
        http.MethodPut} {
        req, _ := http.NewRequest(method, "/", nil)
        w := httptest.NewRecorder()
        h := getHandler(nil)
        h.ServeHTTP(w, req)
        if w.Code != http.StatusMethodNotAllowed {
            t.Errorf("code should be %v", http.StatusMethodNotAllowed)
        }
    }
}

Bam! handler.go is now out 100% coverage but our overall coverage is now 39.5%. We didn't write unit tests for main.go or yahoo.go and we shouldn't necessarily. main.go simply wires up the handler to the Yahoo implementation and begins listening on port 8080. This will be tested every time the app is ran by either working or not. Likewise, yahoo.go is our Yahoo API specific implementation. At the very least, it requires an internet connection and Yahoo to be up. This is a job for integration testing. A topic we'll cover in a future post.

So, let's simply exclude these files from our test metrics. We'll add the following build tag: //+build !test (a new-line is required afterwards). Now run the following:

$ go test -cover -tags test
PASS
coverage: 100.0% of statements
ok      github.com/randrr/go-test       0.009s

And there we have it, 100% unit test coverage of our "business logic." I will leave it as an excercise for the reader to refactor the YahooSunsetFinder to use a mock to simulate HTTP requests and verify logic and error handling in that type.

Now, let's run this thing.

$ go build && ./go-test
2021/12/20 22:32:55 Listening on :8080

Hit it with curl.

$ curl http://localhost:8080/?location=Jacksonville,+FL
{"sunset":"5:30 pm","timestamp":"2016-12-21T03:34:40Z"}

No Excuses

As you can see from this simple example, unit testing in Go is extremely easy. Go's implicit interface implementation makes it easy to make mocks and causes designs to tend toward small, focused components. Testing is a vital part of software engineering. "It works on my machine" is not a valid response. If you take your code seriously, you'll be able to prove that it works all the time.