Jakub Jarosz

Security, Systems & Network Automation

Go Testing: How to Communicate Clearly

2025-04-30 Go

In the Don’t Overload Your Brain - Write Simple Go, we refactor the cars package. We simplify functions, improve readability and reduce cognitive load.

The changes have one simple goal - optimize code for mental digestion. The logic must be clear as a road sign: turn left, turn right, no entry. One glance at a line, and you know what’s going on.

Optimize for glanceability. - Mat Ryer

Let’s roll up our sleeves, sharpen the scalpel and start the second code surgery. This time, we focus on tests in the cars_test.go.

Focus on behaviour, not functions

A good starting point for designing a new package is to ask a few questions:

  • What problems the package is supposed to solve?
  • What functionality the package will provide?
  • How will the data flow to and out of the package?
  • What is the public package API going to look like?

In Go, we build packages - fundamental Lego-like blocks that we assemble in modules and use to build applications. That’s why we must start designing packages by modelling their public APIs.

We write test code in the package cars_test not in cars.

cars_test.go

package cars_test

Listing 1

This forces you to import the cars package and use its exported identifiers - package public API. The testing package documentation refers to this as black box testing.

cars_test.go

package cars_test

import (
	"testing"
	"github.com/qba73/cars"
)

Listing 2

What could be a better way to focus on DevX or DX (Developer Experience) and UX (User Experience) if not using the API you and your teammates build and use every day?

A good API is not just easy to use but also hard to misuse. - Jaana Dogan (JBD)

Run tests in parallel from the beginning

Sometimes we can’t run tests in parallel. But most of the time we should do it. Why? No, it’s not about saving a second or two. It’s about catching potential race conditions in the code.

For example, you may have a global variable of type map that acts as a cache. All seems fine until someone starts using the package in the concurent way. Tracing illusive bugs originating from such mistakes is hard. This category of bad practices CWE website (Common Weakness Enumeration) classifies as concurrency issues. They can be a source of serious security problems.

That’s why your first line of defence is to add t.Parallel() to tests and make concurrency issues easier to detect early and, for example, to synchronize concurrent access to the map with a mutex. This particular example deserves a separate article. We will scrutinize this case another time when discussing CWE-820: Missing Synchronization issue and consequences.

The point is to shift the mindset and design software and packages with concurrency and parallelism in mind.

Let’s parallelize all tests in the cars package.

cars_test.go

func TestNeedsLicense(t *testing.T) {
	t.Parallel()
	// test logic
}

Listing 3

The t.Parallel() marks the test function TestNeedsLicense to run in parallel with other tests. Just remember to run tests with the -race flag. It enables race detection.

go test -race

We can parallelize test execution even more and add t.Parallel() to the method that wraps tested function and test logic - t.Run().

cars_test.go

var tests = []struct{
	// test inputs
}

func TestNeedsLicense(t *testing.T) {
	t.Parallel()

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			t.Parallel()

			got := cars.NeedsLicense(test.kind)
			if !cmp.Equal(test.want, got) {
				t.Error(cmp.Diff(test.want, got))
			}
		})
	}
}

Listing 4

Got and Want

Naming things is hard. It’s one of the main problems in computer science.

The most important thing in the programming language is the name. - Donald Knuth

We all know this. But Go has a clear guideline for naming variables representing a value that the function under test returns and the value we want.

Keep it short. Words got and want describe in plain, the simplest possible English our intent.

cars_test.go

t.Run(test.name, func(t *testing.T) {
	t.Parallel()
		got := cars.NeedsLicense(test.kind)
		if !cmp.Equal(test.want, got) {
			t.Error(cmp.Diff(test.want, got))
		}
	})

Listing 5

You Can’t Always Get What You Want. - The Rolling Stones

Define vars and constants close to functions

We read the code from top to bottom. Sure, we can jump between functions, type definitions and even files. Sometimes, when it makes sense, we group constants, variables or custom errors in logically organized blocks.

In the cars_test.go the variable floatEqualityThreshold is defined at the beginning of the file but used at the end in the helper function. This layout forces us to scroll and search for definitions. It breaks the reading flow.

cars_test.go

var floatEqualityThreshold = 1e-5

// 200+ lines of code

func floatingPointEquals(got, want float64) bool {
	absoluteDifferenceBelowThreshold := math.Abs(got-want) <= floatEqualityThreshold
	relativeDifferenceBelowThreshold := math.Abs(got-want)/(math.Abs(got)+math.Abs(want)) <= floatEqualityThreshold
	return absoluteDifferenceBelowThreshold || relativeDifferenceBelowThreshold
}

Listing 6

Imagine you read a book, and the author forces you to jump between many pages just to read the footnotes. After a while, the flow is gone. So, let’s be polite to readers and keep correlated peace of information together.

We can declare the variable floatEqualityThreshold right before the function floatingPointEquals. This way, you glance at a couple of lines and understand the intent.

cars_test.go

var floatEqualityThreshold = 1e-5

func floatingPointEquals(got, want float64) bool {
	absoluteDifferenceBelowThreshold := math.Abs(got-want) <= floatEqualityThreshold
	relativeDifferenceBelowThreshold := math.Abs(got-want)/(math.Abs(got)+math.Abs(want)) <= floatEqualityThreshold
	return absoluteDifferenceBelowThreshold || relativeDifferenceBelowThreshold
}

Use go-cmp package

Enough to say. Just use it. It’s perfect for tests that compare full stucts or/and JSONs. The larger and more complicated data inputs and outputs the better use case for the cmp package. It’s all we need to see the formatted diff between what we got and what we want.

Besides printing diffs in pleasant for eyes and brain format, it has another hidden gem - support for options.

Forget about writing custom helpers

Don’t reinvent the wheel. Don’t write custom helpers to compare values. We add one function here, and a few functions there, and soon, we clutter the code with common helpers written in different styles.

cmp gives us a unified way to use custom comparers. Yes, we still write the logic, but using the comparers in our tests aligns with the one pattern across the project.

Here, the cars package uses a custom test helper to compare numbers of type float64.

cars_test.go

func floatingPointEquals(got, want float64) bool {
	absoluteDifferenceBelowThreshold := math.Abs(got-want) <= floatEqualityThreshold
	relativeDifferenceBelowThreshold := math.Abs(got-want)/(math.Abs(got)+math.Abs(want)) <= floatEqualityThreshold
	return absoluteDifferenceBelowThreshold || relativeDifferenceBelowThreshold
}

Listing 7

Then, it uses the helper in tests.

cars_test.go

if !floatingPointEquals(got, test.want) {
	// test implementation
}

Listing 8

Bring cmp power - use options

Let’s re-write the helper and use it as a Comparer type.

First, we declare a variable opt (option) of the type Comparer. The opt takes a func that runs our custom business logic - compares two inputs x and y and returns a value of type bool.

cars_test.go

var opt = cmp.Comparer(func(x, y float64) bool {
	delta := math.Abs(x - y)
	mean := math.Abs(x+y) / 2.0
	return delta/mean < 1e-5 // defined precision
})

Listing 9

Once we have the Comparer ready, we plug it into our tests.

cars_test.go

for _, test := range tests {
	t.Run(test.name, func(t *testing.T) {
		got := CalculateResellPrice(test.originalPrice, test.age)
		if !cmp.Equal(test.want, got, opt) {
			t.Error(cmp.Diff(test.want, got))
		}
	})
}

Listing 10

The familiar !cmp.Equal(…) pattern stays the same. We add want, got and our new opt variable.

Notice that the function accepts N* number of options. It uses a Go idiom - the functional option pattern.

Set precision when comparing float variables

Since the precision for comparing float64 numbers is unlikely to change, we may skip creating the variable. We only use the precision value inside the Comparer. Chance, we simply use the literal and add a comment. It’s more of personal taste.

cars_test.go

var opt = cmp.Comparer(func(x, y float64) bool {
	delta := math.Abs(x - y)
	mean := math.Abs(x+y) / 2.0
	return delta/mean < 1e-5 // defined precision
})

As in Don’t Overload Your Brain - Write Simple Go, where we refactor the cars package, we make changes in cars_test keeping readability, simplicity and maintainability in mind. In the end, we read more than we write. Let’s optimize accordingly.

Instead of imagining that our main task is to instruct a computer what to do, let us concentrate rather on explaining to human beings what we want a computer to do. - Donald Knuth

KISS. It pays off.