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 on 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 you code. 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?
Now, before we go on, I’ll take you on a short trip down memory lane. At this moment of my carreer I have probably written thousands (if not tens of thousands) of test cases. And I have failed quite a bit at testing, especially as a novice. So if you are new to test or just haven’t gotten it under your belt yet – worry not, I got you covered.
Before you start with testing there’s one idea I would like you to know: just like your programs are consisted of code, also your tests are code. And really, that’s something you have to remember. It’s simple - test are code.
What this testing code does is it invokes the code that powers your programs and checks if what it returns is what is expected. Quite simple, innit?
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 own conventions on what testing looks like. But really, it’s just that - meeting expectations.
That’s testing in a nutshell. Let’s move on.
What is a test?
So, what is a test then?
It’s quite simple - repeatable steps by which one can verify if something is working as it is supposed to.
What is a test in Go terms?
Quite similarly, it’s a piece of code that you can run many times and it will check if your code is working as intended.
That’s it. Shall we write one?
Testing our Go code
To write a test, we first have to have a program to test. Let’s implement a simple function that will take a slice of int
and return its biggest number:
func Max(numbers []int) int {
var max int
for _, number := range numbers {
if number > max {
max = number
}
}
return max
}
Pretty simple. 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 the result of the 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
As expected - the example passed. 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 would run it, both of these examples would fail. The reason for the failures would be that none of the two slices of int
s that we added do not match the expected maximum that we pass as a second argument to both of the calls. In the second example the maximum is 4
, 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 / an input for the function and an expected output we can test our functions.
What is important to understand here is that testing can be very simple. Here, we are able to check if our function is working as expected without any fancy frameworks and libraries - just plain Go code. If you have any experience with testing you already know that this approach does not scale too far, but it’s very good 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 we can avoid that.
Testing with Go’s testing
package
Golang’s testing
package provides support for automated testing of Go packages. It exposes a set of useful functions that we can use to get couple of benefits: a better looking code, standardised approach to testing and nicer looking output. Also, we eliminate the need to create our own 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, which will be 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 result of the Max
function and we assign it to actual
. Then, we compare actual
to the expected result (4
). If the comparison fails, then we make the test fail by using the t.Errorf
function and we 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
recompiles each package along with any files with names matching the file pattern
test*_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. This is because go test
is smartly done, with two different running modes.
The first mode, is called local directory mode. This mode is active whe the command invoked with no arguments. In local directory mode, go test
will compile the package sources and the tests found in the current directory and then will run the resulting test binary.
After the package test finishes, go test
prints a summary line showing the test status (ok
or FAIL
), package name, and elapsed time. This is what we actually see in our output - our test 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 full test output.
For now, we can stick with using the first mode. We will see when and how to use the second mode in one of the next articles.
Dealing with test failures
Now that we have a passing example, let’s see what tests failures look like. Let’s 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 having the wrong expectations being the only difference. Simply put - 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 Expected 5, got 4
-------- FAIL: TestMaxEmpty (0.00s)
max_test.go 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 tests. 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.
In the second lines, we see the test file name with the line number of where the failure occurred. More specifically, this is where in 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 Expected -1, got 0
FAIL
exit status 1
FAIL github.com/fteem/testing_in_go 0.006s
Good. We could technically remove the TestMaxInvalid
as it is the same as the TestMax
function. To make the other test pass, we need to return -1
when the slice received as 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
. Let’s run the tests again:
› go test
PASS
ok github.com/fteem/testing_in_go 0.006s
Back to passing tests. 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 about what tests are in fact. We understood that testing is useful and that test are just code - nothing more. We saw how we could test our own 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 how to fail 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 very simple but powerful technique. With a little bit of simple 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 make sure it is covered.
Obviously, 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.
Before we stop here, please let me know in the comments what you like and dislike about testing your code? Also, what topics in testing you find confusing or challenging?
Top comments (0)