go testing tips and tricks
intro⌗
Unit testing is one of the most crucial parts of a program; whilst it doesn’t contribute anything to program functionality, it does contribute that the program’s functionality actually functions.
The idea is simple: You compute a result through a function, compare that result with the wanted result, if they don’t match, throw an error.
Here’s how it would look like in Go:
package main
import "testing"
func TestPlus(t *testing.T) {
want := 2
have := 1 + 1
if want != have {
t.Fatalf("want %d is not have %d", want, have)
}
}
Simple, right? But, over time, your tests start to get more and more complicated. Instead of focusing on testing out functionality, you start focusing more on making better messages or adding more functionality and your unit tests start to become a little sloppy.
In this article, I am aiming towards teaching you how to create better unit tests adhering to different guidelines about testing that will improve your testing suites.
screw testing.T
; use something else⌗
This tip is something new I’ve been trying out and it changed my test units drastically. In my latest project, coup-server
, I have tests that test basic functions.
The functions usually take a slew of parameters but return one or two parameters without an error.
Over time, I’ve noticed that the unit tests have become more and more verbose without any testing being involved. I noticed that most of time was spent on making better test messages instead of creating better unit tests; so I went looking for a testing library.
Enter, is, a professional lightweight testing mini-framework for go. is
is pretty simple, you initiate it via the function New and you use one of its three crucial testing functions:
is.NoErr
: whether or not error is nilis.Equal
: tests equality between virtually any data typeis.True
: tests out a condition
To further illustrate this, take a look at the following code comparison from coup-server
:
.
As you could see, the version using is
is that much more concise and readable.
But, you don’t have to use is
like I am doing, there are a plethora of other libraries that you could find on the awesome-go
repository.
build only* functions⌗
The idea is simple: Go code is riddled with functions that return multiple-values; so, instead of assigning one variable to the function output and using that variable, use an only* function.
Here’s how it would look like in actual code:
package main
import (
"testing"
"fmt"
"github.com/matryer/is"
)
func onlyLast(val ...interface{}) interface{} {
return val[len(val-1)]
}
func myFunction() (int, error) {
return -1, fmt.Errorf("asdasd")
}
func TestMyFunction(t *testing.T) {
is := is.New(t)
is.NoErr(onlyLast(myFunction()).(error))
// versus
// _, err := myFunction()
// is.NoErr()
}
You could also do something similar to that:
func onlyLastError(val ...interface{}) error {
return onlyLast(val).(error)
}
compare errors⌗
This tip I have encountered as a way to make ft
’s tests a lot more correct.
In ft
, a function sometimes has to do a 4 or 5 operations. Take the FsVerify
functions which verifies that two files have the same content. It starts by stating a file, checking if it is a directory, compares their sizes, computing a hash, computing the other file’s hash, compares the two hashes and finally returning a result.
Every single step in this process is important and skipping one step is not permittable because it will make the return value incorrect. So, what could we do to ensure that every step has been taken: is.Equal
& global errors.
Start by creating global errors like so:
package main
import "fmt"
var (
ErrStat = fmt.Errorf("error stating file")
ErrIsDir = fmt.Errorf("file is a directory")
ErrSizeMismatch = fmt.Errorf("sizes do not match")
ErrSrcComputeHash = fmt.Errorf("src: could not compute the hash")
ErrDstComputeHash = fmt.Errorf("dst: could not compute the hash")
ErrHashMismatch = fmt.Errorf("the files do not have the same hash")
)
Afterwards in your test, use is.Equal
with faulty function parameters to test out if that error has been returned or not.
func TestFsVerify(t *testing.T) {
is.Equal(FsVerify("404", "file2"), ErrStat)
is.Equal(FsVerify("dir1", "file2"), ErrIsDir)
is.Equal(FsVerify("bigger-file", "file2"), ErrSizeMismatch)
is.Equal(FsVerify("bad-file-hash", "file2"), ErrSrcComputeHash)
is.Equal(FsVerify("file1", "bad-file-hash"), ErrDstComputeHash)
is.Equal(FsVerify("file1", "file2"), ErrHashMismatch)
is.NoErr(FsVerify("file1", "file1"))
}
abstract away functionality⌗
Functions get complex over time; a function that starts out being only 20 lines can grow to 200 and more. A major problem when creating unit tests is that the function is too large to test out.
There aren’t 6 steps like FsVerify
but 10, 20 or more which makes the task of writing a unit test tiring. To mitigate this, you should always abstract away functionality.
Let’s think about FsVerify
for a second: How can it be abstracted away? Well, for starters, we could get a function that Opens two files and returns an error if one of the files didn’t exist or was a directory.
Afterwards, we could create a function that takes in a file and computes a hash out of that file. Maybe, even a function that returns a size or errors out if the file was a directory or the file didn’t exist and so on.
Making small functions can help tremendously in making your unit tests a lot more palatable.
Once you’ve used small functions in a big function, you could later compare the steps not through global errors but through copying the parameters from your bigger function to the smaller function.
In the case of FsVerify
, we could do:
func TestFsVerify(t *testing.T) {
is := is.New(t)
is.Equal(FsVerify("404", "file2"), onlyLast(openTwoFiles("404", "file2")).(error))
// and so on
}
testing code coverage⌗
Another good thing about testing in Go to be exact is the fact that you could find out which areas of a function have been tested or not.
Through a process called code coverage, Go can run a test, trace the lines that are called and finally output them to a file. Later on, the developer can view the lines of code that have been executed and modify the tests so that each case in a function gets executed.
To use this amazing feature, you should download the Go cover
tool by executing the following:
go install golang.org/x/tools/cmd/cover
After that, go to your test directory and execute:
go test -coverprofile=c.out
go tool cover -html=c.out
And a new tab in your browser should be opened with the code that hasn’t been covered in red and code that has been covered in cyan. Take a look:
outro⌗
That’s all. Much of these tips could be obvious to some, however, I have spent a lot of time learning these.
Hopefully these could be of any benefit to you!