Skip to main content

Testing in Go: First Principles

·10 mins

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 ints 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 ints and the maximum of the ints 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:

  1. A slice containing 4,2,1,4 and 3, the expected maximum
  2. 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 ints 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?