DEV Community

Ilija Eftimov
Ilija Eftimov

Posted on • Originally published at ieftimov.com on

Testing in Go: First Principles

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
}
Enter fullscreen mode Exit fullscreen mode

Pretty simple. 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
}
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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 would run it, both of these examples would fail. The reason for the failures would be that none of the two slices of ints 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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
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
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)