I used to spend an unreasonable amount of time thinking about how to begin writing a test.
I googled test patterns in Go.
Many people seem to rely on external dependencies for assertions. And in fact, I understand that generic (aha!) functions like isNil(v interface{}) bool
can initially bring speed to the development. But in the long run, I think that embracing the true strongly-typed nature of Go, instead of just searching for a way around it, is more rewarding. Writing more idiomatic code will be beneficial both for the quality of the code, and for the insights you can get by looking the Beast in the eye.
Then, I invoked the Spirits of the Core Library asking for a sign.
Aa s soon as I realised where I had to look at, I indeed saw a sign.
Deep down in the core library, there is a package that was specifically written for testing purposes: net/http/httptest
. This one had to have good tests.
What I found
Brad Fitzpatrick’s code, what else.
I stumbled upon recorder_test.go
, the test file that tests the response recorder.
The test function has three parts.
The first part: the matchers. The first line defines a function type: checkFunc
. This function signature has an argument for every value we will ever want to test. The arguments of checkFunc should include all the return values of the target function. In this case, since we are testing the methods of a ResponseRecorder, the state we want to test is in the ResponseRecorder itself. This will be the only argument of the checkFunc
.
The matcher functions are closures: provided with the expected value, the returned checkFunc
will error if the expectation is not matched.
func TestRecorder(t *testing.T) {
type checkFunc func(*ResponseRecorder) error
check := func(fns ...checkFunc) []checkFunc { return fns }
hasStatus := func(want int) checkFunc {
return func(rec *ResponseRecorder) error {
if rec.Code != wantCode {
return fmt.Errorf("expected status %d, found %d", want, rec.Code)
}
return nil
}
}
hasContents := func(want string) checkFunc {
return func(rec *ResponseRecorder) error {
if have := rec.Body.String(); have != want {
return fmt.Errorf("expected body %q, found %q", want, have)
}
return nil
}
}
hasHeader := func(key, want string) checkFunc {
return func(rec *ResponseRecorder) error {
if have := rec.Result().Header.Get(key); have != want {
return fmt.Errorf("expected header %s: %q, found %q", key, want, have)
}
return nil
}
}
The second part: the test cases. An anonymous struct
is holding the test data. Every test is defined with:
- the description of the case
- the input
- a slice of
checkFunc
carrying the expectations.
tests := [...]struct {
name string
h func(w http.ResponseWriter, r *http.Request)
checks []checkFunc
}{
{
"200 default",
func(w http.ResponseWriter, r *http.Request) {},
check(hasStatus(200), hasContents("")),
},
{
"first code only",
func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(201)
w.WriteHeader(202)
w.Write([]byte("hi"))
},
check(hasStatus(201), hasContents("hi")),
},
{
"write string",
func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "hi first")
},
check(
hasStatus(200),
hasContents("hi first"),
hasHeader("Content-Type", "text/plain; charset=utf-8"),
),
},
}
The third part: the testing logic. Here is where we get our hands dirty, using the data from the test cases to prepare and execute the actual target code. Then we range over the checkFunc
slice: if an error is returned, it can directly be passed to t.Error()
.
r, _ := http.NewRequest("GET", "http://foo.com/", nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := http.HandlerFunc(tt.h)
rec := NewRecorder()
h.ServeHTTP(rec, r)
for _, check := range tt.checks {
if err := check(rec); err != nil {
t.Error(err)
}
}
})
}
}
TDD or BDD, the choice is yours
If you are more into table-driven tests, the matchers will probably be repeated in every test with different values, and the name of the test case will describe a situation in which you want the target logic to function.
For a BDD dev, the test case name will be the description of the expected outcome, and not all the matchers will likely be used in every case.
If instead, like me, you just want your CLI to be flooded with tests, you can mix and match until you’re happy!