Unit Testing made easy in Go

In this article, we will learn about unit testing in Go. Go provides built-in functionality to test your Go code, so we do not need an expensive setup or third-party libraries to create useful tests.

Unit Testing made easy in Go

Like cells are the building units of our body, unit components make up software. These units can be functions, structs, methods, or any other behavior that the user of a package depends on.

If a unit accepts input and produces output, we should be able to verify that behavior. That is what unit testing gives us. A unit test runs a small part of the program with known input and compares the result with the output we expected.

Go makes this workflow simple. The standard library already includes the testing package, and the Go toolchain already includes the go test command.

Basic Test Structure

A Go test is just a function whose name starts with Test, accepts *testing.T, and lives in a file ending with _test.go.

import "testing"

func TestAbc(t *testing.T) {
	t.Error("test failed")
}

The testing.T value gives us methods such as Error, Errorf, Fail, Fatal, Log, and Logf. When a test calls one of the failure methods, go test marks that test as failed.

Let’s start with a small executable package called greeting.

// hello.go
package main

import "fmt"

// hello returns a greeting message for the provided user.
func hello(user string) string {
	return fmt.Sprintf("Hello %v!", user)
}
// main.go
package main

import (
	"fmt"

	"github.com/logrusorgru/aurora"
)

func main() {
	greetMessage := hello("John")

	fmt.Println(aurora.Yellow(greetMessage))
}

To run both files together, we can use:

go run *.go

That prints:

Hello John!

So far, the hello function works for a normal name. But what happens when the caller passes an empty string? Let’s handle that by returning a default greeting.

// hello.go
package main

import "fmt"

// hello returns a greeting message for the provided user.
func hello(user string) string {
	if len(user) == 0 {
		return "Hello Dude!"
	}

	return fmt.Sprintf("Hello %v!", user)
}

Now we can test both cases from main.go.

// main.go
package main

import (
	"fmt"

	"github.com/logrusorgru/aurora"
)

func main() {
	greetMessageEmpty := hello("")
	fmt.Println(aurora.Yellow(greetMessageEmpty))

	greetMessageJohn := hello("John")
	fmt.Println(aurora.Yellow(greetMessageJohn))
}

This works, but testing behavior manually inside main does not scale. We need a real test file.

Writing The First Test

Create a file named hello_test.go in the same package.

// hello_test.go
package main

import "testing"

// TestHello tests the hello function.
func TestHello(t *testing.T) {
	emptyResult := hello("")

	if emptyResult != "Hello Dude!" {
		t.Errorf("hello(\"\") failed, expected %v, got %v", "Hello Dude!", emptyResult)
	}

	result := hello("Mike")

	if result != "Hello Mike!" {
		t.Errorf("hello(\"Mike\") failed, expected %v, got %v", "Hello Mike!", result)
	}
}

Now run the tests:

go test

If everything passes, Go prints something like this:

PASS
ok      greeting        0.006s

By default, Go keeps the output short. If we want to see each test function as it runs, we can use -v.

go test -v
=== RUN   TestHello
--- PASS: TestHello (0.00s)
PASS
ok      greeting        0.006s

Logging From Tests

In verbose mode, we can also print useful test information with t.Log or t.Logf.

// hello_test.go
package main

import "testing"

// TestHello tests the hello function.
func TestHello(t *testing.T) {
	emptyResult := hello("")

	if emptyResult != "Hello Dude!" {
		t.Errorf("hello(\"\") failed, expected %v, got %v", "Hello Dude!", emptyResult)
	} else {
		t.Logf("hello(\"\") success, expected %v, got %v", "Hello Dude!", emptyResult)
	}

	result := hello("Mike")

	if result != "Hello Mike!" {
		t.Errorf("hello(\"Mike\") failed, expected %v, got %v", "Hello Mike!", result)
	} else {
		t.Logf("hello(\"Mike\") success, expected %v, got %v", "Hello Mike!", result)
	}
}

Now go test -v shows the logs for the passing test.

=== RUN   TestHello
    hello_test.go:14: hello("") success, expected Hello Dude!, got Hello Dude!
    hello_test.go:23: hello("Mike") success, expected Hello Mike!, got Hello Mike!
--- PASS: TestHello (0.00s)
PASS
ok      greeting        0.006s

Seeing A Failed Test

Let’s say we accidentally change the hello function and remove the punctuation for normal names.

func hello(user string) string {
	if len(user) == 0 {
		return "Hello Dude!"
	}

	return fmt.Sprintf("Hello %v", user)
}

Now split the checks into two tests so the failure is easier to read.

// hello_test.go
package main

import "testing"

// TestHelloEmptyArg tests hello with an empty argument.
func TestHelloEmptyArg(t *testing.T) {
	emptyResult := hello("")

	if emptyResult != "Hello Dude!" {
		t.Errorf("hello(\"\") failed, expected %v, got %v", "Hello Dude!", emptyResult)
	} else {
		t.Logf("hello(\"\") success, expected %v, got %v", "Hello Dude!", emptyResult)
	}
}

// TestHelloValidArg tests hello with a valid argument.
func TestHelloValidArg(t *testing.T) {
	result := hello("Mike")

	if result != "Hello Mike!" {
		t.Errorf("hello(\"Mike\") failed, expected %v, got %v", "Hello Mike!", result)
	} else {
		t.Logf("hello(\"Mike\") success, expected %v, got %v", "Hello Mike!", result)
	}
}

Running go test -v now reports the failed case.

=== RUN   TestHelloEmptyArg
--- PASS: TestHelloEmptyArg (0.00s)
=== RUN   TestHelloValidArg
    hello_test.go:24: hello("Mike") failed, expected Hello Mike!, got Hello Mike
--- FAIL: TestHelloValidArg (0.00s)
FAIL
exit status 1
FAIL    greeting        0.006s

This is the main advantage of small focused tests. The failure tells us exactly which behavior broke.

Running Selected Tests

If a package has many tests, we can run only the tests whose names match a pattern.

go test -v -run TestHelloEmptyArg
=== RUN   TestHelloEmptyArg
--- PASS: TestHelloEmptyArg (0.00s)
PASS
ok      greeting        0.007s

We can also tell Go exactly which files to compile, but then we must include the source files that the test depends on.

go test -v hello_test.go hello.go
=== RUN   TestHelloEmptyArg
--- PASS: TestHelloEmptyArg (0.00s)
=== RUN   TestHelloValidArg
    hello_test.go:24: hello("Mike") failed, expected Hello Mike!, got Hello Mike
--- FAIL: TestHelloValidArg (0.00s)
FAIL
FAIL    command-line-arguments  0.012s

In normal projects, prefer testing packages instead of listing files manually.

A Note About go run

When test files are present in an executable package, avoid go run *.go. The *.go pattern also matches _test.go files, and go run cannot run test files.

Use one of these instead:

go run .
go run ./cmd/myapp
go build
go install

Test Coverage

Test coverage tells us how much of the code was executed while the test suite was running.

Let’s turn the greeting code into a non-executable package and test only one branch.

// hello.go
package greeting

import "fmt"

// Hello returns a greeting message for the provided user.
func Hello(user string) string {
	if len(user) == 0 {
		return "Hello Dude!"
	}

	return fmt.Sprintf("Hello %v!", user)
}
// hello_test.go
package greeting

import "testing"

// TestHello tests the Hello function.
func TestHello(t *testing.T) {
	result := Hello("Mike")

	if result != "Hello Mike!" {
		t.Errorf("Hello(\"Mike\") failed, expected %v, got %v", "Hello Mike!", result)
	}
}

Run the test with coverage:

go test -cover
PASS
coverage: 66.7% of statements
ok      greeting        0.007s

The test passed, but coverage is only 66.7% because we did not test the empty-string branch.

For a more detailed report, write coverage data to a profile file.

go test -coverprofile cover.txt

Then generate an HTML report from that profile.

go tool cover -html cover.txt -o cover.html

Open cover.html in a browser and Go will highlight which lines were covered and which lines were missed.

Working With Tests In Go Modules

So far, we tested a simple package. In real projects, we usually work inside Go modules.

Imagine a module named nummanip with a package named calc.

// calc/math.go
package calc

import (
	"errors"

	"github.com/fatih/color"
)

// Add returns the sum of two or more integers.
func Add(numbers ...int) (error, int) {
	sum := 0

	if len(numbers) < 2 {
		errorMessage := color.RedString("provide more than 2 numbers")
		return errors.New(errorMessage), sum
	}

	for _, num := range numbers {
		sum += num
	}

	return nil, sum
}

The first test can be simple.

// calc/math_test.go
package calc

import "testing"

// TestMathAdd tests the Add function.
func TestMathAdd(t *testing.T) {
	result := Add(1, 2, 3)

	if result != 6 {
		t.Errorf("Add(1, 2, 3) failed, expected %v but got value %v", 6, result)
	} else {
		t.Logf("Add(1, 2, 3) passed, expected %v and got value %v", 6, result)
	}
}

If the package is inside a module, we can test it from the module root with a relative package path.

go test ./calc -v
=== RUN   TestMathAdd
--- PASS: TestMathAdd (0.00s)
PASS
ok      github.com/thatisuday/nummanip/v2/calc  0.006s

The Multiple Inputs Problem

A unit is more reliable when we test it with more than one input. The common Go approach is to use a table-driven test.

// calc/math_test.go
package calc

import "testing"

// TestDataItem describes one Add test case.
type TestDataItem struct {
	inputs   []int
	result   int
	hasError bool
}

// TestMathAdd tests the Add function.
func TestMathAdd(t *testing.T) {
	dataItems := []TestDataItem{
		{[]int{1, 2, 3}, 6, false},
		{[]int{99, 99}, 198, false},
		{[]int{1, 1, 5, 6}, 13, false},
		{[]int{1}, 0, true},
	}

	for _, item := range dataItems {
		err, result := Add(item.inputs...)

		if item.hasError {
			if err == nil {
				t.Errorf("Add() with args %v: failed, expected an error but got value %v", item.inputs, result)
			} else {
				t.Logf("Add() with args %v: passed, expected an error and got an error %v", item.inputs, err)
			}
		} else {
			if result != item.result {
				t.Errorf("Add() with args %v: failed, expected %v but got value %v", item.inputs, item.result, result)
			} else {
				t.Logf("Add() with args %v: passed, expected %v and got value %v", item.inputs, item.result, result)
			}
		}
	}
}

Run it the same way:

go test ./calc -v
=== RUN   TestMathAdd
    math_test.go:38: Add() with args [1 2 3]: passed, expected 6 and got value 6
    math_test.go:38: Add() with args [99 99]: passed, expected 198 and got value 198
    math_test.go:38: Add() with args [1 1 5 6]: passed, expected 13 and got value 13
    math_test.go:31: Add() with args [1]: passed, expected an error and got an error provide more than 2 numbers
--- PASS: TestMathAdd (0.00s)
PASS
ok      github.com/thatisuday/nummanip/v2/calc  0.006s

Table-driven tests are one of the most common testing patterns in Go. They keep the test logic in one place while letting us add more input cases easily.

Testing Multiple Packages

A module can contain multiple packages. Let’s add a transform package.

// transform/square.go
package transform

// SquareSlice returns a new slice containing the square of each input number.
func SquareSlice(s []int) []int {
	squares := make([]int, len(s))

	for index, value := range s {
		squares[index] = value * value
	}

	return squares
}
// transform/square_test.go
package transform

import (
	"reflect"
	"testing"
)

// TestTransformSquare tests the SquareSlice function.
func TestTransformSquare(t *testing.T) {
	testSlice := []int{1, 2, 3, 4, 5}
	expectedResult := []int{1, 4, 9, 16, 25}

	result := SquareSlice(testSlice)

	if reflect.DeepEqual(expectedResult, result) {
		t.Log("SquareSlice passed")
	} else {
		t.Errorf("SquareSlice failed, expected %v but got %v", expectedResult, result)
	}
}

To test one package:

go test ./transform -v

To test every package in the module:

go test ./... -v -cover
=== RUN   TestMathAdd
--- PASS: TestMathAdd (0.00s)
PASS
coverage: 100.0% of statements
ok      github.com/thatisuday/nummanip/v2/calc       0.007s
=== RUN   TestTransformSquare
--- PASS: TestTransformSquare (0.00s)
PASS
coverage: 100.0% of statements
ok      github.com/thatisuday/nummanip/v2/transform  0.006s

The ./... pattern tells Go to test all packages below the current directory.

Test Caching

When Go runs tests in package list mode, it caches successful test results. That is why you may sometimes see (cached) instead of a fresh execution time.

Package list mode is used by commands such as:

  • go test .
  • go test ./calc
  • go test ./...

If you want to force Go to run the test again, use -count=1.

go test ./... -count=1

You can also clear the test cache.

go clean -testcache

Separating Package Tests

By default, a test file usually uses the same package name as the source file. That means the test can access both exported and unexported identifiers.

Sometimes we want to test the package like an external user would. For that, use a _test package name and import the package being tested.

// transform/square_test.go
package transform_test

import (
	"reflect"
	"testing"

	"github.com/thatisuday/nummanip/v2/transform"
)

// TestTransformSquare tests the exported SquareSlice function.
func TestTransformSquare(t *testing.T) {
	testSlice := []int{1, 2, 3, 4, 5}
	expectedResult := []int{1, 4, 9, 16, 25}

	result := transform.SquareSlice(testSlice)

	if reflect.DeepEqual(expectedResult, result) {
		t.Log("SquareSlice passed")
	} else {
		t.Errorf("SquareSlice failed, expected %v but got %v", expectedResult, result)
	}
}

This test can only use exported identifiers, such as SquareSlice. If the function were named squareSlice, the external test package could not access it.

Test Data

If a package needs fixture files, put them inside a directory named testdata. The Go tool ignores testdata when building or running normal packages, but tests can still read files from it.

// some_test.go
package report

import (
	"os"
	"testing"
)

func TestReadFixture(t *testing.T) {
	file, err := os.Open("./testdata/file.csv")
	if err != nil {
		t.Fatal(err)
	}
	defer file.Close()

	// Use file in the test.
}

This keeps test fixtures close to the tests that need them.

Using Assertions

If you are familiar with tests in Node.js, you may have used assertion libraries such as chai or Node’s built-in assert. Go does not provide a built-in assertion package.

The Go approach is to write normal conditional logic and call t.Error, t.Errorf, t.Fatal, or t.Fatalf when something is wrong. It is explicit, boring, and easy to debug.

That said, if you prefer assertion helpers, testify is a popular option.

Testing in Go is simple once you understand the shape: write _test.go files, create TestXxx functions, run go test, and let the standard toolchain do the rest. As your package grows, table-driven tests, coverage profiles, external test packages, and testdata help keep the test suite practical.

#golang #testing