Back to Testing guides

A Gentle Introduction to Unit Testing in Go

Lanre Adelowo
Updated on January 15, 2024

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:

entitlement.go
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 by i, used when adding multiple members, like TakeN(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:

entitlement_test.go
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:

  1. 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.

  2. 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 the net/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:

Output
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:

Output
=== 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:

entitlement_test.go
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:

entitlement.go
. . .

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:

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:

entitlement_test.go
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:

Output
=== 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:

entitlement_test.go
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'
Output
=== 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:

Output
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.

htmlcoverage.png

To enhance test coverage, modify your test function as follows:

entitlement_test.go
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
Output
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!

Author's avatar
Article by
Lanre Adelowo
Lanre is a senior Go developer with 7+ years of experience building systems, APIs and deploying at scale. His expertise lies between Go, Javascript, Kubernetes and automated testing. In his free time, he enjoy writing technical articles or reading Hacker news and Reddit.
Got an article suggestion? Let us know
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

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 us
Writer of the month
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working...
Build 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.com

or submit a pull request and help us build better products for everyone.

See the full list of amazing projects on github