Comprehensive Guide to Testing in Go
This article was written by an external contributor.
Testing is an essential part of the development process, and it’s a critical part of the software development life cycle. It ensures that your app functions correctly and meets your customer’s needs. This article will cover everything you need to know about Go testing. You will start with a simple testing function, and work through more tools and strategies to help you master testing in Go.
You’ll learn about a number of different modes of testing, such as table-driven tests, which are used to better organize your test cases; benchmark tests, which are used to validate performance; and fuzz tests, which allow you to explore edge cases and discover bugs.
You will also learn about tools from the standard testing package and its helper functions, and how code coverage can show you how much of your code is being tested. You’ll also learn about Testify, an assertion and mocking library that will improve test readability.
You can find all the code examples in this GitHub repository.
Writing a Simple Unit Test
Unit tests are a way to test small pieces of code, such as functions and methods. This is useful because it allows you to find bugs early. Unit tests make your testing strategies more efficient, since they are small and independent, and thus easy to maintain.
Let’s create an example to practice testing. Create a function, Fooer
, that takes an int
as input and returns a string
. If the input integer is divisible by three, then return "Foo"
; otherwise, return the number as a string
.
You may recognize an oversimplified example of the FooBarQix coding question. Writing tests around this question will help you practice testing with Go.
Create a new file called fooer.go
, and paste in the following code to create the example:
package main import "strconv" // If the number is divisible by 3, write "Foo" otherwise, the number func Fooer(input int) string { isfoo := (input % 3) == 0 if isfoo { return "Foo" } return strconv.Itoa(input) }
Unit tests in Go are located in the same package (that is, the same folder) as the tested function. By convention, if your function is in the file fooer.go
file, then the unit test for that function is in the file fooer_test.go
.
Let’s write a simple function that tests your Fooer
function, which returns "Foo"
, when you input 3
:
package main import "testing" func TestFooer(t *testing.T) { result := Fooer(3) if result != "Foo" { t.Errorf("Result was incorrect, got: %s, want: %s.", result, "Foo") } }
A test function in Go starts with Test
and takes *testing.T
as the only parameter. In most cases, you will name the unit test Test[NameOfFunction]
. The testing
package provides tools to interact with the test workflow, such as t.Errorf
, which indicates that the test failed by displaying an error message on the console.
You can run your tests using the command line:
go test
The output should look like this:
In GoLand, you can run a specific test by clicking on the green arrow in the gutter.
After GoLand finishes running your tests, it shows the results in the Run tool window. The console on the right shows the output of the current test session. It allows you to see the detailed information on the test execution and why your tests failed or were ignored.
To run all the tests in a package, click on the green double-triangle icon at the top.
Writing Table-Driven Tests
When writing tests, you may find yourself repeating a lot of code in order to cover all the cases required. Think about how you would go about covering the many cases involved in the Fooer
example. You could write one test function per case, but this would lead to a lot of duplication. You could also call the tested function several times in the same test function and validate the output each time, but if the test fails, it can be difficult to identify the point of failure. Instead, you can use a table-driven approach to help reduce repetition. As the name suggests, this involves organizing a test case as a table that contains the inputs and the desired outputs.
This comes with two benefits:
- Table tests reuse the same assertion logic, keeping your test DRY.
- Table tests make it easy to know what is covered by a test, as you can easily see what inputs have been selected. Additionally, each row can be given a unique name to help identify what’s being tested and express the intent of the test.
Here is an example of a table-driven test function for the Fooer
function:
func TestFooerTableDriven(t *testing.T) { // Defining the columns of the table var tests = []struct { name string input int want string }{ // the table itself {"9 should be Foo", 9, "Foo"}, {"3 should be Foo", 3, "Foo"}, {"1 is not Foo", 1, "1"}, {"0 should be Foo", 0, "Foo"}, } // The execution loop for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ans := Fooer(tt.input) if ans != tt.want { t.Errorf("got %s, want %s", ans, tt.want) } }) } }
A table-driven test starts by defining the input structure. You can see this as defining the columns of the table. Each row of the table lists a test case to execute. Once the table is defined, you write the execution loop.
The execution loop calls t.Run()
, which defines a subtest. As a result, each row of the table defines a subtest named [NameOfTheFuction]/[NameOfTheSubTest]
.
This way of writing tests is very popular, and considered the canonical way to write unit tests in Go. GoLand can generate those test templates for you. You can right-click your function and go to Generate | Test for function. Check out GoLand’s documentation for more details.
All you need to do is add the test cases:
{"9 should be Foo", args{9}, "Foo"}, {"3 should be Foo", args{3}, "Foo"}, {"1 is not Foo", args{1}, "1"}, {"0 should be Foo", args{0}, "Foo"},
When you execute the test, you’ll see that your TestFooerTableDriven
function runs four subtests, one for each row of the table.
With the generate feature, writing table-driven tests becomes simple and intuitive.
The Testing Package
The testing
package plays a pivotal role in Go testing. It enables developers to create unit tests with different types of test functions. The testing.T
type offers methods to control test execution, such as running tests in parallel with Parallel()
, skipping tests with Skip()
, and calling a test teardown function with Cleanup()
.
Errors and Logs
The testing.T
type provides various practical tools to interact with the test workflow, including t.Errorf()
, which prints out an error message and sets the test as failed.
It is important to mention that t.Error*
does not stop the execution of the test. Instead, all encountered errors will be reported once the test is completed. Sometimes it makes more sense to fail the execution; in that case, you should use t.Fatal*
. In some situations, using the Log*()
function to print information during the test execution can be handy:
func TestFooer2(t *testing.T) { input := 3 result := Fooer(3) t.Logf("The input was %d", input) if result != "Foo" { t.Errorf("Result was incorrect, got: %s, want: %s.", result, "Foo") } t.Fatalf("Stop the test now, we have seen enough") t.Error("This won't be executed") }
The output prompt should look like this:
As it’s shown, the last line t.Error("This won't be executed")
has been skipped, because t.Fatalf
has already terminated this test.
Running Parallel Tests
By default, tests are run sequentially; the method Parallel()
signals that a test should be run in parallel. All tests calling this function will be executed in parallel. go test
handles parallel tests by pausing each test that calls t.Parallel()
, and then resuming them in parallel when all non-parallel tests have been completed. The GOMAXPROCS
environment defines how many tests can run in parallel at one time, and by default this number is equal to the number of CPUs.
You can build a small example running two subtests in parallel. The following code will test Fooer(3)
and Fooer(7)
at the same time:
func TestFooerParallel(t *testing.T) { t.Run("Test 3 in Parallel", func(t *testing.T) { t.Parallel() result := Fooer(3) if result != "Foo" { t.Errorf("Result was incorrect, got: %s, want: %s.", result, "Foo") } }) t.Run("Test 7 in Parallel", func(t *testing.T) { t.Parallel() result := Fooer(7) if result != "7" { t.Errorf("Result was incorrect, got: %s, want: %s.", result, "7") } }) }
GoLand prints all status information (RUN
, PAUSE
, or CONT
) for each test during the execution. When you run the above code, you can clearly see Test_3
was paused before Test_7
started to run. After Test_7
was paused, both tests were resumed and ran until finished.
To reduce duplication, you may want to use table-driven tests when using Parallel()
. As you can see, this example required the duplication of some of the assertion logic.
Skipping Tests
Using the Skip()
method allows you to separate unit tests from integration tests. Integration tests validate multiple functions and components together and are usually slower to execute, so sometimes it’s useful to execute unit tests only. For example, go test
accepts a flag called -test.short
that is intended to run a “fast” test. However, go test
does not decide whether tests are “short” or not. You need to use a combination of testing.Short()
, which is set to true
when -short
is used, and t.Skip()
, as illustrated below:
func TestFooerSkiped(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode.") } result := Fooer(3) if result != "Foo" { t.Errorf("Result was incorrect, got: %s, want: %s.", result, "Foo") } }
This test will be executed if you run go test -v
, but will be skipped if you run go test -v -test.short
.
As shown below, the test was skipped in short mode.
Writing out the flags every time you run your tests can be tedious. Fortunately, GoLand allows you to save run/debug configurations for each test. A configuration is created every time you run a test using the green arrow in the gutter.
You can read more about configuration templates for tests in GoLand’s Help.
Test Teardown and Cleanup
The Cleanup()
method is convenient for managing test tear down. At first glance, it may not be evident why you would need that function when you can use the defer
keyword.
Using the defer
solution looks like this:
func Test_With_Cleanup(t *testing.T) { // Some test code defer cleanup() // More test code }
While this is simple enough, it comes with a few issues that are described in this article about the Go 1.14 new features. The main argument against the defer
approach is that it can make test logic more complicated to set up, and can clutter the test function when many components are involved.
The Cleanup()
function is executed at the end of each test (including subtests), and makes it clear to anyone reading the test what the intended behavior is.
func Test_With_Cleanup(t *testing.T) { // Some test code here t.Cleanup(func() { // cleanup logic }) // more test code here }
You can read more about the tests clean up with examples in this article.
At that point, it is worth mentioning the Helper()
method. This method exists to improve the logs when a test fails. In the logs, the line number of the helper function is ignored and only the line number of the failing test is reported, which helps figure out which test failed.
func helper(t *testing.T) { t.Helper() // do something } func Test_With_Cleanup(t *testing.T) { // Some test code here helper(t) // more test code here }
Finally, TempDir()
is a method that automatically creates a temporary directory for your test and deletes the folder when the test is completed, removing the need to write additional cleanup logic.
func TestFooerTempDir(t *testing.T) { tmpDir := t.TempDir() // your tests }
This function is very practical, but due to its relative newness, many Go developers are unaware of it and still manage temporary directories manually in their tests.
Writing Coverage Tests
As tests are crucial to modern development work, it’s essential to know how much of your code is covered by tests. You can use Go’s built-in tool to generate a test report for the package you’re testing by simply adding the -cover
flag in the test command:
go test -cover
Note that you can also add the flag -v
for more detailed logs.
By default, test coverage calculates the percentage of statements covered through tests. In the output, you can see that eighty percent of the code is currently covered. (The main function is not covered by the tests.) While it’s complicated to fully explain how test coverage is calculated, you can read The Cover Story if you’re interested in knowing more about it.
There are various arguments that can be passed to go test -cover
. For example, go test
only considers packages with test files in the coverage calculation. You can use -coverpkg
to include all packages in the coverage calculation:
go test ./... -coverpkg=./...
You can find a working example in this GitHub repository expanding on why you would need -coverpkg
.
Using the flag -coverprofile
will create a local coverage report file. This is useful when running tests in CI/CD, since you often send the report to your favorite code quality tool.
go test -coverprofile=output_filename
You can also use go tool cover to format the report. For example, the -html
flag will open your default browser to display a graphical report.
go tool cover -html=output_filename
You can also simply use GoLand’s built-in coverage report. If you use the Run with Coverage option, you’ll get a detailed coverage report on the side panel.
GoLand also highlights covered lines in green, and uncovered lines in red. This is very helpful in deciding what test to write next. In the image below, some additional code was added to demonstrate what uncovered code looks like.
The last flag you need to know about is -covermode
. By default, the coverage is calculated based on the statement coverage, but this can be changed to take into account how many times a statement is covered. There are several different options:
set
: Coverage is based on statements.count
: Count is how many times a statement was run. It allows you to see which parts of code are only lightly covered.atomic
: Similar to count, but for parallel tests.
Knowing which flag to use when running coverage tests is most important when running tests in your CI/CD, because locally you can rely on GoLand for a friendly coverage report. Note that GoLand uses the atomic
mode by default, which allows for coverage to be represented in shades of green on the left side of covered statements.
Writing Benchmark Tests
Benchmark tests are a way of testing your code performance. The goal of those tests is to verify the runtime and the memory usage of an algorithm by running the same function many times.
To create a benchmark test:
- Your test function needs to be in a
*_test
file. - The name of the function must start with
Benchmark
. - The function must accept
*testing.B
as the unique parameter. - The test function must contain a
for
loop usingb.N
as its upper bound.
Here is a simple example of a benchmark test of the Fooer
function:
func BenchmarkFooer(b *testing.B) { for i := 0; i < b.N; i++ { Fooer(i) } }
The target code should be run N
times in the benchmark function, and N
is automatically adjusted at runtime until the execution time of each iteration is statistically stable.
In this example, the benchmark test ran 59,969,790 times with a speed of 19 ns per iteration. Benchmark tests themselves never fail.
You will have to use other tools if you want to save and analyze the results in your CI/CD. perf/cmd
offers packages for this purpose. benchstat
can be used to analyze the results, and benchsave
can be used to save the result.
Writing Fuzz Tests
Fuzz testing is an exciting testing technique in which random input is used to discover bugs or edge cases. Go’s fuzzing algorithm is smart because it will try to cover as many statements in your code as possible by generating many new input combinations.
To create a fuzz test:
- Your test function needs to be in a
_test
file. - The name of the function must start with
Fuzz
. - The test function must accept
testing.F
as the unique parameter. - The test function must define initial values, called seed corpus, with the
f.Add()
method. - The test function must define a fuzz target.
Let’s put all these into a comprehensive example:
func FuzzFooer(f *testing.F) { f.Add(3) f.Fuzz(func(t *testing.T, a int) { Fooer(a) }) }
The goal of the fuzz test is not to validate the output of the function, but instead to use unexpected inputs to find potential edge cases. By default, fuzzing will run indefinitely, as long as there isn’t a failure. The -fuzztime
flag should be used in your CI/CD to specify the maximum time for which fuzzing should run. This approach to testing is particularly interesting when your code has many branches; even with table-driven tests, it can be hard to cover a large set of input, and fuzzing helps to solve this problem.
To run a fuzz test in GoLand, click on the green triangle in the gutter and select Run | go test -fuzz option, otherwise (without -fuzz
) the test will only run once, using the corpus seed.
The Testify Package
Testify is a testing framework providing many tools for testing Go code. There is a considerable debate in the Go community about whether you should use Testify or just the standard library. Proponents feel that it increases the readability of the test and its output.
Testify can be installed with the command go get github.com/stretchr/testify
.
Testify provides assert functions and mocks, which are similar to traditional testing frameworks, like JUnit for Java or Jasmine for NodeJS.
func TestFooerWithTestify(t *testing.T) { // assert equality assert.Equal(t, "Foo", Fooer(0), "0 is divisible by 3, should return Foo") // assert inequality assert.NotEqual(t, "Foo", Fooer(1), "1 is not divisible by 3, should not return Foo") }
Testify provides two packages, require
and assert
. The require
package will stop execution if there is a test failure, which helps you fail fast. assert
lets you collect information, but accumulate the results of assertions.
func TestMapWithTestify(t *testing.T) { // require equality require.Equal(t, map[int]string{1: "1", 2: "2"}, map[int]string{1: "1", 2: "3"}) // assert equality assert.Equal(t, map[int]string{1: "1", 2: "2"}, map[int]string{1: "1", 2: "2"}) }
When running the above test, all the lines below the first require
assertion will be skipped.
The output log also clearly indicates the difference between the actual output and the expected output. Compared to Go’s built-in testing
package, the output is more readable, especially when the testing data is complicated, such as with a long map or a complicated object. The log points out exactly which line is different, which can boost your productivity.
GoLand integrates with Testify to analyze the assertion result.
Wrapping Up
Testing is important because it allows you to validate the code’s logic and find bugs when changing code. Go offers numerous tools out of the box to test your application. You can write any test with the standard library, and Testify and its “better” assertion function and mock capability offer optional additional functionality.
The testing
package offers three testing modes: regular tests (testing.T
), benchmark tests (testing.B
), and fuzz tests (testing.F
). Setting any type of test is very simple. The testing
package also offers many helper functions that will help you write better and cleaner tests. Spend some time exploring the library before jumping into testing.
Finally, your IDE plays a crucial role in writing tests efficiently and productively. We hope this article has allowed you to discover some of the features of GoLand, such as generating table-driven tests, running test functions with a click, the coverage panel, color-coded coverage gutters, and integration with Testify for easier debugging.