Some time ago I ran into an example source code of the standard library with some interesting approach to writing unit tests. At first, it felt strange, but I decided to apply it to my everyday routine and realized how awesome it is. I always put readability of my source code as a top priority, that's why I adopted custom check functions in my tests.
To be honest, I was not sure if people would share my enthusiasm for writing tests this way, but after having it presented on the latest GoWroc meetup (more details here), I saw that I'm not the only one that appreciates it. That's why I decided to share it with an even broader audience, so here we are.
In order to compare the way, we can write unit test I wrote a function that acts as a simple service that produces a shipment based on the user request. The input contains a total weight and a list of Item
elements, each of them having their Weight
and Name
. Optionally we can ask for all of a shipment's items to be squeezed in a single package. The output Shipment
has Weight
and a list of Package
items, each one having Description
and Weight
. There are a few rules that are important here:
- if the user wants
OnePackage
, we generate onePackage
with Grouped goods asDescription
and a sum of weights as its own. If not, eachItem
is translated into aPackage
- if the user defines a total weight, it's passed through. If not, it's a sum of weights of
Item
elements
Traditional approach
I decided to refer to the way I used to write tests as a traditional approach, as it is by far the most common.
What I used to is define a bunch of input and output pairs, then I compared each element of the actual outcome with what I expected. This seems pretty natural at first, but after a while, you realize that this way not only you have longer test cases, but also you absolutely need to make sure the whole expected result is correct. That is a hassle if you already check weight calculations before, but now you only worry about the way we translate items into packages.
// service_test.go
func TestProcessRequest_OldWay(t *testing.T) {
testCases := []struct {
desc string
in *Request
out *Response
err error
}{
...
{
desc: "weight from request",
in: &Request{
Items: []*Item{{Name: "Product 1", Weight: 300}},
Weight: 500,
},
out: &Response{
Shipment: &Shipment{
Packages: []*Package{
{Description: "Product 1", Weight: 300},
},
Weight: 500,
},
},
...
},
}
for _, tC := range testCases {
t.Run(tC.desc, func(t *testing.T) {
out, err := ProcessRequest(tC.in)
if err != nil {
if err != tC.err {
t.Errorf("Expected error %v, got: %v", tC.err, err)
}
return
}
if tC.err != nil {
t.Errorf("Expected error: %v", tC.err)
return
}
if out.Shipment.Weight != tC.out.Shipment.Weight {
t.Errorf("Expected shipment weight %d, got: %d", tC.out.Shipment.Weight, out.Shipment.Weight)
}
if len(out.Shipment.Packages) != len(tC.out.Shipment.Packages) {
t.Errorf("Expected packages count to be %d, got: %d", len(tC.out.Shipment.Packages), len(out.Shipment.Packages))
}
for i, p := range out.Shipment.Packages {
exp := tC.out.Shipment.Packages[i]
if p.Weight != exp.Weight {
t.Errorf("Expected package %d weight %d, got: %d", i, exp.Weight, p.Weight)
}
if p.Description != exp.Description {
t.Errorf("Expected package %d description %s, got: %s", i, exp.Description, p.Description)
}
}
})
}
}
As you can see, it takes a lot of code for each test case (it's hard to read) and it might be a bit overwhelming at first, to check what is or isn't checked. Also, we someone forgets to check one of the property, you are likely to miss it and be unaware of it not getting verified at all.
Custom checks approach
The new approach is based on a set of functions that check a part of the response, nothing else. We start by defining a check
function that takes as its argument all results from the function under test, plus *testing.T
object to be able to fail tests from within the check function itself.
type check func(*Response, error, *testing.T)
The second step is just for readability (and for saving keystrokes later), that is we want to define a group of checks one after the other as a var-arg parameter, not to have to create []check{..}
slice in each test case:
checks := func(cs ...check) []check { return cs }
Now, we need to define a set of reusable check functions that verify parts of the service outcome:
// service_test.go
...
hasNoError := func() check {
return func(_ *Response, err error, t *testing.T) {
if err != nil {
t.Errorf("Unexpected error %v", err)
}
}
}
hasTotalWeight := func(exp int) check {
return func(r *Response, _ error, t *testing.T) {
if r.Shipment.Weight != exp {
t.Errorf("Expected shipment weight %d, got: %d", exp, r.Shipment.Weight)
}
}
}
Now we can finally check what we originally wanted, without worrying about the other things:
testCases := []struct {
desc string
in *Request
checks []check
}{
{
...
desc: "weight from request",
in: &Request{
Items: []*Item{{Name: "Product 1", Weight: 300}},
Weight: 500,
},
checks: checks(
hasNoError(),
hasTotalWeight(500),
),
...
},
}
for _, tC := range testCases {
t.Run(tC.desc, func(t *testing.T) {
out, err := ProcessRequest(tC.in)
for _, ch := range tC.checks {
ch(out, err, t)
}
})
}
As you can see, the test cases are simpler and easier to read. If you ever want to add another thing you want to check, you can add a test case and limit yourself to verifying that one thing, without worrying about the rest of the service outcome.
Full example
The full source code of custom checks function (as well as with the old approach) is available on Github. This post and repository were inspired by net/http/httptest tests in Go's standard library sources.