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 one Package with Grouped goods as Description and a sum of weights as its own. If not, each Item is translated into a Package
  • 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.