Lesson 4: Go Testing: Table-Driven Tests & Interface Mocks
Why Testing in Go Is Different
In Go, there are no test frameworks. No assertEqual. No describe/it blocks. No mocking libraries. Just the testing standard library package, a naming convention, and interfaces.
This minimalism is intentional. When you read a Kubernetes test, it looks exactly like every other Go test:
func TestPodScheduling(t *testing.T) {
tests := []struct {
name string
pod *corev1.Pod
expected string
}{
{"simple", makePod("test", "node1"), "node1"},
{"pending", makePod("test", ""), ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := schedule(tt.pod)
if got != tt.expected {
t.Errorf("scheduled to %q, want %q", got, tt.expected)
}
})
}
}
This pattern — the table-driven test — is the single most important Go testing convention. Master it, and you can read and write tests in any Go project.
Write idiomatic table-driven tests, mock dependencies with interfaces, use subtests and helpers, and understand how cloud-native projects test their code.
Test Basics
File Naming
Test files end in _test.go. They live in the same package as the code they test. For example, user.go's tests go in user_test.go.
Function Naming
Test functions start with Test and take *testing.T:
func TestAdd(t *testing.T) {
got := Add(2, 3)
if got != 5 {
t.Errorf("Add(2, 3) = %d; want 5", got)
}
}
Error vs Fatal
// t.Error/t.Errorf: mark test failed, keep running
if err != nil {
t.Errorf("unexpected error: %v", err)
}
// t.Fatal/t.Fatalf: mark test failed, stop immediately
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("cannot read test fixture: %v", err)
}
Use Errorf for assertion failures, Fatalf for setup failures where the rest of the test cannot proceed.
Table-Driven Tests — The Go Way
Table-driven tests: define input/output pairs in a slice, iterate with subtests. One test function, many cases, clean failures.
Instead of writing separate test functions for each case, you define a table of test cases and loop over them. Each case is a struct with a name, inputs, and expected outputs.
The Template
func TestParsePort(t *testing.T) {
tests := []struct {
name string
input string
want int
wantErr bool
}{
{"valid port", "8080", 8080, false},
{"zero port", "0", 0, true},
{"negative", "-1", 0, true},
{"not a number", "abc", 0, true},
{"max port", "65535", 65535, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParsePort(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParsePort() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("ParsePort() = %d, want %d", got, tt.want)
}
})
}
}
Why This Pattern Wins
- Adding a new case is one line — just add a struct literal to the slice
- Each case runs independently —
t.Runisolates failures, lets you filter:go test -run TestParsePort/negative - No test framework lock-in — it's plain Go code, portable forever
- Easy to review — all cases visible in one place, reviewers can check coverage at a glance
Name the slice tests, each element tt (or tc), and include a name field in every case struct. These conventions appear in every cloud-native Go project.
Mocking via Interfaces (No Framework Needed)
Remember Lesson 0001? Interfaces are satisfied implicitly. This makes mocking in Go trivial — create a test double that implements the same interface:
// Production interface
type Store interface {
Get(key string) (string, error)
Set(key, value string) error
}
// Production function that depends on Store
func GetConfig(s Store, key string) (string, error) {
val, err := s.Get(key)
if err != nil {
return "", fmt.Errorf("config %s: %w", key, err)
}
return val, nil
}
// Test: fakeStore implements Store — no declaration needed
type fakeStore struct {
data map[string]string
getCalls int
}
func (f *fakeStore) Get(key string) (string, error) {
f.getCalls++
val, ok := f.data[key]
if !ok {
return "", errors.New("not found")
}
return val, nil
}
func (f *fakeStore) Set(key, val string) error {
f.data[key] = val
return nil
}
func TestGetConfig(t *testing.T) {
store := &fakeStore{data: map[string]string{"host": "localhost"}}
val, err := GetConfig(store, "host")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != "localhost" {
t.Errorf("got %q, want localhost", val)
}
if store.getCalls != 1 {
t.Errorf("Get called %d times, want 1", store.getCalls)
}
}
fakeStore never declares it implements Store. It just has the right methods. That's implicit satisfaction making testing simple.
This is why cloud-native Go projects don't use mocking frameworks. They define interfaces and write fakes. Kubernetes has fake.NewClientBuilder() which returns a fake that implements client.Client.
Subtests and Helpers
t.Run — Run Named Subtests
t.Run(name, func) creates a named subtest. Each subtest runs independently — one failure doesn't stop others. You can filter by name:
// Run all tests: go test
// Run one subtest: go test -run TestParsePort/valid_port
t.Helper — Better Failure Output
When a helper function fails, you want the stack trace to point to the test function, not the helper:
// Without t.Helper(): failure points to line 5 (inside helper) — confusing
// With t.Helper(): failure points to line 11 (test call site) — clear
func assertNoError(t *testing.T, err error) {
t.Helper() // Always call this FIRST in any test helper
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSomething(t *testing.T) {
_, err := DoThing()
assertNoError(t, err) // failure points HERE, not inside helper
}
t.Cleanup — Automatic Teardown
func TestWithFile(t *testing.T) {
f, err := os.CreateTemp("", "test")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Remove(f.Name()) })
// ... test uses f ...
}
TestMain — Global Setup/Teardown
func TestMain(m *testing.M) {
// Global setup: start test database, set env vars
setup()
code := m.Run() // runs all tests
// Global teardown
teardown()
os.Exit(code)
}
Running Tests
CommandEffect
go test ./...Run all tests in module
go test -v ./...Verbose — show each test name
go test -run TestUserRun tests matching pattern
go test -run TestUser/CreateRun specific subtest
go test -count=1 ./...Disable caching (force re-run)
go test -race ./...Enable race detector
go test -cover ./...Show coverage percentage
go test -coverprofile=c.out ./...Write coverage data to file
go test -bench=. ./...Run benchmarks
go test -timeout 30sSet per-test timeout
Go caches test results. If neither the code nor the test changed, go test returns cached results. Use -count=1 to force a fresh run.
Testing in Cloud-Native Projects
Kubernetes: Fake Client + Interface
This is the exact pattern from your Lesson 0001 and the mocking section above:
// controller-runtime provides a fake client implementing client.Client
func TestReconcile(t *testing.T) {
existingPod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "test-pod", Namespace: "default"},
}
// Build a fake client pre-loaded with objects
fakeClient := fake.NewClientBuilder().
WithObjects(existingPod).
Build()
// Inject the fake into the reconciler (accepts Client interface)
r := &PodReconciler{
Client: fakeClient,
Scheme: scheme,
}
_, err := r.Reconcile(ctx, reconcile.Request{
NamespacedName: types.NamespacedName{Name: "test-pod"},
})
if err != nil {
t.Fatalf("reconcile: %v", err)
}
// Verify: read back from fake client to check the result
var updated corev1.Pod
if err := fakeClient.Get(ctx, client.ObjectKeyFromObject(existingPod), &updated); err != nil {
t.Fatal(err)
}
if updated.Status.Phase != corev1.PodRunning {
t.Errorf("expected Running, got %s", updated.Status.Phase)
}
}
Prometheus: httptest for HTTP Handlers
func TestMetricsHandler(t *testing.T) {
req := httptest.NewRequest("GET", "/metrics", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Errorf("status: %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "go_goroutines") {
t.Errorf("missing expected metric")
}
}
Your first Kubernetes PR will probably include a test. You'll write a table-driven test with a fake client, following the pattern above. Understanding interfaces makes this pattern obvious.
Practice
Write a function ValidateEmail(email string) error that returns an error if the email doesn't contain @ or doesn't contain . after the @. Then write a table-driven test with at least 5 cases covering:
- Valid email
- Missing
@ - Missing
.after@ - Empty string
@at start Uset.Runfor each case. Name your test functionTestValidateEmail. Run it withgo test -v -run TestValidateEmail.
Define a Cache interface with Get(key string) (string, bool) and Set(key, value string).
Write a function GetOrFetch(c Cache, key string, fetch func(string) (string, error)) (string, error) that:
- Returns the value from cache if present
- Otherwise calls
fetch(key), stores the result in cache, and returns it Write afakeCache(a struct with a map) and a table-driven test forGetOrFetchcovering: cache hit, cache miss (fetch succeeds), and cache miss (fetch fails).
Write a helper function assertStringsEqual(t *testing.T, got, want string). Call it without t.Helper() first — observe that the failure line number points inside the helper. Then add t.Helper() and observe it points to the call site. Note the difference.
Quick Check
What's Next?
You can now write idiomatic Go tests. Next up: Concurrency I — Goroutines & Channels. The heart of Go's runtime. You'll write concurrent programs, communicate between goroutines with channels, and understand select.
Ask Questions
Want me to review your test code? Confused about when to use fake vs stub vs mock? Ask.
1. Q1: What is the primary reason to use t.Run in a table-driven test?
2. Q2: Why don't cloud-native Go projects use mocking frameworks?
3. Q3: What does t.Helper() do?