Skip to main content

Testing in Go: go test

·15 mins

If you remember anything from this article, remember this: go test is a command that automates the execution of test files and functions in a Go project. The go test command ships with Go itself, so if you have Golang installed, there’s nothing to check - it’s available on your machine.

go test will recompile each package and any files with names matching the file pattern *_test.go. These *_test.go files can contain test functions, benchmark functions, and example functions. Each listed package will cause the execution of a separate test binary.

go test compiles test files that declare a package, ending with the suffix *_test.go as a separate package. It then links them with the main test binary and runs them.

While go test seems simple on the surface, it has many options and commands. It allows for control of what test to be run, such as file-specific or package-specific tests. It has flags for skipping tests, reporting test coverage, and other options. Also, it has intelligent caching mechanisms under the hood to avoid rebuilding packages every time we run our tests.

go test is a compact yet a rich tool that every Gopher should have under their belt. We will not look at all its features, but at the end of this article, we will have a good understanding of the workings of the tool.

Running modes #

go test has two running modes. Understanding them is essential to have an easy time working with the tool:

  1. Local directory mode, or running without arguments
  2. Package list mode, or running with arguments

In the local directory mode, go test compiles the package sources and tests found in the current directory and then runs the resulting test binary. This mode disables caching. After the package test finishes, go test prints a summary line showing the test status (‘ok’ or ‘FAIL’), the package name, and elapsed time.

To run your tests in this mode, run go test in your project’s root directory.

In the package list mode, go test compiles and tests each package listed as arguments to the command. 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.

To run your test in this mode, run go test with explicit package arguments. For example, we can run go test PACKAGE_NAME to test a specific package or go test ./... to test all packages in a directory tree. Or we can run go test . to run all tests in the current directory.

In our daily work with go test, the difference between the two modes is caching.

When we run go test in package list mode, it will cache successful package test results to avoid unnecessary reruns. When it can find a test result in the cache, go test will redisplay the cached result instead of rerunning the tests. When this happens, go test will annotate the test results with (cached) in place of the elapsed time in the summary line.

Test run control #

One of the significant features of the go test command is controlling what test files and test functions we can run. As we discussed before, go test has two modes to supply package or file names. Additionally, we can also selectively run one or more test functions.

To have an easier time further in this post, we will define a type Person. It will have two functions: a constructor NewPerson and a function older, which will take a *Person and return if one *Person is more senior than another *Person.

package person

import "errors"

type Person struct {
	age int
}

func NewPerson(age int) (*Person, error) {
	if age < 1 {
		return nil, errors.New("A person is at least 1 years old")
	}

	return &Person{
		age: age,
	}, nil
}

func (p *Person) older(other *Person) bool {
	return p.age > other.age
}

Let’s add tests for the constructor and the older function. We want to test that the constructor will return an error when a negative integer is passed as the age argument:

package person

import (
	"testing"
)

func TestNewPersonPositiveAge(t *testing.T) {
	_, err := NewPerson(1)
	if err != nil {
		t.Errorf("Expected person, received %v", err)
	}
}

func TestNewPersonNegativeAge(t *testing.T) {
	p, err := NewPerson(-1)
	if err == nil {
		t.Errorf("Expected error, received %v", p)
	}
}

Let’s add two more tests for the older function. We will create two *Person with different ages and check for the return value of the older function:

func TestOlderFirstOlderThanSecond(t *testing.T) {
	p1, _ := NewPerson(1)
	p2, _ := NewPerson(2)

	if p1.older(p2) {
		t.Errorf("Expected p1 with age %d to be younger than p2 with age %d", p1.age, p2.age)
	}
}

func TestOlderSecondOlderThanFirst(t *testing.T) {
	p1, _ := NewPerson(2)
	p2, _ := NewPerson(1)

	if !p1.older(p2) {
		t.Errorf("Expected p1 with age %d to be older than p2 with age %d", p1.age, p2.age)
	}
}

The first way to control test runs is by supplying the test files as arguments to the go test command. For example, if we have a person.go and person_test.go files, we need to run:

$ go test person.go person_test.go
ok  	command-line-arguments	0.005s

The go test command prints only the final ok because all the test cases passed. If we would like to see a more detailed output, we can use the -v flag:

$ go test person.go person_test.go -v
=== RUN   TestNewPersonPositiveAge
--- PASS: TestNewPersonPositiveAge (0.00s)
=== RUN   TestNewPersonNegativeAge
--- PASS: TestNewPersonNegativeAge (0.00s)
=== RUN   TestOlderFirstOlderThanSecond
--- PASS: TestOlderFirstOlderThanSecond (0.00s)
=== RUN   TestOlderSecondOlderThanFirst
--- PASS: TestOlderSecondOlderThanFirst (0.00s)
PASS
ok  	command-line-arguments	0.005s

The second way to run the test for the person package is to supply its name to the go test command:

$ go test person
ok  	person	0.004s

This way, the go test locates the package, builds it, and runs its tests. (Adding the -v flag will produce the same output as before.)

The third way to run tests is by specifying a test function to be run, using the -run flag. The flag takes an argument, a regex that will try to match against any test functions in the current directory. For example, if we would like to run the TestNewPersonPositiveAge and TestNewPersonNegativeAge function, we can do this:

$ go test -run TestNewPerson -v
=== RUN   TestNewPersonPositiveAge
--- PASS: TestNewPersonPositiveAge (0.00s)
=== RUN   TestNewPersonNegativeAge
--- PASS: TestNewPersonNegativeAge (0.00s)
PASS
ok  	person	0.004s

If we mistakenly supply a regexp that will not match anything, go test will inform us about it:

$ go test -run TestFoo -v
testing: warning: no tests to run
PASS
ok  	person	0.004s

Failing Fast #

When running tests, we often want to lower the waiting time and stop at the first test failure we hit. The default mode is to wait for all tests to finish before reporting the errors. So this can be a helpful feature, especially for larger projects.

To do this, go test has a special flag we can use: -failfast. Fail fast does what says on the tin: it will stop at the first test that fails, aborting the running test suite/files. To see it in action, we will break one of the test functions for the older function that we introduced earlier:

// Removing everything else for brewity...

func TestOlderFirstOlderThanSecond(t *testing.T) {
	p1, _ := NewPerson(100)
	p2, _ := NewPerson(2)

	if p1.older(p2) {
		t.Errorf("Expected p1 with age %d to be younger than p2 with age %d", p1.age, p2.age)
	}
}

We know this test will fail: p1 will be older than p2 because they are 100 and 2 years old, respectively. If we run go test, it will run all the test functions, ignoring when some fail:

$ go test -v
=== RUN   TestNewPersonPositiveAge
--- PASS: TestNewPersonPositiveAge (0.00s)
=== RUN   TestNewPersonNegativeAge
--- PASS: TestNewPersonNegativeAge (0.00s)
=== RUN   TestOlderFirstOlderThanSecond
--- FAIL: TestOlderFirstOlderThanSecond (0.00s)
    person_test.go:26: Expected p1 with age 100 to be younger than p2 with age 2
=== RUN   TestOlderSecondOlderThanFirst
--- PASS: TestOlderSecondOlderThanFirst (0.00s)
FAIL
exit status 1
FAIL	person	0.004s

Now, let’s see the difference if we add the -failfast flag:

$ go test -v -failfast
=== RUN   TestNewPersonPositiveAge
--- PASS: TestNewPersonPositiveAge (0.00s)
=== RUN   TestNewPersonNegativeAge
--- PASS: TestNewPersonNegativeAge (0.00s)
=== RUN   TestOlderFirstOlderThanSecond
--- FAIL: TestOlderFirstOlderThanSecond (0.00s)
    person_test.go:26: Expected p1 with age 100 to be younger than p2 with age 2
FAIL
exit status 1
FAIL	person	0.004s

By adding the -failfast flag, we stopped right where we got the first failure

  • when running the TestOlderFirstOlderThanSecond test function.

This option is useful when we do some heavier refactorings of our code. If our refactor breaks some tests, instead of being overwhelmed by all the errors, we can add the -failfast flag and fix our mistakes one by one.

Next time you do some big refactors, give it a shot; it might save you a few grey hairs as you’re fixing the tests.

Coverage #

Another exciting feature that is packed in go test is test coverage. Taken from Go’s website from the blog post on coverage:

Test coverage is a term that describes how much of a package’s code is exercised by running the package’s tests. If executing the test suite causes 80% of the package’s source statements to be run, we say that the test coverage is 80%.

We will not go into detail here on how the coverage tools work. I recommend referring to the “Test coverage for Go” section in the post above to understand how it measures the coverage.

If we would like to check the test coverage on our Person type and its functions, we can run go test -cover:

$ go test -cover
PASS
coverage: 100.0% of statements
ok  	person	0.004s

100% coverage is excellent - it means that our tests cover all the functionality of our program. We can add some more functionality to the NewPerson constructor function to see the coverage tool in action. Creating a person that is 1000 years old doesn’t make much sense, especially knowing the older person ever recorded was 122 years old. We can extend the constructor to validate that the newly created Person instance cannot be older than 130 years:

package person

import "errors"

type Person struct {
	age int
}

func NewPerson(age int) (*Person, error) {
	if age < 1 {
		return nil, errors.New("A person is at least 1 years old")
	}

	if age >= 130 {
		return nil, errors.New("A person cannot be older than 130 years")
	}

	return &Person{
		age: age,
	}, nil
}

func (p *Person) older(other *Person) bool {
	return p.age > other.age
}

If we rerun the coverage report, we will see the following output:

$ go test -cover -v
=== RUN   TestNewPersonPositiveAge
--- PASS: TestNewPersonPositiveAge (0.00s)
=== RUN   TestNewPersonNegativeAge
--- PASS: TestNewPersonNegativeAge (0.00s)
=== RUN   TestOlderFirstOlderThanSecond
--- PASS: TestOlderFirstOlderThanSecond (0.00s)
=== RUN   TestOlderSecondOlderThanFirst
--- PASS: TestOlderSecondOlderThanFirst (0.00s)
PASS
coverage: 83.3% of statements
ok  	person	0.004s

Whoops, our coverage dropped from 100% to 83.3%. To see where our problem is, we can ask go test to create a coverage profile for us, using the -coverprofile flag:

$ go test -coverprofile=prof.out
PASS
coverage: 83.3% of statements
ok  	person	0.004s

The output is a prof.out file, with the following contents:

$ cat prof.out
mode: set
person/person.go:9.42,10.13 1 1
person/person.go:14.2,14.16 1 1
person/person.go:18.2,20.3 1 1
person/person.go:10.13,12.3 1 1
person/person.go:14.16,16.3 1 0
person/person.go:23.44,25.2 1 1

We don’t have to understand the output here, as its format is not user-friendly. Go has another tool that we can use with the profile file to visualize the coverage report better: go tool cover. Using the prof.out profile file, we can generate an HTML page that will visualize what tests cover which parts of the code:

$ go tool cover -html=prof.out

This command pops open our browser with a page looking like this:

If we look at the image above, the red-colored code is the one that is never exercised by our tests. In other words, the red code is not covered, while the green one is. The grey code is not tracked because it’s boilerplate and should not be tested from the coverage tool perspective.

From the image, it’s clear that our tests never run the new branch where we handle unreasonably old age as the argument. Let’s add another quick test function that will cover this functionality:

// person_test.go
func TestNewPersonHugeAge(t *testing.T) {
	p, err := NewPerson(150)
	if err == nil {
		t.Errorf("Expected error, received %v", p)
	}
}

After running go test -coverprofile=hundred.out -v we will see the coverage go back to 100% again:

$ go test -coverprofile=hundred.out -v
=== RUN   TestNewPersonPositiveAge
--- PASS: TestNewPersonPositiveAge (0.00s)
=== RUN   TestNewPersonNegativeAge
--- PASS: TestNewPersonNegativeAge (0.00s)
=== RUN   TestNewPersonHugeAge
--- PASS: TestNewPersonHugeAge (0.00s)
=== RUN   TestOlderFirstOlderThanSecond
--- PASS: TestOlderFirstOlderThanSecond (0.00s)
=== RUN   TestOlderSecondOlderThanFirst
--- PASS: TestOlderSecondOlderThanFirst (0.00s)
PASS
coverage: 100.0% of statements
ok  	person	0.004s

Here’s the HTML output of the coverage profile:

The go test and go tool cover tools packs more combined functionality. I encourage you to read more about it in the official blog post and to experiment further with the tools.

Running modes #

Software is built to serve highly mutable business requirements, which results in applications that grow and evolve. Such applications are (hopefully) well tested, with tests operating on unit, integration and end-to-end levels.

A common technique on larger projects is to be explicit about what type of tests we want to run. For example, when running go test, do we want to include running the integration tests of the project or just the unit tests? In such cases, two practical techniques come out of the box with Go: build tags and short mode.

Using -short mode #

The -short mode for go test allows us to mark any long-running tests to be skipped in this mode. go test and the testing package support this via the t.Skip(), the testing.Short() functions and the -short flag.

We can skip a test function by checking if the short mode is on and invoking the t.Skip function if that returns true. Let’s see this by changing an earlier example:

func TestOlderFirstOlderThanSecond(t *testing.T) {
	if testing.Short() {
		t.Skip("Skipping long-running test.")
	}
	p1, _ := NewPerson(1)
	p2, _ := NewPerson(2)

	if p1.older(p2) {
		t.Errorf("Expected p1 with age %d to be younger than p2 with age %d", p1.age, p2.age)
	}
}

If we run the tests using go test -v, we should see no change in the output:

$ go test -v
=== RUN   TestNewPersonPositiveAge
--- PASS: TestNewPersonPositiveAge (0.00s)
=== RUN   TestNewPersonNegativeAge
--- PASS: TestNewPersonNegativeAge (0.00s)
=== RUN   TestNewPersonHugeAge
--- PASS: TestNewPersonHugeAge (0.00s)
=== RUN   TestOlderFirstOlderThanSecond
--- PASS: TestOlderFirstOlderThanSecond (0.00s)
=== RUN   TestOlderSecondOlderThanFirst
--- PASS: TestOlderSecondOlderThanFirst (0.00s)
PASS
ok  	person	0.004s

If we would enable the short mode using the -short flag, we should see the change in the behavior and output:

$ go test -short -v
=== RUN   TestNewPersonPositiveAge
--- PASS: TestNewPersonPositiveAge (0.00s)
=== RUN   TestNewPersonNegativeAge
--- PASS: TestNewPersonNegativeAge (0.00s)
=== RUN   TestNewPersonHugeAge
--- PASS: TestNewPersonHugeAge (0.00s)
=== RUN   TestOlderFirstOlderThanSecond
--- SKIP: TestOlderFirstOlderThanSecond (0.00s)
    person_test.go:30: Skipping long-running test.
=== RUN   TestOlderSecondOlderThanFirst
--- PASS: TestOlderSecondOlderThanFirst (0.00s)
PASS
ok  	person	0.005s

We can see that the output contains one SKIP line, meaning in short mode, go test skipped the test function. We can use -short in other ways, besides skipping a test. For example, mocking network calls instead of opening a connection or loading simple fixtures instead of loading them from a database.

The options are many; it all depends on the function that the test is covering.

Using build tags #

go test supports build tags out of the box, like the -short flag. While we apply the short mode on the function level, we use the build tags on a file level. The nature of build tags does not allow us to use them to skip one or a few functions. Instead, build tags are used to run (or skip) a particular type of test file from our test suite.

There are a couple of rules on build tags. As indicated in this Stack Overflow answer:

  • build tags are special comments, with the format // +build TAGNAME
  • we have to place the tag on the first line of the file
  • we have to add an empty line after the tag
  • the tag name comment cannot have a dash, but it allows underscores

Following these rules, we can add a tag to our person_test.go file from earlier:

// +build person_tests

package person

import (
	"testing"
)

// Snipped...

(The person_tests tag is useless when we have only one file in the test suite, but we will add it for experimentation purposes.)

To add the build tag to our go test command, we need to run go test -tags=TAG_NAME. To run the files with the person_tests build tag:

$ go test -tags=person_tests -v
=== RUN   TestNewPersonPositiveAge
--- PASS: TestNewPersonPositiveAge (0.00s)
=== RUN   TestNewPersonNegativeAge
--- PASS: TestNewPersonNegativeAge (0.00s)
=== RUN   TestNewPersonHugeAge
--- PASS: TestNewPersonHugeAge (0.00s)
=== RUN   TestOlderFirstOlderThanSecond
--- PASS: TestOlderFirstOlderThanSecond (0.00s)
=== RUN   TestOlderSecondOlderThanFirst
--- PASS: TestOlderSecondOlderThanFirst (0.00s)
PASS
ok  	person	0.005s

Based on the supplied tag, go test detects the files tagged with person_tests and runs them. If we provided a tag that does not exist in the package, the output would report that no files are found:

$ go test -tags=foo -v
?   	person	[no test files]

Notable mentions #

Covering all the features that go test packs in a single post is a hard undertaking. Here I will mention some other useful features that you should try to use and read more on:

  • List tests with -list: to list tests, benchmarks, or examples matching a regular expression passed as the argument. The -list flags are analogous to the -run flag that we discussed before. When we use this flag, go test will not run any tests, benchmarks, or examples.
  • Disabling caching with -count: as discussed before, go test by default caches test results for packages. It then uses them to skip running tests that have are not modified between two runs. Although it can improve the performance, there are times when we would like to disable the caching using the -count=1 flag.
  • Get JSON output using -json: if we would like to take the go test output and process it using a program, having it in a JSON format is better than plain text. This flag will convert the output to JSON, so processing it with a program is less cumbersome.
  • Use more CPU cores using -cpu: this flag will set the GOMAXPROCS variable to the argument that we pass to the flag. It limits the number of operating system threads that can execute user-level Go code simultaneously.
  • Detect race conditions using -race: since Go provides concurrent code primitives, race conditions are always a risk. Go’s tooling is great in this regard - it has a fully integrated race conditions detector in its toolchain. You can read more about it in its announcement blog post.