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
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.
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))
}
[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)
}
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)))
}
~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"))
}
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.
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())
}
}
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()
}
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))
}
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.
package mathutil
func Add(a, b int) int { return a + b }
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)
}
}
}
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:
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)
}
})
}
}
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:
package mathutil
import "testing"
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}
-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:
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)
}
})
}
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.
Q1. Why does Go's generic type inference sometimes fail, forcing you to write out the type arguments explicitly?
Type inference works by unifying type parameters against the types of the function's ordinary arguments, so it can fail when a type parameter only appears in the return type, when constraint type sets don't pin down a single type, or when arguments mix types the compiler can't unify (e.g. passing both int and int32 for the same parameter). In those cases you instantiate explicitly, e.g. Map[int, string](nums, fn).
Q2. Why write a generic function with a narrow constraint instead of just using any (interface{})?
A narrow constraint (like requiring cmp.Ordered or a custom interface) lets the compiler check at compile time that the type supports the operators or methods you use, and it preserves static type information so no type assertions are needed inside the function. With any you'd lose that safety and have to do runtime type switches or reflection to do anything useful with the value.
Q3. Can a generic type have generic methods with their own type parameters?
No. A method can use the type parameters declared on its receiver's type, but it cannot introduce additional type parameters of its own — Go does not support generic methods in that sense. If you need that flexibility, you write a standalone generic function instead of a method.
Q4. Why should context.Value be used sparingly, and what shouldn't go in it?
context.Value is untyped and unenforced by the compiler, so overusing it turns function signatures into a lie — dependencies get hidden instead of passed explicitly. It's meant only for request-scoped metadata that truly cuts across API boundaries (request IDs, trace spans, auth tokens), not for passing optional parameters, config, or anything a function actually depends on to do its job.
Q5. Why do contexts form a tree, and how does cancellation propagate?
Every WithCancel/WithTimeout/WithDeadline/WithValue call derives a new child context from a parent, so a request's contexts form a tree rooted at something like context.Background(). Cancelling (or timing out) a context cancels that node and every descendant beneath it, but never propagates upward to its parent or sideways to siblings.
Q6. Why must you always call the cancel function returned by WithCancel or WithTimeout, even if the context finishes naturally?
Each derived context registers itself with its parent so the parent can cancel it later; failing to call cancel keeps that association (and any timer, for WithTimeout/WithDeadline) alive until the parent itself is cancelled, leaking memory and goroutines. The idiom is ctx, cancel := context.WithTimeout(parent, d); defer cancel(), and go vet's lostcancel check flags call sites that discard cancel without calling it.
Q7. Walk through the table-driven test pattern with subtests.
You define a slice of anonymous structs, each with a name field plus inputs and expected outputs, then loop over it calling t.Run(tc.name, func(t *testing.T) { ... }) for each case. Using t.Run gives each case its own named subtest in the output (and lets you run just one with go test -run TestX/case_name), rather than lumping all failures into one undifferentiated test.
Q8. What does t.Helper() do, and why use it?
t.Helper() marks the current function as a test helper, so when an assertion inside it fails, go test reports the line number in the caller (the actual test) instead of the line inside the helper. It's typically the first line of small reusable assert-style functions shared across tests.
Q9. How do you measure test coverage in Go?
Run go test -cover for a quick percentage, or go test -coverprofile=cover.out followed by go tool cover -html=cover.out to get an interactive HTML report showing exactly which lines were and weren't exercised. Coverage measures which statements ran, not whether the assertions were meaningful, so a high number doesn't guarantee good tests.
Q1. Explain union type constraints and the ~ (tilde) syntax, e.g. interface { ~int | ~int32 }.
A union like int | int32 means the type argument must be exactly int or exactly int32. Prefixing with ~, as in ~int, means "any type whose underlying type is int," so a named type like type UserID int also satisfies the constraint — without the tilde it wouldn't, since UserID is a distinct defined type even though its underlying type is int.
Q2. How does the performance of generic code compare to interface{}-based code, and how does the Go compiler implement instantiation?
Go's compiler uses a hybrid strategy sometimes called "GC shape stenciling": it generates a separate compiled version per distinct GC shape (roughly, per distinct pointer/memory layout) rather than one copy per concrete type, and types sharing a shape pass a runtime dictionary describing type-specific details like methods. This avoids the boxing and dynamic dispatch that interface{}-based code pays for on every call, so generics are generally faster than interface-based equivalents, though not always quite as fast as a fully monomorphized (one-copy-per-type) implementation like Rust's or C++'s.
Q3. What's a common cause of context-related goroutine or resource leaks in production code?
The classic cause is calling WithCancel/WithTimeout/WithDeadline and never invoking (or conditionally skipping) the returned cancel function on every code path, including early returns and error branches. Each uncancelled derived context keeps its parent's cancellation bookkeeping (and any associated timer) alive, and any goroutine blocked selecting on that Done() channel never wakes up, leaking both memory and goroutines until the process exits.
Q4. What's the relationship between context.WithTimeout and context.WithDeadline, and what happens if the parent's deadline is already earlier than the one you request?
WithTimeout(parent, d) is implemented as WithDeadline(parent, time.Now().Add(d)) — it's shorthand for a deadline computed from "now." If the parent context already has a deadline earlier than the one you're requesting, the child's effective deadline is the earlier of the two: the returned context is set up so it fires at the parent's (sooner) deadline, since a child can never outlive its parent's cancellation.
Q5. Why is context.Value deliberately not designed as a general-purpose way to pass optional parameters or dependencies into a function?
Go's design philosophy favors explicit dependencies visible in a function signature, which are checked by the compiler and easy to trace; context.Value is untyped (interface{} in, interface{} out), unenforced, and searched dynamically up the context tree, so misuse hides real dependencies, breaks static analysis, and invites key collisions. It exists narrowly for cross-cutting request metadata (trace IDs, deadlines-adjacent data) that has no natural place in every function's parameter list, not as a substitute for dependency injection.
Q6. How do you design Go code so it's easy to test, particularly code that depends on time, randomness, or external services?
Depend on small interfaces rather than concrete types (e.g. accept an interface with a Get(ctx, url) method instead of a concrete HTTP client) so tests can substitute fakes or mocks, and inject those dependencies through constructors or function parameters rather than reaching for globals or init()-created singletons. For time, accept a func() time.Time or a small clock interface instead of calling time.Now() directly, so tests can control it deterministically.
Q7. How does fuzzing work with go test -fuzz?
You write func FuzzXxx(f *testing.F), seed it with example inputs via f.Add(...), then call f.Fuzz(func(t *testing.T, args ...) { ... }) with the property to check. Running go test -fuzz=FuzzXxx mutates the seed corpus to search for inputs that panic or fail the property, saving any failing input under testdata/fuzz/ so it's replayed automatically on every future go test.
Q8. When would you use httptest.NewServer versus httptest.NewRecorder?
httptest.NewServer spins up a real listening HTTP server on a loopback address, useful when the code under test needs an actual URL to hit (e.g. testing an HTTP client, or an integration-style test across a real network round trip). httptest.NewRecorder is a lighter-weight http.ResponseWriter implementation you pass directly to a handler's ServeHTTP, useful for fast, in-process unit tests of a single handler without opening any sockets.
Q9. What's the tradeoff of using generics versus code generation or reflection for writing type-agnostic Go code?
Generics give compile-time type checking and near-native performance without a separate build step, whereas code generation (e.g. go generate plus a template) produces fully monomorphized, debuggable code at the cost of tooling complexity, and reflection gives ultimate flexibility at runtime but sacrifices compile-time safety and speed due to boxing and dynamic dispatch. In practice, generics now cover most of the cases people used to reach for code generation or interface{} plus reflection, such as generic containers, comparison helpers, and constrained numeric algorithms.