Advanced

Generics, Context & Testing

Three things you'll reach for constantly in real Go codebases, even though none of them come up in a first "hello world." Every claim here was checked against a real Go 1.26 toolchain and the official documentation before being written down.

Generics Context Testing Interview Questions

Generics

Since Go 1.18, a function or type can take type parameters, written in square brackets, letting one definition work across multiple types safely (checked at compile time) instead of writing near-duplicate functions per type or falling back to interface{} and runtime type assertions.

T = ?
Max(a, b)
?
Pick a type to call Max with.
package main

import "fmt"

func Max[T int | float64](a, b T) T {
	if a > b {
		return a
	}
	return b
}

func main() {
	fmt.Println(Max(3, 7))
	fmt.Println(Max(2.5, 1.1))
}
7 2.5

[T int | float64] is a type constraint: T can be instantiated as int or float64, and the compiler checks every call. Two predeclared constraints come up constantly: any (an alias for interface{} — any type at all) and comparable (any type that supports == and !=, which is exactly what's required for, say, a generic function that uses its arguments as map keys).

Generic types: beyond functions

Type parameters work on types too, not just functions. This lets you write data structures once and use them with any element type — no interface{}, no type assertions, full compile-time safety:

package main

import "fmt"

type Stack[T any] struct {
	items []T
}

func (s *Stack[T]) Push(v T) {
	s.items = append(s.items, v)
}

func (s *Stack[T]) Pop() (T, bool) {
	if len(s.items) == 0 {
		var zero T
		return zero, false
	}
	v := s.items[len(s.items)-1]
	s.items = s.items[:len(s.items)-1]
	return v, true
}

func main() {
	var ints Stack[int]
	ints.Push(10)
	ints.Push(20)
	v, ok := ints.Pop()
	fmt.Println(v, ok)

	var strs Stack[string]
	strs.Push("hello")
	strs.Push("world")
	s, ok := strs.Pop()
	fmt.Println(s, ok)
}
20 true world true
The zero-value return pattern

When Pop finds an empty stack, it needs to return something for the T return value. The idiom var zero T returns the zero value of whatever type T is — 0 for int, "" for string, nil for pointers, etc. The caller checks the bool to know if the value is meaningful.

Custom constraints with ~

You can define your own constraints as interfaces. The ~ operator means "any type whose underlying type is this" — so ~int matches int and any custom type defined as type MyInt int:

package main

import "fmt"

type Signed interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64
}

type MyInt int

func Abs[T Signed](n T) T {
	if n < 0 {
		return -n
	}
	return n
}

func main() {
	fmt.Println(Abs(-7))
	fmt.Println(Abs(MyInt(-42)))
}
7 42
~int vs int in constraints

Without ~, a constraint int | string only matches the exact types int and string. With ~int, it also matches type Miles int or type UserID int — any type whose underlying representation is int. Use ~ when you want to accept custom types that are based on a primitive, which is most of the time in real code.

comparable in practice

The built-in comparable constraint accepts any type that supports == and != — which includes all basic types, structs (if all fields are comparable), and pointers, but not slices, maps, or functions. This is exactly what you need for a generic Contains function:

package main

import "fmt"

func Contains[T comparable](s []T, target T) bool {
	for _, v := range s {
		if v == target {
			return true
		}
	}
	return false
}

func main() {
	fmt.Println(Contains([]int{1, 2, 3}, 2))
	fmt.Println(Contains([]string{"a", "b"}, "c"))
}
true false

Context

context.Context carries cancellation signals (and deadlines, and request-scoped values) across API boundaries and between goroutines. WithCancel, WithTimeout, and WithDeadline each derive a new context from a parent — and cancelling a parent cancels everything derived from it, all the way down.

ctx (parent, WithTimeout)
derived ctx A
derived ctx B
Click to cancel the parent and watch cancellation propagate down.
package main

import (
	"context"
	"fmt"
	"time"
)

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel()

	select {
	case <-time.After(300 * time.Millisecond):
		fmt.Println("work finished")
	case <-ctx.Done():
		fmt.Println("cancelled:", ctx.Err())
	}
}
cancelled: context deadline exceeded

The simulated work takes 300ms, but the context's timeout fires at 100ms, so ctx.Done() is the case that's ready first. ctx.Err() then reports exactly why: the exported sentinel error context.DeadlineExceeded, whose message is the "context deadline exceeded" text printed above. Always call the cancel function returned alongside a context (typically via defer) even when it isn't the reason you stopped — it releases resources associated with the context immediately instead of waiting for the deadline.

WithCancel: explicit manual cancellation

WithTimeout and WithDeadline cancel automatically after a duration. WithCancel gives you a cancel function you call yourself — useful when cancellation depends on program logic rather than a timer:

package main

import (
	"context"
	"fmt"
	"sync"
)

func worker(ctx context.Context, wg *sync.WaitGroup) {
	defer wg.Done()
	<-ctx.Done()
	fmt.Println("worker stopped:", ctx.Err())
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	var wg sync.WaitGroup

	wg.Add(1)
	go worker(ctx, &wg)

	cancel() // signal the worker to stop
	wg.Wait()
}
worker stopped: context canceled
context.Canceled vs context.DeadlineExceeded

ctx.Err() returns one of two sentinel errors: context.Canceled when you call cancel() manually, or context.DeadlineExceeded when the deadline/timeout fires. Both close the Done() channel, but callers often need to distinguish why work stopped. Use errors.Is(ctx.Err(), context.Canceled) to check.

WithValue: request-scoped data

WithValue attaches a key-value pair to a context, retrievable with ctx.Value(key). This is Go's sanctioned way to pass request-scoped data (request IDs, authentication info, tracing spans) across API boundaries without threading extra parameters through every function call:

package main

import (
	"context"
	"fmt"
)

// Unexported key type prevents collisions with other packages
type ctxKey struct{}

func WithRequestID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, ctxKey{}, id)
}

func GetRequestID(ctx context.Context) string {
	v, _ := ctx.Value(ctxKey{}).(string)
	return v
}

func main() {
	ctx := WithRequestID(context.Background(), "abc-123")
	fmt.Println("request ID:", GetRequestID(ctx))
}
request ID: abc-123
Context values are not for optional parameters

A common misuse is stuffing function arguments into context to avoid changing a function signature. Context values are for cross-cutting concerns that flow through many layers automatically — request IDs, auth tokens, tracing spans. If a function logically needs a parameter to do its job, pass it as a normal argument, not through context.

Background() vs TODO()

Both return an empty, cancelable context with no values and no deadline. The difference is semantic, not functional: context.Background() is for the top-level entry point of your program (main, init, tests), while context.TODO() is a placeholder meaning "I haven't decided which context to use here yet." Use TODO() when you're refactoring and haven't wired up the context chain yet — it makes the incomplete work greppable.

Testing

Go's testing story is built into the toolchain: any file named *_test.go is excluded from normal builds but picked up by go test. A test is just a function named TestXxx taking a *testing.T. The most common pattern for testing several inputs is a table-driven test: a slice of cases looped over, each run as its own named subtest.

math.go
package mathutil

func Add(a, b int) int { return a + b }
math_test.go
package mathutil

import "testing"

func TestAdd(t *testing.T) {
	cases := []struct{ a, b, want int }{
		{2, 3, 5},
		{-1, 1, 0},
		{0, 0, 0},
	}
	for _, c := range cases {
		got := Add(c.a, c.b)
		if got != c.want {
			t.Errorf("Add(%d, %d) = %d, want %d", c.a, c.b, got, c.want)
		}
	}
}
$ go test -v ./mathutil/ === RUN TestAdd --- PASS: TestAdd (0.00s) PASS ok example.com/goverify/mathutil 0.003s
Why no Playground link here

The Go Playground runs a single-file package main program (or, without a main, a single file of tests) — it isn't a natural fit for a two-file package like this one. The output above is real, captured by actually running go test -v against these exact two files, not simulated.

Each entry in cases is a complete, independent test case; adding another input to check means adding another line to the slice, not another copy-pasted test function. For tests large enough to want isolated pass/fail reporting per case, wrap the loop body in t.Run(name, func(t *testing.T) {...}) to get named subtests.

Subtests with t.Run

The table-driven test above reports a single pass/fail for TestAdd. If one case fails, you have to read the error message to figure out which one. Wrapping each case in t.Run gives named subtests with isolated pass/fail reporting, and lets you run individual cases with -run TestAdd/positive:

math_test.go
package mathutil

import "testing"

func TestAdd(t *testing.T) {
	cases := []struct{
		name      string
		a, b, want int
	}{
		{"positive", 2, 3, 5},
		{"negative", -1, 1, 0},
		{"zeros", 0, 0, 0},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got := Add(c.a, c.b)
			if got != c.want {
				t.Errorf("Add(%d, %d) = %d, want %d", c.a, c.b, got, c.want)
			}
		})
	}
}
$ go test -v ./mathutil/ === RUN TestAdd === RUN TestAdd/positive === RUN TestAdd/negative === RUN TestAdd/zeros --- PASS: TestAdd (0.00s) --- PASS: TestAdd/positive (0.00s) --- PASS: TestAdd/negative (0.00s) --- PASS: TestAdd/zeros (0.00s) PASS ok example.com/goverify/mathutil 0.003s
t.Helper(): correct line numbers in failures

When a test calls a helper function and the helper calls t.Errorf, the error line points to the helper, not the actual test. Calling t.Helper() at the start of the helper function tells Go to skip it in the error report and point to the caller instead. Always add it to functions that are called from tests but aren't tests themselves.

Benchmarks: measuring performance

A function named BenchmarkXxx taking a *testing.B lets Go measure how fast your code runs. The testing framework calls your function repeatedly, adjusting b.N until the results are stable:

bench_test.go
package mathutil

import "testing"

func BenchmarkAdd(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Add(2, 3)
	}
}
$ go test -bench=. -benchmem ./mathutil/ goos: linux goarch: amd64 pkg: example.com/goverify/mathutil cpu: 12th Gen Intel(R) Core(TM) i7-1255U BenchmarkAdd-12 1000000000 0.1416 ns/op 0 B/op 0 allocs/op PASS ok example.com/goverify/mathutil 0.159s
Reading benchmark output

-12 after the benchmark name is GOMAXPROCS. The three key metrics: ns/op (nanoseconds per operation — lower is faster), B/op (bytes allocated per operation), and allocs/op (number of allocations per operation). For this trivial function, there are zero allocations — the compiler inlines it entirely.

Fuzzing: finding edge cases automatically

Go's built-in fuzzing (Go 1.18+) generates random inputs and feeds them to your test, looking for inputs that cause failures. A fuzz function takes a *testing.F and calls f.Fuzz with a function that receives random values of the specified types:

fuzz_test.go
package mathutil

import "testing"

func IsPalindrome(s string) bool {
	for i := 0; i < len(s)/2; i++ {
		if s[i] != s[len(s)-1-i] {
			return false
		}
	}
	return true
}

func ReverseBytes(s string) string {
	b := []byte(s)
	for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
		b[i], b[j] = b[j], b[i]
	}
	return string(b)
}

func FuzzIsPalindrome(f *testing.F) {
	f.Add("racecar")
	f.Add("hello")
	f.Add("")
	f.Fuzz(func(t *testing.T, s string) {
		result := IsPalindrome(s)
		reversed := IsPalindrome(ReverseBytes(s))
		if result != reversed {
			t.Errorf("mismatch: IsPalindrome(%q)=%v, IsPalindrome(reverse)=%v",
				s, result, reversed)
		}
	})
}
$ go test -fuzz=FuzzIsPalindrome -fuzztime=2s ./mathutil/ fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed fuzz: elapsed: 0s, now fuzzing with 12 workers fuzz: elapsed: 2s, execs: 1062182 (505047/sec), new interesting: 7 (total: 11) PASS ok example.com/goverify/mathutil 2.106s
How fuzzing works

f.Add("racecar") provides seed inputs — the fuzzer starts from these and mutates them (flipping bits, adding/removing bytes, crossing inputs) to maximize code coverage. When a failure is found, the fuzzer minimizes it to the shortest input that still fails and writes it to testdata/fuzz/FuzzXxx/ — that file becomes a permanent regression test that runs with every go test even without -fuzz.

Interview Questions

Common interview questions about generics, context, and testing — organized by experience level. Click a tab to filter.

Q1. What are generics in Go, and when were they added to the language?

Generics let you write functions and types that operate on a type parameter instead of a concrete type, so the same code works for int, string, or any type satisfying a constraint. They were added in Go 1.18 (2022) and have been stable ever since — no experimental flags are needed in Go 1.26.

Q2. How do you declare a generic function? Show the syntax for a function that works on any slice element type.

Type parameters go in square brackets before the regular parameter list, e.g. func Map[T, U any](s []T, f func(T) U) []U. Here T and U are type parameters constrained by any, meaning they can be any type.

Q3. What is a constraint in Go generics?

A constraint is an interface that specifies which types are allowed to be substituted for a type parameter, and which operations (methods, operators) are permitted on values of that type parameter. It's written where you'd normally see a type, e.g. [T Ordered].

Q4. What does the comparable constraint mean, and when do you need it?

comparable is a built-in constraint that permits only types supporting == and !=. You need it when a generic function or type uses a type parameter as a map key or compares two values of that type directly, e.g. func Contains[T comparable](s []T, v T) bool.

Q5. What is context.Context used for in Go?

context.Context carries a cancellation signal, an optional deadline, and optional request-scoped values across API boundaries and between goroutines. It's the standard way to tell downstream work "stop, the caller no longer needs this."

Q6. How do you create a context that can be cancelled manually?

ctx, cancel := context.WithCancel(parent) returns a derived context and a cancel func(). Calling cancel() closes the context's Done() channel, signaling cancellation to anything that received ctx.

Q7. What does a context's Done() method return, and how is it typically used?

Done() returns a <-chan struct{} that is closed when the context is cancelled or its deadline expires. Code typically selects on it alongside other channels, e.g. select { case <-ctx.Done(): return ctx.Err() case v := <-work: ... }, to exit early.

Q8. How do you write a basic test in Go — file naming and function signature?

Tests live in a file ending in _test.go in the same package, and each test is a function named TestXxx(t *testing.T) where Xxx starts with a capital letter. Failures are reported with t.Error/t.Errorf (continues) or t.Fatal/t.Fatalf (stops the test), and you run them with go test.

Q9. What is a table-driven test?

A table-driven test defines a slice of struct literals, each holding an input and its expected output, then loops over the slice running the same assertion logic for every case. It keeps many similar test cases concise and makes adding a new case a one-line change instead of a new function.

← Back to the roadmap