Testing your code is a good idea, but sometimes you might be testing the wrong thing, or even worse, testing nothing. Sometimes it might not be clear how to test something, or you might think something is not testable at all. This article is here to show you some tricks on making your code more testable, and a thought process you can apply to testing your code.
Before we start let's get one thing out of the way: You don't need a third party testing framework. We have all the tools we need right here in the Go standard lib, and this guide will be using those tools exclusively. You certainly don't need to import an entire "assert" library to avoid writing one if statement. See: https://golang.org/doc/faq#testing_framework if you disagree with me.
Let's start with a simple example of a test. Here is a function that finds the average of a slice of floats:
go1func average(values []float64) float64 {2var total float64 = 03for _, value := range values {4total += value5}6return total / float64(len(values))7}8
This is a perfectly testable function. We know what the output should be given any input, so we can test it very easily:
go1func Test_Average(t *testing.T) {2result := average([]float64{3.5, 2.5, 9.0})3if result != 5 {4t.Errorf("for test average, got result %f but expected 5", result)5}6}7
This is a valid, and useful, test. But it only tests one option. Testing multiple inputs can get messy, unless you organise well. It is ideal to create an array of test cases:
go1func Test_Average(t *testing.T) {2tests := []struct{3name string4input float645expectedResult float646}{7{8name: "three normal values",9input: []float64{3.5, 2.5, 9.0},10expectedResult: 5,11},12{13name: "handle zeroes",14input: []float64{0, 0},15expectedResult: 0,16},17{18name: "handle one value",19input: []float64{15.3},20expectedResult: 15.3,21},22}2324for _, test := range tests {25result := average(test.input)26if result != test.expectedResult {27t.Errorf(28"for average test '%s', got result %f but expected %f",29test.name,30result,31test.expectedResult,32)33}34}35}36
So we can now handle many test cases elegantly. Unfortunately not all code is going to be as simple as this. Let's consider if the function was like this:
go1func averageFromWeb() (float64, error) {2resp, err := http.DefaultClient.Get("http://our-api.com/numbers")3if err != nil {4return 0, err5}67values := []float64{}8if err := json.NewDecoder(resp.Body).Decode(&values); err != nil {9return 0, err10}1112var total float64 = 013for _, value := range values {14total += value15}16return total / float64(len(values))17}18
Now our code is hitting an API to get the values. We could still run the same test on this code, but we would be relying on that API to provide us with the same values every single time. We would also be limited to just a single test case, even if we could rely on the API in that way. Not to mention it's not ideal to be relying on a connection to our API, or to hit our API every time we test. Let's avoid racking up unnecessary cloud bills, shall we?
It's at this point that we need to ask ourselves, what is it that we are actually trying to test here? Are we trying to test that the Golang HTTP library can make HTTP calls? No. We didn't write that code. We want to test all of the other code around it that we wrote. That is, calculating the average of the values.
So how do we call this function in a test, but also avoid testing the HTTP call? We need to mock it. But before we can do that, we need to make it mockable. This will require some tricky setup. The first thing we do is turn the HTTP part of the function into a seperate function:
go1func getValues() (float64, error) {2resp, err := http.DefaultClient.Get("http://our-api.com/numbers")3if err != nil {4return 0, err5}67values := []float64{}8if err := json.NewDecoder(resp.Body).Decode(&values); err != nil {9return 0, err10}1112return values, nil13}14
Now our original function could use this function. But that doesn't make the code mockable just yet. The HTTP call still happens. We need to have a mock version of this function that doesn't call the API, but just returns some values we choose:
go1function getValues() (float64, error) {2return []float64{1.0, 2.5}, nil3}4
The issue with this is, when we run this code for real, we want the real version to run. We want the HTTP call to happen. But when we test it, we want the mock version of the function to run. We need to be able to run this function with two different setups. A real setup, and a mock setup.
This is where an interface comes in handy. The averageFromWeb function doesn't need to know what version of the getValues function is running, it just needs to know the output types. So let's make that an interface:
go1type ValueGetter interface {2GetValues() ([]float64, error)3}4
Now, we could set up our function so that we pass in the implementation of this interface, like so:
go1func averageFromWeb(valueGetter ValueGetter) (float64, error) {2values, err := valueGetter.GetValues()3if err != nil {4return 0, err5}67var total float64 = 08for _, value := range values {9total += value10}11return total / float64(len(values))12}13
This works for this scenario, but it's not going to scale for us in the future. Say we had 20 functions that all call different HTTP services, or databases, etc. We don't want to have to pass the implementations for all of those services and database around everywhere we go. We will have spaghetti code in no time. To make this elegant, we can create a "service" struct that can hold our implementation details, and make our function a method on that struct:
go1type service struct {2valueGetter ValueGetter3}45func (s service) averageFromWeb() (float64, error) {6values, err := s.valueGetter.GetValues()7if err != nil {8return 0, err9}1011var total float64 = 012for _, value := range values {13total += value14}15return total / float64(len(values))16}17
Now we're talking. The last two things we need are our implementations of ValueGetter. Here they are:
go1// The real implementation //23type httpValueGetter struct {}45func (h httpValueGetter) GetValues() (float64, error) {6resp, err := http.DefaultClient.Get("http://our-api.com/numbers")7if err != nil {8return 0, err9}1011values := []float64{}12if err := json.NewDecoder(resp.Body).Decode(&values); err != nil {13return 0, err14}1516return values, nil17}1819// The mock implementation //2021type mockValueGetter struct {22values []float6423err error24}2526func (m mockValueGetter) GetValues() (float64, error) {27return m.values, m.err28}29
Now we have a nicely mockable, and scalable service model for our function. In the real version of our code we'll initialise this service with the real version of the ValueGetter like so:
go1func main() {2service := service{valueGetter: httpValueGetter{}}34average, err := service.averageFromWeb()5if err != nil {6panic(err)7}89fmt.Println(average)10}11
And our test will look like this:
go1func Test_Average(t *testing.T) {2testError := fmt.Errorf("an example error to compare against")34tests := []struct{5name string6input float647err error8expectedResult float649expectedErr error10}{11{12name: "three normal values",13input: []float64{3.5, 2.5, 9.0},14expectedResult: 5,15},16{17name: "handle zeroes",18input: []float64{0, 0},19expectedResult: 0,20},21{22name: "handle one value",23input: []float64{15.3},24expectedResult: 15.3,25},26{27name: "error case",28input: []float64{3.5, 2.5, 9.0},29err: testError,30expectedErr: testError,31},32}3334for _, test := range tests {35service := service{valueGetter: mockValueGetter{36values: test.input,37err: test.err,38}}3940result, err := service.averageFromWeb()4142if err != test.expectedErr {43t.Errorf(44"for average test '%s', got error %v but expected %v",45test.name,46err,47test.expectedErr,48)49}5051if result != test.expectedResult {52t.Errorf(53"for average test '%s', got result %f but expected %f",54test.name,55result,56test.expectedResult,57)58}59}60}61
Fantastic! We're creating the mockValueGetter using the same input values from the test we made earlier, and also testing the error case.
This approach can be used to easily mock out any part of the code, including HTTP calls, database queries, and more. There is also an added advantage of making the code more modular and maintainable. Try this out on your own codebase and see how much more of your code you can test!