Getting Started with Go, Part 4: Testing & Generics
The worker pool from Part 3 is genuinely concurrent, which means it's genuinely capable of hiding a bug in a way a single-threaded loop can't. This part is about making sure it actually does what it claims — with tests — and then about generics, which arrived in Go 1.18 and are still the most over-applied feature in the language when people meet them for the first time.
Testing is a standard library feature, not a framework choice
Go doesn't have a testing framework in the sense other languages do — testing is a standard library package, go test is a subcommand of the toolchain, and a test is just a function named TestXxx taking *testing.T:
func TestClassify(t *testing.T) {
if got := classify("urgent-deploy"); got != "HIGH" {
t.Errorf("classify() = %q, want %q", got, "HIGH")
}
}
t.Errorf records a failure and keeps the test running (so you see every failure, not just the first); t.Fatalf records a failure and stops immediately, for when continuing wouldn't make sense (a nil you're about to dereference, say).
Table-driven tests
The idiom nearly every nontrivial Go test converges on: a slice of input/expected-output pairs, looped over once. It reads like a spec, and adding a new case is a one-line addition instead of a new test function:
func TestClassify(t *testing.T) {
cases := []struct {
name string
want string
}{
{"urgent-deploy", "HIGH"},
{"batch-report", "LOW"},
{"cleanup", "NORMAL"},
}
for _, tc := range cases {
if got := classify(tc.name); got != tc.want {
t.Errorf("classify(%q) = %q, want %q", tc.name, got, tc.want)
}
}
}
This one's a complete, runnable file on its own — there's no func main here at all, which is deliberate: hit Run and it executes as go test, exactly like it would on your machine, and you'll see the real PASS/FAIL output:
package main
import (
"strings"
"testing"
)
func classify(name string) string {
switch {
case strings.HasPrefix(name, "urgent-"):
return "HIGH"
case strings.HasPrefix(name, "batch-"):
return "LOW"
default:
return "NORMAL"
}
}
func TestClassify(t *testing.T) {
cases := []struct {
name string
want string
}{
{"urgent-deploy", "HIGH"},
{"batch-report", "LOW"},
{"cleanup", "NORMAL"},
}
for _, tc := range cases {
if got := classify(tc.name); got != tc.want {
t.Errorf("classify(%q) = %q, want %q", tc.name, got, tc.want)
}
}
}
For the worker pool from Part 3, the same shape applies to correctness (does every job appear exactly once in the results, regardless of which worker handled it), just with a slightly less trivial assertion than string equality.
Benchmarks
Benchmarks live next to tests, follow the same go test toolchain, and use a very similar shape — a BenchmarkXxx(b *testing.B) function that runs its body b.N times, with the toolchain figuring out how large N needs to be for a stable measurement:
func BenchmarkClassify(b *testing.B) {
for i := 0; i < b.N; i++ {
classify("urgent-deploy")
}
}
Run with go test -bench=. — benchmark timing depends on the machine it runs on, so this one isn't part of the series' runnable examples (a shared sandbox has no stable notion of "how fast is this machine"), but the shape is exactly this simple everywhere you run it.
Generics: what problem they actually solve
Before Go 1.18, a generic container meant either duplicating code per type or falling back to interface{} and losing type safety at the call site. Generics close that specific gap — nothing more:
type Queue[T any] struct {
items []T
}
func (q *Queue[T]) Push(item T) {
q.items = append(q.items, item)
}
func (q *Queue[T]) Pop() (T, bool) {
var zero T
if len(q.items) == 0 {
return zero, false
}
item := q.items[0]
q.items = q.items[1:]
return item, true
}
[T any] declares a type parameter — any here is just an alias for interface{}, meaning "accept literally any type." A Queue[Job] and a Queue[string] are both fully type-checked at compile time; Pop() on a Queue[Job] returns a Job, not an interface{} you'd have to type-assert back.
Here it is holding Job values from earlier parts of the series — complete and runnable:
package main
import "fmt"
type Queue[T any] struct {
items []T
}
func (q *Queue[T]) Push(item T) {
q.items = append(q.items, item)
}
func (q *Queue[T]) Pop() (T, bool) {
var zero T
if len(q.items) == 0 {
return zero, false
}
item := q.items[0]
q.items = q.items[1:]
return item, true
}
type Job struct {
Name string
Priority string
}
func main() {
queue := &Queue[Job]{}
queue.Push(Job{Name: "urgent-deploy", Priority: "HIGH"})
queue.Push(Job{Name: "cleanup", Priority: "LOW"})
for {
job, ok := queue.Pop()
if !ok {
break
}
fmt.Printf("dequeued %s (priority=%s)\n", job.Name, job.Priority)
}
}
When generics are the wrong tool
The failure mode isn't using generics — it's reaching for a type parameter where an interface already solved the problem. Part 2's Processor interface didn't need to become Processor[T]; the whole point of that interface is that different concrete types (a flaky one, a logging one, eventually a concurrent one) all satisfy the same behavior contract. Generics are for when the shape of the code is identical across types and only the type itself varies — a queue, a stack, a Map/Filter helper. If you're writing the same logic against different types, that's generics. If you're writing different logic behind the same interface, that's, well, an interface — which is exactly what Part 2 already gave us, and exactly why it didn't need to change here.
Further Reading
- Go blog: An Introduction To Generics
- Go blog: When To Use Generics — the "shape vs. behavior" distinction, argued properly
- Testing package documentation