10 minutes read

Like any other programmer, you write code, and even if you are completely sure that your code works correctly, it may not be so: you need a proof. There are many ways to get it, but not all of them are reliable.

The easiest way to check the program code for correctness is to output the results to the terminal and see what works well and what not. Everyone has their own strategy: for example, you can utilize usual functions, such as fmt.Println(...) or fmt.Printf(...). Besides, you will require the main function, and sometimes the main package to do this. All this works but isn't particularly convenient or too reliable. Hence, you need a mechanism to implement checks in a unified way. It shouldn't be complicated but rather structured and not add extra code to the executable file. Go has a testing package that provides support for automated testing of your code. Further in this topic, you will learn how to use it.

Creating a simple test

Suppose you've written a useful function that needs to be tested against various inputs. Now you need to add one more function:

func TestName(*testing.T)

For example:

func TestHello(t *testing.T)

The prefix Test tells the system that this is a test. The word Hello identifies the test name for a specific function. In this case, Hello. Both must start with a capital letter. The test identifier and the tested function name don't have to match, but it's good practice if they do.

testing is a standard Go package. testing.T means testing type, and t is the variable, through which the testing methods are available to you. You'll look at some of the testing methods later.

Let's write a function Hello and test it. You can give the name greetings to the package. Create a file with the name hello.go and put the function there. This is the code you will test.

hello.go:

package greetings

func Hello(lang string) string {
    switch lang {
    case "fr":
        return "bonjour"
    case "it":
        return "ciao"
    default:
        return "hello"
    }
}

Now, create a test file hello_test.go. This is where you will write the test for the Hello function.

Don't forget to import the "testing" package.

hello_test.go:

package greetings

import "testing"

func TestHello(t *testing.T) {
    got := Hello("fr")
    expected := "bonjour"
    if got != expected {
        t.Errorf("Hello(\"fr\") = %v, expected = %v", got, expected)
    }

    got = Hello("x")
    expected = "hello"
    if got != expected {
        t.Errorf("Hello(\"x\") = %v, expected = %v", got, expected)
    }

    // example of a failed test:
    // got = "holla"
    // expected = "ciao"
    // if got != expected {
    //	t.Errorf("Hello(\"it\") = %v, expected = %v", got, expected)
    // }
}

You call a function (Hello in this case) and get something from it. Then you save it in the got variable. Here you want to get something definite known to you in advance. For example, you expect the function to return "bonjour" for the "fr" input. During testing, you compare the expected result with the actual one. If something is wrong, you call t.Errorf(...). This method will report where something you didn't expect happened, and the testing will continue. If you need to stop testing, you should call another method: t.Fatalf(...).

The test is ready! It remains to understand how to run it and get results.

Running a test

Let's look at two ways to run tests: in the terminal and in the GoLand IDE.

To run tests in the terminal, type go test:

> go test
PASS
ok      hyperskillUnitTesting   0.594s

PASS tells us that the test was successful. hyperskillUnitTesting is the name of your module (set in the file go.mod). You can use any other name instead.

To run tests in GoLand, press the green button near the test function declaration:

function declaration

=== RUN   TestHello
--- PASS: TestHello (0.00s)
PASS
Process finished with the exit code 0

The form of the output is different, but the meaning is the same.

If you uncomment the code above:

got = "holla"
expected = "holla"
if got != expected {
    t.Errorf("Hello(\"it\") = %v, expected = %v", got, expected)
}

This is a specially prepared code with an incorrect outcome. You reinitialize the variable, but now with the wrong data (got = "holla").

Now, let's run the test in Terminal:

> go test
--- FAIL: TestHello (0.00s)
    hello_test.go:23: Hello("it") = holla, expected = ciao
FAIL
exit status 1
FAIL    hyperskillUnitTesting   0.814s

The test will fail. The output also indicates the place in the code where the test has failed, as well as the result that you should have received, ideally: hello_test.go:23: Hello("it") = holla, expected = ciao.

The same, now in GoLand:

=== RUN   TestHello
    hello_test.go:23: Hello("it") = holla, expected = ciao
--- FAIL: TestHello (0.00s)

FAIL

Process finished with the exit code 1

Test coverage

Alright, the tests passed. How can you understand that you did everything you could and there were no untested code sections left? The first part of this question is more rhetorical, and the second can be answered quite easily: you have to check the coverage.

Coverage is a metric showing how much of your code the tests cover.

To check the coverage of your code, type go test -cover in Terminal:

> go test -cover
PASS
coverage: 75.0% of statements
ok      hyperskillUnitTesting   0.691s

Run -> Run with Coverage in GoLand:

Run with Coverage in GoLand

You can see which part of the code the tests don't cover:

> go test --coverprofile=c.out
> go tool cover --html=c.out

After executing these commands, an .html file will be generated. This file shows coverage areas (not tracked, not covered, covered):

coverage areas

Take notice that full coverage doesn't guarantee full verification. Coverage indicates that tests can reach and execute all code and all branches.

Error testing

Let's write another function for the greetings package. Here, you create your own error to inform the user about incorrectly entered input data. In this case – about the absence of any data. It is good practice to inform the users of your functions!

bye.go:

package greetings

import (
    "errors"
    "fmt"
)

var ErrEmptyName = errors.New("empty name")

func Bye(name string) (string, error) {
    if len(name) == 0 {
        return "", ErrEmptyName
    }
    return fmt.Sprintf("Bye-bye, %v", name), nil
}

bye_test.go:

package greetings

import (
    "fmt"
    "testing"
)

func TestBye(t *testing.T) {
    name := "Felix"
    expected := "Bye-bye, Felix"
    got, err := Bye(name)
    if err != nil {
        t.Errorf("Unexpected error: %s", err.Error())
    }
    if got != expect {
        t.Errorf("Expected: %s, got: %s", expected, got)
    }
}

If you run the tests in terminal (> go test), it will launch all the test functions in the package (TestHello and TestBye).

To launch only one test function, use the following syntax: > go test -run TestBye.

Since, in your case, the function also returns an error, you should handle it in order to cover the code by tests:

if err != nil {
    t.Errorf("Unexpected error: %s", err.Error())
}

This is an unexpected error because the function shouldn't throw an error on the current data.

You can write another code to check for an expected error. Add the following lines to the end of the TestBye function:

_, err = Bye("")
if !errors.Is(err, ErrEmptyName) {
    t.Errorf("Expected: %v, got: %v", ErrEmptyName, err)
}

With errors.Is, you can check that the expected error is thrown.

Above are simple examples of creating and running tests. For each tested function, you created your own test file. You could also create a single file with two test functions. Running the tests would be the same as above.

Useful tips

When the test grows large, splitting it into test cases makes sense. Consider the TestHello(t *testing.T). Now you have one test, but it looks bulky. Here's what you can do:

package greetings

import "testing"

func TestHelloFrench(t *testing.T) {
    got := Hello("fr")
    expected := "bonjour"
    if got != expected {
        t.Errorf("Hello(\"fr\") = %v, expected = %v", got, expected)
    }
}

func TestHelloDefault(t *testing.T) {
    got := Hello("x")
    expected = "hello"
    if got != expected {
        t.Errorf("Hello(\"x\") = %v, expected = %v", got, expected)
    }
}

// TestHelloItalian has incorrect check and will fail during the test run
func TestHelloItalian(t *testing.T) {
    got := "holla"
    expected = "ciao"
    if got != expected {
        t.Errorf("Hello(\"it\") = %v, expected = %v", got, expected)
    }
}

Now the test is more readable!

It is good practice to start with testing rather than with main. The main package and the main function should be as thin as possible.

Conclusion

You need to write tests to ensure your code is correct: it doesn't matter if you wrote the code yourself or used somebody else's code. The most basic thing you should start with is unit tests. You have some expectations from the code and want to check that the expectations match reality. To do this, you can write and run tests. Don't forget to take into account the boundary conditions. Besides, try to completely cover your code with tests to make it more robust.

12 learners liked this piece of theory. 1 didn't like it. What about you?
Report a typo