In unit testing, developers test individual functions, methods, modules, and packages to verify their correctness. Unit testing helps to find and fix bugs early in the development cycle, and it prevents regressions when refactoring. A good unit test can also serve as a form of documentation for developers who are new to the project.
In this tutorial, we’ll cover how to write unit tests in Go using the built-in testing package and several external tools. By the end of this article, you’ll understand concepts like table driven tests, dependency injection, and code coverage.
Let’s get started!
To understand testing in Go, we’ll write a basic program that computes the product of two integers. Then, we’ll write a test that verifies the correctness of its output.
First, create a directory on your file system and navigate into it. In the directory root, create a file called integers.go
and add the following code:
// integers.go package main import ( "fmt" ) // Multiply returns the product of two integers func Multiply(a, b int) int { return a * b }
Let’s write a test to verify that the Multiply()
function works correctly. In the current directory, create a file called integers_test.go
and add the following code to it:
// integers_test.go package main import "testing" func TestMultiply(t *testing.T) { got := Multiply(2, 3) want := 6 if want != got { t.Errorf("Expected '%d', but got '%d'", want, got) } }
The convention for naming test files in Go is to end the file name with the _test.go
suffix and place the file in the same directory as the code it tests. In the example above, the Multiply
function is in integers.go
, so its tests are placed in integers_test.go
.
Note that Go does not ship test files in any binaries that it produces because they are not needed for the code to run. In Go, a test function must always use the following signature:
func TestXxx(*testing.T)
A test’s name begins with the Test
prefix, followed by the name of the function being tested, Xxx
. It takes a single argument, which is a pointer of type testing.T
. The type exports several methods for tasks like reporting errors, logging intermediate values, and specifying helper methods.
In our example in the previous section, the got
variable inside the TestMultiply()
function is assigned to the result of the Multiply(2, 3)
function call. want
is assigned to the expected result 6
.
The latter part of the test checks if the values of want
and got
are equal. If not, the Errorf()
method is invoked, failing the test.
Now, let’s use the go test
command to run our test in the terminal. As long as Go is installed, the go test
command is already available on your machine.
The go test
command compiles the sources, files, and tests found in the current directory, then runs the resulting test binary. When testing is done, a summary of the test, either PASS
or FAIL
, will be printed to the console, as seen in the code block below:
$ go test PASS ok github.com/ayoisaiah/random 0.003s
When you use go test
as above, caching is disabled, so the tests are executed every time.
You can also opt into package list mode by using go test .
, which caches successful test results and avoids unnecessary reruns.
You can run tests in a specific package by passing the relative path to the package, for example, go test ./package-name
. Additionally, you can use go test ./...
to run the tests for all the packages in the codebase:
$ go test . ok github.com/ayoisaiah/random (cached)
If you append the -v
flag to go test
, the test will print out the names of all the executed test functions and the time spent for their execution. Additionally, the test displays the output of printing to the error log, for example, when you use t.Log()
or t.Logf()
:
$ go test -v === RUN TestMultiply --- PASS: TestMultiply (0.00s) PASS ok github.com/ayoisaiah/random 0.002s
Let’s cause our test to fail by changing want
to 7
. Run go test
once again, and inspect its output:
$ go test -v --- FAIL: TestMultiply (0.00s) integers_test.go:10: Expected '7', but got '6' FAIL exit status 1 FAIL github.com/ayoisaiah/random 0.003s
As you can see, the test failed, and the message passed to the t.Errorf()
function is present in the failure message. If you return the want
value to 6
, the test will pass once again.
The test example from above contains only a single case. However, any reasonably comprehensive test would have multiple test cases, ensuring that each unit of code is sufficiently audited against a range of values.
In Go, we use table driven tests, which allow us to define all our tests cases in a slice, iterate over them, and perform comparisons to determine if the test case succeeded or failed:
type testCase struct { arg1 int arg2 int want int } func TestMultiply(t *testing.T) { cases := []testCase{ {2, 3, 6}, {10, 5, 50}, {-8, -3, 24}, {0, 9, 0}, {-7, 6, -42}, } for _, tc := range cases { got := Multiply(tc.arg1, tc.arg2) if tc.want != got { t.Errorf("Expected '%d', but got '%d'", tc.want, got) } } }
In the code snippet above, we use the testCase
struct to define the inputs for each test case. The arg1
and arg2
properties represent the arguments to Multiply
, while want
is the expected result for the test case.
The cases
slice is used to set up all the test cases for the Multiply
function. Note that the property names are omitted for simplicity.
To test each case, we need to iterate over the cases
slice, pass arg1
and arg2
from each case to Multiply()
, then confirm if the return value is equal to what want
specified. We can test as many cases as needed using this setup.
If you run the test again, it will pass successfully:
$ go test -v === RUN TestMultiply --- PASS: TestMultiply (0.00s) PASS ok github.com/ayoisaiah/random 0.002s
In the examples above, we’ve used the t.Errorf()
method to fail tests. Using t.Errorf()
is equivalent to invoking t.Logf()
, which logs text to the console either on test failures or when the -v
flag is provided, followed by t.Fail()
, which marks the current function as failed without halting its execution.
Using t.Errorf()
prevents a test failure when we halt the function, allowing us to gather more information to fix the problem. Additionally, in a table driven test, t.Errorf()
allows us to fail a specific case without affecting the execution of other tests.
If a test function cannot recover from a failure, you can stop it immediately by invoking t.Fatal()
or t.Fatalf()
. Either method marks the current function as failed, stopping its execution immediately. These methods are equivalent to calling t.Log()
or t.Logf()
, followed by t.FailNow()
.
Using a table driven test is effective, however, there is one major flaw – the inability to selectively run an individual test case without running all test cases.
One solution to this problem is to comment out all the test cases that are irrelevant in the moment and uncomment them again later on. However, doing so is tedious and error-prone. For this scenario, we’ll use a subtest!
In Go 1.7, we can split each test case into a unique test that is run in a separate goroutine by adding a Run()
method to the testing.T
type. The Run()
method takes the name of the subtest as its first argument and the subtest function as the second. You can use the test name to identify and run the subtest individually.
To see it in action, let’s update our TestMultiply
test, as shown below:
func TestMultiply(t *testing.T) { cases := []testCase{ {2, 3, 6}, {10, 5, 50}, {-8, -3, 24}, {0, 9, 0}, {-7, 6, -42}, } for _, tc := range cases { t.Run(fmt.Sprintf("%d*%d=%d", tc.arg1, tc.arg2, tc.want), func(t *testing.T) { got := Multiply(tc.arg1, tc.arg2) if tc.want != got { t.Errorf("Expected '%d', but got '%d'", tc.want, got) } }) } }
Now, when you run the tests with the -v
flag, each individual test case will be reported in the output. Because we constructed the name of each test from the values in each test case, it’s easy to identify a specific test case that failed.
To name our test cases, we’ll add a name
property to the testCase
struct. It’s worth noting that the TestMultiply
function does not finish running until all its subtests have exited:
$ go test -v === RUN TestMultiply === RUN TestMultiply/2*3=6 === RUN TestMultiply/10*5=50 === RUN TestMultiply/-8*-3=24 === RUN TestMultiply/0*9=0 === RUN TestMultiply/-7*6=-42 --- PASS: TestMultiply (0.00s) --- PASS: TestMultiply/2*3=6 (0.00s) --- PASS: TestMultiply/10*5=50 (0.00s) --- PASS: TestMultiply/-8*-3=24 (0.00s) --- PASS: TestMultiply/0*9=0 (0.00s) --- PASS: TestMultiply/-7*6=-42 (0.00s) PASS ok github.com/ayoisaiah/random 0.003s
Code coverage counts the lines of code that are successfully executed when your test suite is running, representing the percentage of your code covered by your test suite. For example, if you have a code coverage of 80 percent, it means that 20 percent of the codebase is lacking tests.
Go provides a built-in method for checking your code coverage. Since Go v1.2, developers can use the -cover
option with go test
to generate a code coverage report:
$ go test -cover PASS coverage: 100.0% of statements ok github.com/ayoisaiah/random 0.002s
We’ve managed to achieve 100 percent test coverage for our code, however, we’ve only tested a single function comprehensively. Let’s add a new function in the integers.go
file without writing a test for it:
// integers.go // Add returns the summation of two integers func Add(a, b int) int { return a + b }
When we run the tests again with the -cover
option, we’ll see coverage of just 50 percent:
$ go test -cover PASS coverage: 50.0% of statements ok github.com/ayoisaiah/random 0.002s
Although we know what percentage of our codebase is covered, we don’t know what parts of our codebase aren’t covered. Let’s convert the coverage report to a file using the --coverprofile
option so we can examine it more closely:
$ go test -coverprofile=coverage.out PASS coverage: 50.0% of statements ok github.com/ayoisaiah/random 0.002s
In the code block above, the tests run as before, and code coverage is printed to the console.
However, the test results are also saved to a new file called coverage.out
in the current working directory. To study these results, let’s run the following command, which breaks the coverage report down by function:
$ go tool cover -func=coverage.out github.com/ayoisaiah/random/integers.go:4: Multiply 100.0% github.com/ayoisaiah/random/integers.go:9: Add 0.0% total: (statements) 50.0%
The code block above shows that the Multiply()
function is fully covered, while the Add()
function has only 50 percent coverage overall.
Another way to view the results is through an HTML representation. The code block below will open the default web browser automatically, showing the covered lines in green, uncovered lines in red, and uncounted statements in grey:
$ go tool cover -html=coverage.out
Using the HTML coverage method makes it easy to visualize what you haven’t covered yet. If the package being tested has multiple files, you can select each file from the input on the top right to see its coverage breakdown:
Let’s get the code coverage back to 100 percent by adding a test for the Add()
function, as shown below:
func TestAdd(t *testing.T) { cases := []test{ {1, 1, 2}, {7, 5, 12}, {-19, -3, -22}, {-1, 8, 7}, {-12, 0, -12}, } for _, tc := range cases { got := Add(tc.arg1, tc.arg2) if tc.want != got { t.Errorf("Expected '%d', but got '%d'", tc.want, got) } } }
Running the tests again should display a code coverage of 100 percent:
$ go test -cover PASS coverage: 100.0% of statements ok github.com/ayoisaiah/random/integers 0.003s
Let’s say that you have many test files and functions, but you want to isolate only one or a few to execute. We can do so using the -run
option. For example, if we want to run only the tests for the Add
function, we’ll pass the test function name as an argument to -run
:
$ go test -v -run=TestAdd === RUN TestAdd --- PASS: TestAdd (0.00s) PASS ok github.com/ayoisaiah/random/integers 0.003s
As you can see from the output above, only the TestAdd
method was executed. Note that the argument to -run
is interpreted as a regular expression, so all the tests that match the provided regex will be run.
If you have a set of test functions that begin with the same prefix, like TestAdd_NegativeNumbers
and TestAdd_PositiveNumbers
, you can run them in isolation by passing the prefix, TestAdd
, to -run
.
Now, let’s assume that we only want to run TestAdd
and TestMultiply
, but we have other test functions. We can use a pipe character to separate their names in the argument to -run
:
$ go test -v -run='TestAdd|TestMultiply' === RUN TestMultiply --- PASS: TestMultiply (0.00s) === RUN TestAdd --- PASS: TestAdd (0.00s) PASS ok github.com/ayoisaiah/random/integers 0.002s
You can also run a specific subtest by passing its name to -run
. For example, we can run any of the subtests in the TestMultiply()
function, as shown below:
$ go test -v -run='TestMultiply/2*3=6' === RUN TestMultiply === RUN TestMultiply/2*3=6 --- PASS: TestMultiply (0.00s) --- PASS: TestMultiply/2*3=6 (0.00s) PASS ok github.com/ayoisaiah/random 0.003s
Let’s assume we have a function that prints some output to the console, as shown below:
// printer.go func Print(text string) { fmt.Println(text) }
The Print()
function above outputs its string argument to the console. To test it, we have to capture its output and compare it to the expected value. However, because we have no control over the implementation of fmt.Println()
, using this method won’t work in our case. Instead, we can refactor the Print()
function, making it easier to capture its output.
First, let’s replace the call to Println()
with a call to Fprintln()
, which takes an io.Writer
interface as its first argument, specifying where its output should be written. In our example below, this location is specified as os.Stdout
. Now, we can match the behavior provided by Println
:
func Print(text string) { fmt.Fprintln(os.Stdout, text) }
For our function, it doesn’t matter where we print the text. Therefore, instead of hard-coding os.Stdout
, we should accept an io.Writer
interface and pass that to fmt.Fprintln
:
func Print(text string, w io.Writer) { fmt.Fprintln(w, text) }
Now, we can control where the output of the Print()
function is written, making it easy to test our function. In the example test below, we’ll use a buffer of bytes to capture the output of Print()
, then compare it to the expected result:
// printer_test.go func TestPrint(t *testing.T) { var buf bytes.Buffer text := "Hello, World!" Print(text, &buf) got := strings.TrimSpace(buf.String()) if got != text { t.Errorf("Expected output to be: %s, but got: %s", text, got) } }
When utilizing Print()
in your source code, you can easily inject a concrete type and write to the standard output:
func main() { Print("Hello, World!", os.Stdout) }
Although the example above is quite trivial, it illustrates one method for moving from a specialized function to a general-purpose one, allowing for the injection of different dependencies.
Writing unit tests ensures that each unit of code is working correctly, increasing the chance that your application as a whole will function as planned.
Having adequate unit tests also comes in handy when refactoring by helping to prevent regressions. The built-in testing package and the go test
command provide you with considerable unit testing capabilities. You can learn more by referring to the official documentation.
Thanks for reading, and happy coding!
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowes-toolkit is a lightweight, efficient JavaScript utility library, ideal as a modern Lodash alternative for smaller bundles.
The use cases for the ResizeObserver API may not be immediately obvious, so let’s take a look at a few practical examples.
Automate code comments using VS Code, Ollama, and Node.js.
Learn to build scalable micro-frontend applications using React, discussing their advantages over monolithic frontend applications.