Testing in Go: First Principles
Table of Contents
If you have any programming experience, whether that’s as a student or a professional, there’s a good chance you have heard about testing. It’s an omnipresent topic, be it at conferences, books, or articles. (See what I did there?)
Also, it seems like a topic that everyone agrees on - yes, testing is good, and we should do it. There are many reasons why folks consider testing good for your code’s quality. But, before we go down the rabbit hole and discuss the pros and cons of testing, let’s learn how we can test our Go code.
Of course, through actual examples that you can follow along.
What is testing? #
Throughout my career, I have written thousands of test cases. And I have failed quite a bit at writing them, especially as a novice. So if you are new to testing or just haven’t gotten it under your belt yet – worry not. I know how you feel, and that feeling stops here.
Before you start with testing, there’s one idea I would like you to internalize: tests are just code. We are in control of what we will write and how complicated we’ll make our tests. But, just like with our programs, we need to keep the code quality high in our tests, too.
Tests invoke the code that powers your programs and checks if what the code returns is what is expected.
It all revolves around setting expectations and then making your program meet these expectations. Usually, languages provide you with packages or libraries to test your code, with each language or library having its testing conventions. But at its core, testing is just meeting expectations.
That’s testing in a nutshell. Let’s move on.
What is a test? #
So, what is a test then? Repeatable steps to verify if a piece of code is working as it is supposed to.
What is a test in Go terms? Similarly, it’s code that we can run many times, and it will check if our program’s code is working as expected.
That’s it. Shall we write one?
Testing our Go code #
To write a test, we first have to have a program to test. So let’s implement a
function that will take a slice of int
and return its largest number:
func Max(numbers []int) int {
var max int
for _, number := range numbers {
if number > max {
max = number
}
}
return max
}
We take a slice of int
s and return the largest. So, how can we test that our
code works as expected?
Let’s write a function that will take two arguments: a slice of int
s and the
maximum of the int
s in that slice. We will call it TestMax
:
func TestMax(numbers []int, expected int) string {
output := "Pass"
actual := Max(numbers)
if actual != expected {
output = fmt.Sprintf("Expected %v, but instead got %v!", expected, actual)
}
return output
}
The TestMax
function will check if he Max
function call matches the
expected
result. If it does, it will simply return "Pass"
, otherwise it
will return an informative string with what it was expecting and what it got.
Let’s use it in our main
function:
func main() {
fmt.Println(TestMax([]int{1, 2, 3, 4}, 4))
}
The main
function will invoke the TestMax
once here, with a slice that
contains 1,2,3,4
as argument and 4
as the expected maximum.
You might already be thinking that the example will pass. Let’s run it:
$ go run max.go
Pass
The example passed, great! So let’s add two more:
func main() {
fmt.Println(TestMax([]int{1, 2, 3, 4}, 4))
fmt.Println(TestMax([]int{4, 2, 1, 4}, 3))
fmt.Println(TestMax([]int{0, 0, 0, 0}, 1))
}
Here we add two more examples, with two different pairs of arguments:
- A slice containing
4,2,1,4
and3
, the expected maximum - A slice containing four zeroes, and
1
as the expected maximum
If we ran it, both of these examples would fail. The failure would be that none
of the two slices of int
s that we added match the expected maximum we pass as
a second argument to both calls. For example, the maximum is 4
in the second
example, while we expect a 3
. In the third example, the maximum is 0
while
we expect 1
.
Testing our Max
function can be done with just one function (TestMax
). As
long as we can supply input for the function and expected output, we can test
our functions.
What is essential to understand here is that testing can be straightforward. We can check if our function is working as expected without any fancy frameworks and libraries - only plain Go code. Of course, if you have any experience with testing, you already know that this approach does not scale too far, but it’s terrific to understand the idea that tests are just code.
Using this approach, we could technically even write our own testing framework/library. The good thing is that Go already has a testing package included in its standard library so that we can avoid that.
Testing with Go’s testing
package #
Golang’s testing
package supports
automated testing of Go packages. Moreover, it exposes valuable functions that
we can use to get a couple of benefits: a better-looking code, a standardized
approach to testing, and nicer looking output. Also, we eliminate the need to
create our reporting of failed/passed tests.
Let’s see how we could test our Max
function using the testing
package.
First, we need to create a max_test.go
file, the test counterpart to our
max.go
(where our Max
function is defined). Most importantly, both of the
files have to be part of the same package (in our example main
):
// max_test.go
package main
import "testing"
func TestMax(t *testing.T) {
actual := Max([]int{1, 2, 3, 4})
if actual != 4 {
t.Errorf("Expected %d, got %d", 4, actual)
}
}
The testing
package that is imported allows for benchmarking and testing. In
our test, we use the testing.T
type, which, when passed to Test*
functions,
manages the test state and formats the test logs.
Within the TestMax
function, we get the Max
function and assign it to
actual
. Then, we compare actual
to the expected result (4
). If the
comparison fails, we make the test fail by using the t.Errorf
function and
supply the error message.
What happens when we run go test
#
Golang’s testing
package also comes with its buddy – the go test
command.
This command automates testing the packages named by the import paths. go test
recompiles each package along with any files with names matching the file
pattern *_test.go
.
To run this file, we have to use the go test
command:
$ go test
PASS
ok github.com/fteem/testing_in_go 0.007s
As you can see, we did not need to tell Golang which tests to run - it figured
this out on its own. Golang can figure what tests to run on its own because go test
is smartly done, with two different running modes.
The first mode is called local directory mode. This mode is active when the
command is invoked with no arguments. For example, in local directory mode, go test
will compile the package sources and the tests found in the current
directory and then run the resulting test binary.
After the package test finishes, go test
prints a summary line showing the
test status (ok
or FAIL
), the package name, and the elapsed time. We can
see in the output that our tests have passed! Looking at the output above, we
can also see that they passed in 0.007s
. Pretty fast!
The second mode is called package list mode. This mode is activated when the
command is invoked with explicit arguments. In this mode, go test
will
compile and test each of the packages listed as arguments. If a package test
passes, go test
prints only the final ‘ok’ summary line. If a package test
fails, go test
prints the complete test output.
For now, we can stick with using the first mode. However, we will see when and how to use the second mode in one of the following articles.
Dealing with test failures #
Now that we have a passing example, let’s experience the look and feel of test failures. We will add another example, which we will purposely fail:
func TestMaxInvalid(t *testing.T) {
actual := Max([]int{1, 2, 3, 4})
if actual != 5 {
t.Errorf("Expected %d, got %d", 5, actual)
}
}
The TestMaxInvalid
is very similar to the test function we had before, with
the only difference being that we have the wrong expectations. More
specifically, we know that Max
will return 4
here, but we are expecting a
5
.
While we are here, let’s add one more example where we would pass an empty slice
as an argument to Max
and expect -1
as a result:
func TestMaxEmpty(t *testing.T) {
actual := Max([]int{})
if actual != -1 {
t.Errorf("Expected %v, got %d", -1, actual)
}
}
Let’s run go test
again and see the output:
$ go test
--- FAIL: TestMaxInvalid (0.00s)
max_test.go:15: Expected 5, got 4
--- FAIL: TestMaxEmpty (0.00s)
max_test.go:22: Expected -1, got 0
FAIL
exit status 1
FAIL github.com/fteem/testing_in_go 0.009s
The two new tests failed unsurprisingly. If we inspect the output here, we will
notice that there are two lines per failed test. Both of these lines start with
--- FAIL:
and have the test function name after. At the end of the line,
there’s also the time it took for the test function to run.
We see the test file name with the line number of where the failure occurred in
the following lines. More specifically, this is wherein both of our test files
we invoke t.Errorf
.
Let’s make our tests pass. First, we need to fix the expectation in the
TestMaxInvalid
test function:
func TestMaxInvalid(t *testing.T) {
actual := Max([]int{1, 2, 3, 4})
if actual != 4 {
t.Errorf("Expected %d, got %d", 4, actual)
}
}
Now, when we run it we should see one less failure:
$ go test
--- FAIL: TestMaxEmpty (0.00s)
max_test.go:22: Expected -1, got 0
FAIL
exit status 1
FAIL github.com/fteem/testing_in_go 0.006s
Good. Technically, we could remove the TestMaxInvalid
as it is the same as
the TestMax
function. However, to make the other test pass, we need to return
-1
when the slice received as an argument in Max
is empty:
package main
func Max(numbers []int) int {
if len(numbers) == 0 {
return -1
}
var max int
for _, number := range numbers {
if number > max {
max = number
}
}
return max
}
The len
function will check the length of the numbers
slice. If it’s 0
,
it will return -1
. So let’s rerun the tests:
$ go test
PASS
ok github.com/fteem/testing_in_go 0.006s
Our tests are passing again. With our new change, the Max
function will
return -1
when the slice in arguments is empty.
In closing #
What we talked about in this article is what tests are. We understood that testing is valuable and that tests are just code - nothing more. We saw how we could test our code without any libraries or frameworks, with just simple Golang code.
Then, we went on to explore Golang’s testing
package. We saw how an actual
test function looks like. We talked about function definitions, the testing.T
argument that we have to pass in, and failing a test. Then we added some more
tests for our Max
function and made its tests pass.
As you can see, testing, in a nutshell, is a straightforward but powerful technique. With a little bit of code, we can assure that our code functions in an expected matter, that we can control. And with any new functionality added to our code, we can easily throw in another test to ensure it is covered.
There is much more to testing that we will explore in other articles, but now that we are confident with these basic ideas and approaches, we can build our knowledge on top of them.
Before we stop here, please let me know what you like and dislike about testing your code? Also, what topics in testing you find confusing or challenging?