Unit testing is very common in Go (Golang) code. Many of the types of applications that Go is frequently used for are very easy to write great unit tests for. However, if you are new to the language or new to unit testing in general, then there are a few things that may help to steer you in the right direction. This article focuses on helping get you started writing unit tests and learning about some of the best practices related to testing.
If you are new to unit testing in general, you might want to check out our post on the benefits of unit testing.
Getting started
Unlike most other languages I have worked with, Go has a built-in test runner and framework for standard language tooling. The easiest way to run unit tests for your project is using a command like go test ./...
from the command line. Running this command will discover and run any tests within your current directly or their subdirectories.
Tests must be in files separate from your main package code and end with the suffix _test.go
. A test function is identified by starting with func Test
. This is what one of the most simple tests would look like:
// hello.go - This is the code to be tested
package hello
func HelloWorld() string {
return "hello world"
}
// hello_test.go - This is the unit test file
package hello_test
import (
"fmt"
"testing"
"github.com/PullRequestInc/pkg/hello"
)
func TestHelloWorld(t *testing.T) {
actual := hello.HelloWorld()
if actual != "hello world" {
t.Errorf("expected 'hello world', got '%s'", actual)
}
}
The above test would pass as long as HelloWorld()
always returns the correct string. However, if it ever failed, then it would print out the failure message from t.Errorf
.
Unique to Go
If you are used to unit testing in other languages, there are a couple of things that will seem unique to writing unit tests in Go.
For one you will notice in the example above that the testing seems somewhat “crude” in that you perform the check yourself and then report an error message. There are not built in helpers for things like t.assertEquals(expected, actual)
. This is because Go tends to err on the side of minimalism and providing the basics that you need to perform a task in one way. This helps the language maintain backward compatibility for a long time and focus on optimizing and improving these core functionalities over time.
That being said, there is a great third-party library widely used for performing assertions of different types, testify. Testify has an assert
and require
package. The assert
will fail the test on the first failure it encounters, similar to the built-in t.Fail
. Where require
will just report an error and continue the test similar to t.Error
. I personally really like using the assert
package and think that it fits in nicely with the built-in Go test tooling and framework. Your tests' overall structure will still be the same, except you will have some assertion helpers to make things slightly fewer lines of code. The example above would become:
assert.Equal(t, "hello world", hello.HelloWorld())
Go tests are also aimed at being similar to how you write standard Go code. This way, you can use and improve on your techniques for writing normal Go code as you write tests. In many other languages, the way you write tests feels completely different from writing the standard code (for example, BDD tests). However, if you want to use BDD testing, there are some third-party frameworks available such as Ginkgo. Although you are just getting started, I recommend avoiding using large third-party frameworks like this as most teams and projects use the standard testing framework for Go. There are also some features of the standard test framework that do not always have as good of compatibility with these other frameworks, like the concurrency testing mode.
Setup and Teardown
As we pointed out above, the Go tests are straightforward and just consist of a function. Many other test frameworks you may be used to have features like setUp
methods that get run before every test and tearDown
methods that get run after every test. This is one of the reasons that some people may be enticed to go with a third-party framework like Ginkgo or others at first. However, you can achieve the same functionality with Go tests.
The pattern for this in Go is referred to as “table-driven tests”. Here is how you would achieve a setUp
and tearDown
shared by multiple similar tests.
// hello_test.go
package hello_test
import (
"fmt"
"testing"
"github.com/PullRequestInc/pkg/hello"
)
func TestHello(t *testing.T) {
type test struct {
input string
expected string
}
tests := []test{
{input: "world", expected: "hello world"},
{input: "pullrequest", expected: "hello pullrequest"},
}
for _, tc := range tests {
// perform setUp before each test here
t.Run(tc.input, func(t *testing.T) {
actual := hello.Hello(tc.input)
if actual != tc.expected {
t.Errorf("expected '%s', got '%s'", tc.expected, actual)
}
// perform tearDown after each test here
})
}
}
This is just a simple example above, and I am not actually using the setUp
or tearDown
, but you can see where you would insert such tasks. However, these table-driven tests can get as complicated as you need them to be. For some test cases, you may actually want to include a function as a parameter and call separate functions for the tests with a shared setUp
and tearDown
.
This is another example of how writing Go tests with the built-in tooling will actually teach you and improve your Go skills.
Testing within the same package
There are two main modularity options for how you want to test your Go package. You can create another package for your test code only, or you can add your test code in the same package that you are testing. Both ways will not actually include the code from your _test.go
files in your binary as these files are ignored in compilation of your binary. The main difference here is whether or not you need or want to have access to internal functions and data of the package or not.
The way to switch between these two types of testing is by using package hello
or package hello_test
for your hello_test.go
file.
For example, say you have a package that looks like this:
package hello
func world() string {
return "world"
}
func HelloWorld() string {
return "hello " + world()
}
If you wanted to be able to test the world
function separately from the HelloWorld
function, you could only do this if your test is in the same package hello
. This is because world
is an un-exported method, so you can’t access it outside of the package. So if you tried to test it from a package hello_test
, you could get a compilation error indicating that there is no symbol world
.
When possible, I recommend trying to use a separate testing package and just testing your publicly exported units. This tends to lead to a bit more modular code, and you get to test your package the same way that a consumer would use it. This can also help you to see how the API reads. However, I wouldn’t export extra internal functions just so that you can test the package in this way, and it is acceptable to test within the same package.
We will talk about mocking in a follow up to this post. Accepting interfaces to your public functions and using some mocks for the interfaces can often help with making your packages more testable from an external test package, which will also lead to more versatile packages that can be extended.
Illustration of the Golang Gopher by Renee French