A Gentle Introduction to Unit Testing in Go
In any well-organized codebase, most functionality is broken into small and reusable functions, often referred to as units.
Unit testing is the practice of testing these functions with various inputs to ensure they return the expected results in all scenarios.
This approach offers numerous advantages, including increased confidence when refactoring, acting as documentation, and boosting overall quality by encouraging thoughtful API design.
These tests are typically automated and integrated into continuous integration pipelines so that they can run repeatedly without human intervention to help prevent regressions.
In this post, you will learn to implement unit tests in Go using its built-in testing framework. By the end of this tutorial, you will understand:
- The Go standard library's testing capabilities.
- How to run and interpret test results.
- How to resolve issues in failing tests.
- How to create and analyze code coverage reports.
Let's dive in!
Prerequisites
Before proceeding with this tutorial, ensure that you've met the following requirements:
- Basic familiarity with the Go programming language.
- A recent version of Go installed on your local machine.
Step 1 — Setting up the demo project
To demonstrate the basics of testing in Go, I've prepared a simple provision of a simple entitlements claim implementation, which can be found in this GitHub repository.
You can clone it to your machine by executing:
git clone https://github.com/betterstack-community/gounittests.git
Then, navigate to the project directory and open it:
cd gounittests
code .
To understand what the program does, consider a web application with both free and premium subscriptions. While both tiers have access to a "teams" feature, free users are limited to adding a maximum of three team members, while premium users can add up to 10.
The logic for this feature is modeled in the provided entitlement.go
file:
package main
import (
"errors"
)
type Entitlement struct {
Teams struct {
Enabled bool `json:"enabled,omitempty"`
Size Counter `json:"size,omitempty"`
} `json:"teams,omitempty"`
}
var ErrCounterExhausted = errors.New("counter exhausted")
type Counter int64
func NewCounter(i int64) *Counter {
c := Counter(i)
return &c
}
func (c *Counter) Value() int64 {
if *c == 0 {
return 0
}
return int64(*c)
}
func (c *Counter) Take() error { return c.TakeN(1) }
func (c *Counter) TakeN(i int64) error {
if *c <= 0 {
return ErrCounterExhausted
}
*c -= Counter(i)
return nil
}
The program above defines two custom types. The Entitlement
struct specifies
team actions. For instance, if Teams.Enabled
is set to false
, the addition
of team members is blocked.
The Counter
type is a numerical count of how many additional team members can
be added. It will have an initial value that decrements with each new addition,
stopping at 0, indicating the limit is reached.
Counter
has three methods:
Value()
: Returns the current count or available spots.Take()
: Decreases the count by one.TakeN(i int64)
: Decreases the count byi
, used when adding multiple members, likeTakeN(4)
for 4 new members.
To add a new user, call Entitlement.Teams.Size.Take()
. For instance, if a user
initially could add 5 members, using Take()
will reduce this number to 4.
Here's a basic example:
func reduceEntitlement() error {
e := &Entitlement{
Teams: struct {
Enabled bool "json:\"enabled,omitempty\""
Size *Counter "json:\"size,omitempty\""
}{
Enabled: true,
Size: NewCounter(5),
},
}
return e.Teams.Size.Take()
}
In the next section, you will write a test that verifies that the Take()
method functions correctly.
Step 2 — Writing your first test
Now that you understand the program's logic, you will write some tests to validate its accuracy. Remember that unit tests involve feeding inputs into a program and checking the resulting output or the altered state of the program.
For our initial test, we'll start with a Counter
that's set to five, then
invoke the Take()
method to decrement its value. The test will then confirm
whether the resulting value is indeed four.
In Go, tests are written in files named <name>_test.go
, where <name>
corresponds to the file with the target functions. The _test.go
suffix is a
standard Go convention, enabling the compiler to overlook test files during
program compilation.
Start by creating a test file named entitlement_test.go
:
code entitlement_test.go
Now, proceed to pen your first test like this:
package main
import (
"testing"
)
func TestCounter_Take(t *testing.T) {
c := NewCounter(10)
err := c.Take()
if err != nil {
t.Fatalf("Could not take entitlement counter... %v", err)
}
if c.Value() != 9 {
t.Fatalf("counter did not reduce. Expected 9, got %d", c.Value())
}
}
The testing
package is the backbone for all Go tests. The T
type represents
the test's state and provides access to testing functionalities. For instance,
t.Fatalf()
indicates a test failure and stops it immediately.
As per the testing package documentation, test
functions should be named TestXxx
, with Xxx
being the name of the function
being tested. For example, a test for a Multiply
function should be named
TestMultiply
.
In this scenario, our test function is TestCounter_Take
, where Counter
is
the type and Take
is the method being tested.
The function begins by creating a new Counter
instance set to 10. After
calling Take()
, the function checks for errors to ensure the operation didn't
fail.
Since Take()
should decrease the counter by one, the test confirms the
counter's new value is 9. If it's not, this will indicate a flaw in the program
logic.
Next, we will explore how to execute these tests and interpret their results.
Step 3 — Running tests in Go
Go differentiates itself from many languages by including built-in, robust
testing support through the go test
command, eliminating the need for external
dependencies. This command works in two distinct modes:
Local directory mode: When you run
go test
without specifying package arguments, it operates in local directory mode. Here, Go compiles and executes all test files in your current working directory.Package mode: This mode is engaged when you run
go test
with specific package paths. Examples include:
go test .
to test the current package.go test ./...
to test the current package and all its sub-packages.go test net/http
to test thenet/http
package.
Given that we only have a single test file, we'll use the local directory mode to run the tests.
Execute go test
in the root directory of your project. The expected output
should resemble:
PASS
ok github.com/adelowo/unittests 0.5s
A useful feature of go test
is the -v
flag, enabling verbose mode:
go test -v
It provides detailed insights about each test function, including their status and execution duration:
=== RUN TestCounter_Take
--- PASS: TestCounter_Take (0.00s)
PASS
ok github.com/adelowo/unittests 0.626s
This detailed output is especially helpful for identifying test performance and failures, making it a valuable tool for debugging and optimizing your test suite.
Step 4 — Dealing with failing tests
Test failures can arise for various reasons, including supplying unexpected data, incorrect logic, etc. In this section, you'll observe how Go manages failing tests and how to debug and rectify them to achieve success.
First, update your entitlement_test.go
file by adding a new test designed to
fail initially:
func TestCounter_Take(t *testing.T) {
. . .
}
func TestCounter_TakeN(t *testing.T) {
c := NewCounter(2)
err := c.TakeN(3)
if err != nil {
t.Fatalf("Could not take entitlement counter... %v", err)
}
if c.Value() != 0 {
t.Fatalf("counter should be 0, but got %d", c.Value())
}
}
Running go test
now should yield the following error:
--- FAIL: TestCounter_TakeN (0.00s)
entitlement_test.go:29: counter did not reduce. Expected 0, got -1
FAIL
exit status 1
FAIL github.com/adelowo/unittests 0.658s
This test failure highlights a bug: we attempted to decrease a Counter
of size
two by three, which is not feasible and should not result in a negative number.
To address this, update the TakeN()
method to prevent negative counters:
. . .
var (
ErrCounterExhausted = errors.New("counter exhausted")
ErrNegativeCounter = errors.New("counter cannot be negative")
)
. . .
func (c *Counter) TakeN(i int64) error {
if *c <= 0 {
return ErrCounterExhausted
}
if remaining := *c - Counter(i); remaining < 0 {
return ErrNegativeCounter
} else {
*c = remaining
}
return nil
}
In the above code, the ErrNegativeCounter
error was added to identify
operations that trip the Counter
into a negative state.
After modifying the code, re-run the tests with go test -v
. You should now
encounter a different error:
go test -v
=== RUN TestCounter_Take
--- PASS: TestCounter_Take (0.00s)
=== RUN TestCounter_TakeN
entitlement_test.go:25: Could not take entitlement counter... counter cannot be negative
--- FAIL: TestCounter_TakeN (0.00s)
FAIL
exit status 1
FAIL github.com/adelowo/unittests 1.280s
Running specific tests
When fixing or adding new tests, it's often useful to run only the relevant
ones. The -run
flag is provided to allow for test filtering based on a regular
expression.
For example, to run only the failing test, execute:
go test -v -run=TestCounter_TakeN
Or even:
go test -v -run=TakeN
The TestCounter_Take
test will now be omitted from the output:
=== RUN TestCounter_TakeN
entitlement_test.go:30: counter did not reduce. Expected 0, got 2
--- FAIL: TestCounter_TakeN (0.00s)
FAIL
exit status 1
FAIL github.com/adelowo/unittests 0.499s
While this might not seem significantly impactful in this scenario, it becomes extremely useful when dealing with large test suites comprising hundreds or even thousands of test functions.
Fixing the failing test
Let's now proceed to fix the test so that it passes. Modify the
TestCounter_TakeN
test in entitlement_test.go
to correctly handle the
negative counter error:
func TestCounter_TakeN(t *testing.T) {
c := NewCounter(2)
err := c.TakeN(3)
if !errors.Is(err, ErrNegativeCounter) {
t.Fatalf("Expected a negative counter error, but got: %v", err)
}
}
Here, the test is adjusted to expect the ErrNegativeCounter
error when
attempting to reduce the counter by more than its current value. If this
specific error is not encountered, the test will fail.
Running go test -v
again should show all tests passing:
=== RUN TestCounter_Take
--- PASS: TestCounter_Take (0.00s)
=== RUN TestCounter_TakeN
--- PASS: TestCounter_TakeN (0.00s)
PASS
ok github.com/adelowo/unittests 0.001s
In the upcoming section, you'll learn how to ensure thorough testing by providing multiple inputs to a test function.
Step 5 — Utilizing table-driven tests
Currently, we have two separate test functions performing similar tasks. To streamline and expand our testing capabilities, we'll use table-driven testing.
This approach uses a test array containing various input values and their expected results, allowing us to test multiple scenarios within a single function.
Replace the contents of entitlement_test.go
with the following code:
package main
import (
"errors"
"testing"
)
func TestCounter_Take(t *testing.T) {
testTable := []struct {
name string
startingValue int64
expectedValue int64
valueToRemove int64
gotError error
}{
{
name: "one item can be taken successfully",
startingValue: 10,
expectedValue: 9,
valueToRemove: 1,
gotError: nil,
},
{
name: "all items can be taken successfully",
startingValue: 10,
expectedValue: 0,
valueToRemove: 10,
gotError: nil,
},
{
name: "cannot take more items than what the counter has",
startingValue: 10,
expectedValue: 0,
valueToRemove: 16,
gotError: ErrNegativeCounter,
},
{
name: "report counter exhausted error",
startingValue: 0,
expectedValue: 0,
valueToRemove: 1,
gotError: ErrCounterExhausted,
},
}
for _, v := range testTable {
t.Run(v.name, func(t *testing.T) {
c := NewCounter(v.startingValue)
err := c.TakeN(v.valueToRemove)
if v.gotError != nil {
if !errors.Is(err, v.gotError) {
t.Fatalf(
"expected an error: %v, but got: %v",
v.gotError,
err,
)
}
return
}
if c.Value() != v.expectedValue {
t.Fatalf("Expected %d got %d", v.expectedValue, c.Value())
}
})
}
}
In this revised version, a testTable
slice contains various test cases, each
with a unique set of parameters and expected outcomes.
The name
field describes each test case, startingValue
sets the initial
Counter
value, valueToRemove
defines the amount to be subtracted,
expectedValue
denotes the expected Counter value after the operation and
gotError
handles expected error scenarios.
Each test case in the table is executed within the t.Run()
method, creating a
subtest for each case. This format keeps tests concise and easily readable, and
it simplifies adding new test cases.
Run these tests with go test -v, and you should receive a successful output:
=== RUN TestCounter_Take
=== RUN TestCounter_Take/one_item_can_be_taken_successfully
=== RUN TestCounter_Take/all_items_can_be_taken_successfully
=== RUN TestCounter_Take/cannot_take_more_items_than_what_the_counter_has
=== RUN TestCounter_Take/report_counter_exhausted_error
--- PASS: TestCounter_Take (0.00s)
--- PASS: TestCounter_Take/one_item_can_be_taken_successfully (0.00s)
--- PASS: TestCounter_Take/all_items_can_be_taken_successfully (0.00s)
--- PASS: TestCounter_Take/cannot_take_more_items_than_what_the_counter_has (0.00s)
--- PASS: TestCounter_Take/report_counter_exhausted_error (0.00s)
PASS
ok github.com/adelowo/unittests 0.001s
Although it's a single test function, each case is treated as an individual
subtest, identifiable by the name
field (spaces replaced with underscores).
To run a specific subtest, use the -run
flag:
go test -v -run 'TestCounter_Take/one'
=== RUN TestCounter_Take
=== RUN TestCounter_Take/one_item_can_be_taken_successfully
--- PASS: TestCounter_Take (0.00s)
--- PASS: TestCounter_Take/one_item_can_be_taken_successfully (0.00s)
PASS
ok github.com/adelowo/unittests 0.001s
Step 6 — Checking code coverage
Code coverage is a crucial metric in testing, indicating the extent to which your code is tested. It's expressed as a percentage representing the proportion of your codebase that your tests cover.
While aiming for high code coverage is beneficial, achieving 100% is often unrealistic for most applications, and even 90% can be unnecessarily ambitious, especially for larger projects.
The go test
command provides a few different flags for reporting the code
coverage for the program under test.
1. Basic coverage reporting
To get a quick overview of your code coverage, use go test -cover
. This
command gives a simple percentage output indicating the extent of code coverage:
PASS
coverage: 91.7% of statements
ok github.com/adelowo/unittests 0.001s
This output shows that over 90% of the unittests
module is tested. However, it
doesn't specify which parts of your code are untested.
2. HTML coverage reporting
For a more detailed view, you can generate an HTML report in two steps.
First, create a coverage report file:
go test -coverprofile=coverage.out
This command generates a coverage.out
file and prints a summary like before:
PASS
coverage: 91.7% of statements
ok github.com/adelowo/unittests 0.001s
Next, convert this file into an HTML report:
go tool cover -html=coverage.out
This command opens the report in your default web browser. The report color-codes the lines as follows: red lines are not covered by tests, green lines are covered, and grey lines don't count towards coverage.
To enhance test coverage, modify your test function as follows:
func TestCounter_Take(t *testing.T) {
. . .
for _, v := range testTable {
t.Run(v.name, func(t *testing.T) {
c := NewCounter(v.startingValue)
var err error
if v.valueToRemove == 1 {
err = c.Take()
} else {
err = c.TakeN(v.valueToRemove)
}
if v.gotError != nil {
if !errors.Is(err, v.gotError) {
t.Fatalf(
"expected an error: %v, but got: %v",
v.gotError,
err,
)
}
return
}
if c.Value() != v.expectedValue {
t.Fatalf("Expected %d got %d", v.expectedValue, c.Value())
}
})
}
}
After these changes, re-run the coverage report:
go test -cover
PASS
coverage: 100.0% of statements
ok github.com/adelowo/unittests 0.001s
This should now show 100% coverage, indicating that every statement in your code is now being tested.
Final thoughts
This article provided a comprehensive guide to writing, running, and debugging your first tests in Go. It also introduced you to code analysis and coverage metrics, which are essential for assessing the effectiveness of your tests.
While this marks the beginning of your testing journey, it sets a solid foundation for learning advanced Go testing concepts. For further exploration, see the testing package documentation and our Scaling Go guides.
Thanks for reading!
Make your mark
Join the writer's program
Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.
Write for usBuild on top of Better Stack
Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.
community@betterstack.comor submit a pull request and help us build better products for everyone.
See the full list of amazing projects on github