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 ./calcgo 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.