Exercises — Module 11: Testing and Benchmarking¶
Work through exercises in order — they're designed to build on each other. Attempt each problem genuinely before looking at the solution. Seeing the solution first might feel like progress, but it isn't.
Instructions¶
- Attempt first. Spend at least the estimated time on each problem before checking hints or solutions.
- Write your work. Don't just read the solution — actually write and run it.
- Check your answer against the acceptance criteria, not just the solution code.
- Score yourself honestly in the Scoring Log at the bottom.
- If you're stuck after a genuine effort, use the hints one at a time — not all at once.
Difficulty Legend¶
| Symbol | Difficulty | Expected Time | Points |
|---|---|---|---|
| 🟢 Easy | Recall and basic application | 5–10 min | 1 pt |
| 🟡 Medium | Requires combining 2+ concepts | 15–25 min | 2 pts |
| 🔴 Hard | Multi-step, requires real problem-solving | 30–60 min | 3 pts |
| ⭐ Challenge | Open-ended; more than one good answer | 60+ min | 5 pts |
Exercise 1: Table-Driven Test for a String Function [🟢 Easy] [1 pt]¶
Context¶
Table-driven tests are the idiomatic default in Go. Before writing any other kind of test, a Go programmer should be fluent at this pattern. This exercise focuses on the core skill: writing a test file with a well-structured table, covering normal cases, edge cases, and boundary inputs.
Task¶
Write a complete reverse_test.go file containing a table-driven test for the following function:
// Reverse returns the string s with its runes in reversed order.
// It handles multi-byte UTF-8 characters correctly.
func Reverse(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}
Your test table must include at least 5 cases covering: empty string, single character, ASCII string, palindrome, and a string containing a multi-byte UTF-8 character (e.g., "café").
Requirements¶
- Use a
[]struct{ name, input, want string }table (or equivalent) - Use
t.Run(tt.name, ...)for each case - Use
t.Errorf(nott.Fatalf) for value mismatch — all cases should run even if one fails - Cover all 5 required case types
- The test file compiles with
go test ./...
Hints¶
Hint 1 (try without this first)
The package declaration at the top of your test file should be `package yourpkg_test` (or `package yourpkg` if you want white-box access). Import `"testing"`. Your function signature is `func TestReverse(t *testing.T)`.Hint 2 (only if Hint 1 wasn't enough)
For the palindrome case, pick a word that reads the same forwards and backwards (e.g., `"racecar"`). The `Reverse("racecar")` should return `"racecar"`. For the UTF-8 case, `Reverse("café")` should return `"éfac"` — not garbled bytes.Expected Output / Acceptance Criteria¶
$ go test -v -run TestReverse ./...
=== RUN TestReverse
=== RUN TestReverse/empty_string
=== RUN TestReverse/single_character
=== RUN TestReverse/ascii_string
=== RUN TestReverse/palindrome
=== RUN TestReverse/utf8_string
--- PASS: TestReverse (0.00s)
--- PASS: TestReverse/empty_string (0.00s)
--- PASS: TestReverse/single_character (0.00s)
--- PASS: TestReverse/ascii_string (0.00s)
--- PASS: TestReverse/palindrome (0.00s)
--- PASS: TestReverse/utf8_string (0.00s)
PASS
Solution¶
Show Solution (attempt first!)
package stringutil_test
import "testing"
func TestReverse(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"empty string", "", ""},
{"single character", "a", "a"},
{"ascii string", "hello", "olleh"},
{"palindrome", "racecar", "racecar"},
{"utf8 string", "café", "éfac"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Reverse(tt.input)
if got != tt.want {
t.Errorf("Reverse(%q) = %q; want %q", tt.input, got, tt.want)
}
})
}
}
Exercise 2: Testing an Error-Returning Function [🟢 Easy] [1 pt]¶
Context¶
Most real Go functions return (value, error). Testing them requires checking both: was an error returned when expected, and is the returned value correct when no error was returned? The wantErr bool pattern is the idiomatic way to handle this in a table.
Task¶
Write a table-driven test for the following function:
// ParsePositive parses s as a positive integer.
// Returns an error if s is not a valid integer or if it is <= 0.
func ParsePositive(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("not an integer: %w", err)
}
if n <= 0 {
return 0, fmt.Errorf("must be positive, got %d", n)
}
return n, nil
}
Include cases for: valid input ("42"), zero ("0"), negative ("-5"), non-integer ("abc"), and the boundary value ("1").
Requirements¶
- Use a table with
name string,input string,want int,wantErr boolfields - Use
t.Fatalfwhen the error expectation is wrong (subsequent checks would be meaningless) - When
wantErris true, return after confirming the error — do not checkwant - When
wantErris false, uset.Errorfto check the returned value
Hints¶
Hint 1
The pattern for the error check is: This checks that got-an-error matches expected-an-error, then skips the value check if an error was expected.Expected Output / Acceptance Criteria¶
$ go test -v -run TestParsePositive ./...
--- PASS: TestParsePositive (0.00s)
--- PASS: TestParsePositive/valid_42 (0.00s)
--- PASS: TestParsePositive/zero (0.00s)
--- PASS: TestParsePositive/negative (0.00s)
--- PASS: TestParsePositive/not_an_integer (0.00s)
--- PASS: TestParsePositive/boundary_1 (0.00s)
PASS
Solution¶
Show Solution
package parseutil_test
import "testing"
func TestParsePositive(t *testing.T) {
tests := []struct {
name string
input string
want int
wantErr bool
}{
{"valid 42", "42", 42, false},
{"zero", "0", 0, true},
{"negative", "-5", 0, true},
{"not an integer", "abc", 0, true},
{"boundary 1", "1", 1, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParsePositive(tt.input)
if (err != nil) != tt.wantErr {
t.Fatalf("ParsePositive(%q) error = %v; wantErr %v", tt.input, err, tt.wantErr)
}
if tt.wantErr {
return // error was expected — value is undefined, skip check
}
if got != tt.want {
t.Errorf("ParsePositive(%q) = %d; want %d", tt.input, got, tt.want)
}
})
}
}
Exercise 3: Write a Benchmark and Compare Two Implementations [🟡 Medium] [2 pts]¶
Context¶
Benchmarks answer the question "which is faster?" with actual numbers, not intuition. This exercise requires writing two benchmarks for competing string-joining approaches and reading the output to determine which allocates less.
Task¶
Write two benchmark functions in a _test.go file comparing these two approaches to joining a slice of strings with a separator:
// Approach A: naive loop with string concatenation
func JoinNaive(parts []string, sep string) string {
result := ""
for i, p := range parts {
if i > 0 {
result += sep
}
result += p
}
return result
}
// Approach B: strings.Join
func JoinStdlib(parts []string, sep string) string {
return strings.Join(parts, sep)
}
Use a fixed input of 100 strings each of length 10, joined with ", ". Run the benchmarks and report which approach is faster and which allocates less.
Requirements¶
- Both benchmark functions follow the
BenchmarkXxx(b *testing.B)signature - Each benchmark uses a fixed input prepared before the measurement loop
-
b.ResetTimer()is called after input preparation - Both benchmarks call
b.ReportAllocs() - You run both with
go test -bench=. -benchmemand record the output in a comment
Hints¶
Hint 1
Prepare the input slice before the loop:Hint 2 (only if Hint 1 wasn't enough)
The `_ = result` pattern discards the result to prevent the compiler from optimizing the call away. Without it, a sufficiently smart compiler could theoretically eliminate the entire computation.Expected Output / Acceptance Criteria¶
Your benchmark output will vary by machine, but should show a clear difference. Record actual numbers from your run. Example (not guaranteed values):
BenchmarkJoinNaive-8 20000 75840 ns/op 26080 B/op 99 allocs/op
BenchmarkJoinStdlib-8 300000 4212 ns/op 1168 B/op 2 allocs/op
The key finding: strings.Join should be significantly faster with far fewer allocations. This is because the naive approach allocates a new string on every +=, while strings.Join computes the total length once and allocates exactly once.
Solution¶
Show Solution
package strjoin_test
import (
"strings"
"testing"
)
func BenchmarkJoinNaive(b *testing.B) {
// Prepare fixed input — not included in the measurement
parts := make([]string, 100)
for i := range parts {
parts[i] = strings.Repeat("x", 10)
}
b.ResetTimer() // measurement starts here
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = JoinNaive(parts, ", ")
}
}
func BenchmarkJoinStdlib(b *testing.B) {
parts := make([]string, 100)
for i := range parts {
parts[i] = strings.Repeat("x", 10)
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = JoinStdlib(parts, ", ")
}
}
Exercise 4: Write a Fuzz Test for a Parser [🔴 Hard] [3 pts]¶
Context¶
Fuzz tests find bugs in input-handling code that you would never think to test manually. This exercise requires identifying the right invariants to assert and writing a fuzz test that would catch real parser bugs. The parser in this exercise is intentionally simple to keep focus on the testing mechanics.
Task¶
Write a fuzz test for the following CSV row parser:
// ParseCSVRow parses a single line of comma-separated values.
// It does not handle quoted fields. Returns a slice of field strings.
// Must never panic on any input.
func ParseCSVRow(line string) []string {
return strings.Split(line, ",")
}
Your fuzz test must:
1. Include at least 3 seed corpus entries
2. Assert the round-trip invariant: strings.Join(ParseCSVRow(line), ",") == line
3. Assert that the number of fields is always >= 1 (even for an empty string, Split returns a slice with one empty string)
4. Not panic (guaranteed by the fuzzer's crash detection)
Requirements¶
- Function signature is
func FuzzParseCSVRow(f *testing.F) - At least 3
f.Add(...)seed corpus entries including the empty string - The fuzz function has signature
func(t *testing.T, line string) - Both invariants (round-trip and len >= 1) are asserted
- The test passes when run with
go test -run FuzzParseCSVRow ./...(seed corpus only)
Hints¶
Hint 1 (structural hint — try designing without this first)
The fuzz function body checks invariants. For the round-trip check:Hint 2 (only if Hint 1 wasn't enough)
This parser does not handle errors, so you cannot do the `if err != nil { return }` early exit. Every input must satisfy both invariants. The `strings.Split` function always returns at least one element, so the `len >= 1` invariant should always hold — but the fuzz test forces you to confirm this explicitly.Hint 3 (near-solution hint — only if truly stuck)
func FuzzParseCSVRow(f *testing.F) {
f.Add("")
f.Add("a,b,c")
f.Add("one")
f.Fuzz(func(t *testing.T, line string) {
fields := ParseCSVRow(line)
if len(fields) < 1 {
t.Errorf("ParseCSVRow(%q) returned empty slice", line)
}
if strings.Join(fields, ",") != line {
t.Errorf("round-trip failed for %q", line)
}
})
}
Expected Output / Acceptance Criteria¶
# Seed corpus run — should pass immediately
$ go test -run FuzzParseCSVRow ./...
PASS
# Active fuzzing — should not find any crashers for this simple implementation
$ go test -fuzz FuzzParseCSVRow -fuzztime=10s ./...
fuzz: elapsed: 10s, execs: 843201, new interesting: 15, corpus: 22
ok yourpackage 10.023s
Solution¶
Show Solution
package csvutil_test
import (
"strings"
"testing"
)
func FuzzParseCSVRow(f *testing.F) {
// Seed corpus: representative inputs for the fuzzer to mutate
f.Add("") // empty input — edge case
f.Add("a,b,c") // typical multi-field case
f.Add("one") // single field, no commas
f.Add(",") // separator only — two empty fields
f.Add("a,,b") // empty field in the middle
f.Fuzz(func(t *testing.T, line string) {
// Invariant 1: must return at least 1 field
fields := ParseCSVRow(line)
if len(fields) < 1 {
t.Errorf("ParseCSVRow(%q) returned empty slice (len=%d)", line, len(fields))
}
// Invariant 2: round-trip — joining the fields back must reproduce the original line
rejoined := strings.Join(fields, ",")
if rejoined != line {
t.Errorf("round-trip failed:\n input: %q\n rejoined: %q", line, rejoined)
}
})
}
Exercise 5: Test an HTTP Handler with httptest [🟡 Medium] [2 pts]¶
Context¶
Testing HTTP handlers requires creating fake HTTP requests and capturing the response — without starting a real network server. net/http/httptest is the standard tool for this. This exercise practices the complete pattern: httptest.NewRequest, httptest.NewRecorder, driving the handler, and checking both status code and body.
Task¶
Write a table-driven test for the following HTTP handler:
// EchoHandler echoes the "message" query parameter back as plain text.
// Returns 400 Bad Request if the parameter is missing or empty.
// Returns 200 OK with "Echo: <message>" if the parameter is present.
func EchoHandler(w http.ResponseWriter, r *http.Request) {
msg := r.URL.Query().Get("message")
if msg == "" {
http.Error(w, "message parameter required", http.StatusBadRequest)
return
}
fmt.Fprintf(w, "Echo: %s", msg)
}
Include cases for: valid message, missing parameter, and empty parameter.
Requirements¶
- Use
httptest.NewRequestandhttptest.NewRecorder - Check both the status code (
w.Result().StatusCode) and the response body - Use a table-driven structure with at least 3 cases
- Import
"net/http","net/http/httptest","io","testing"
Hints¶
Hint 1
Expected Output / Acceptance Criteria¶
$ go test -v -run TestEchoHandler ./...
--- PASS: TestEchoHandler (0.00s)
--- PASS: TestEchoHandler/valid_message (0.00s)
--- PASS: TestEchoHandler/missing_parameter (0.00s)
--- PASS: TestEchoHandler/empty_parameter (0.00s)
PASS
Solution¶
Show Solution
package handler_test
import (
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestEchoHandler(t *testing.T) {
tests := []struct {
name string
url string
wantStatus int
wantBody string
}{
{
name: "valid message",
url: "/echo?message=hello",
wantStatus: http.StatusOK,
wantBody: "Echo: hello",
},
{
name: "missing parameter",
url: "/echo",
wantStatus: http.StatusBadRequest,
wantBody: "message parameter required\n",
},
{
name: "empty parameter",
url: "/echo?message=",
wantStatus: http.StatusBadRequest,
wantBody: "message parameter required\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tt.url, nil)
w := httptest.NewRecorder()
EchoHandler(w, req)
res := w.Result()
if res.StatusCode != tt.wantStatus {
t.Errorf("status = %d; want %d", res.StatusCode, tt.wantStatus)
}
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if string(body) != tt.wantBody {
t.Errorf("body = %q; want %q", string(body), tt.wantBody)
}
})
}
}
Exercise 6: Example Function with Output Comment [🟢 Easy] [1 pt]¶
Context¶
Example functions are simultaneously documentation and tests. A // Output: comment makes the example an executable test: if the actual output doesn't match, go test fails. This exercise practices writing examples that will appear in go doc output and on pkg.go.dev.
Task¶
Write example functions for Reverse and ParsePositive (from Exercises 1 and 2). Each example must have a // Output: comment with the expected output. Include at least 2 calls per example function.
Requirements¶
- Both functions are named
ExampleReverseandExampleParsePositive - Each has a
// Output:comment that matches the actual output exactly - The examples compile and pass
go test ./... - The examples are realistic (show useful usage, not trivial inputs)
Hints¶
Hint 1
The `// Output:` comment must match the actual output exactly, including spaces and newlines. Use `fmt.Println` (which adds a newline) — the output comment should have one line per `Println` call. `fmt.Printf` without a trailing `\n` won't end with a newline, which changes the output.Expected Output / Acceptance Criteria¶
Solution¶
Show Solution
package stringutil_test
import "fmt"
func ExampleReverse() {
fmt.Println(Reverse("hello"))
fmt.Println(Reverse(""))
fmt.Println(Reverse("racecar"))
// Output:
// olleh
//
// racecar
}
func ExampleParsePositive() {
n, err := ParsePositive("42")
fmt.Println(n, err)
_, err = ParsePositive("0")
fmt.Println(err)
// Output:
// 42 <nil>
// must be positive, got 0
}
Exercise 7: TestMain with Package-Level Setup [⭐ Challenge] [5 pts]¶
Context¶
Some tests require shared infrastructure that is expensive to set up on a per-test basis — a database connection, a listening server, or a compiled binary. TestMain provides package-level setup and teardown. This challenge exercise requires designing the full lifecycle: setup → run all tests → teardown → exit with correct code.
Task¶
Write a complete test file with TestMain that:
- Creates a temporary directory and writes a sample config file (
config.json) into it before any tests run - Stores the temp directory path in a package-level variable accessible to all tests
- Runs all tests with
m.Run() - Cleans up the temp directory after all tests complete
- Calls
os.Exit(code)with the exit code fromm.Run() - Includes at least one test (
TestConfigExists) that reads from the temp directory set up byTestMain
Requirements¶
-
TestMain(m *testing.M)is the entry point — no test runs without it - Setup (write config file) happens before
m.Run() - Teardown (
os.RemoveAll) happens afterm.Run()but beforeos.Exit - A package-level
var testDir stringholds the temp directory path -
TestConfigExistsverifies the config file was created in the expected location - The test file compiles and passes
go test ./...
Hints¶
Hint 1 (structural hint)
var testDir string
func TestMain(m *testing.M) {
// setup
var err error
testDir, err = os.MkdirTemp("", "mytest-*")
if err != nil {
fmt.Fprintf(os.Stderr, "setup: %v\n", err)
os.Exit(1)
}
// write files into testDir...
code := m.Run() // run all tests
os.RemoveAll(testDir) // teardown
os.Exit(code) // must call os.Exit with the code
}
Hint 2 (conceptual hint)
The `os.Exit(code)` call after `m.Run()` is essential. Without it, `os.Exit` at the end of `TestMain` would exit with code 0 regardless of test results. The code from `m.Run()` is 0 if all tests passed, 1 if any failed.Hint 3 (near-solution hint — only if truly stuck)
For `TestConfigExists`:Expected Output / Acceptance Criteria¶
$ go test -v ./...
=== RUN TestConfigExists
--- PASS: TestConfigExists (0.00s)
PASS
ok yourpackage 0.005s
The cleanup should remove the temp directory — verify with ls /tmp/mytest-* before and after.
Solution¶
Show Solution
package config_test
import (
"fmt"
"os"
"path/filepath"
"testing"
)
// testDir is set by TestMain and accessible to all tests in this package
var testDir string
func TestMain(m *testing.M) {
// Setup: create a temp directory and write a sample config file
var err error
testDir, err = os.MkdirTemp("", "configtest-*")
if err != nil {
fmt.Fprintf(os.Stderr, "TestMain setup: %v\n", err)
os.Exit(1)
}
configContent := `{"version": 1, "debug": false}`
configPath := filepath.Join(testDir, "config.json")
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
fmt.Fprintf(os.Stderr, "TestMain write config: %v\n", err)
os.RemoveAll(testDir)
os.Exit(1)
}
// Run all tests and capture the exit code
code := m.Run()
// Teardown: remove the temp directory
if err := os.RemoveAll(testDir); err != nil {
fmt.Fprintf(os.Stderr, "TestMain teardown: %v\n", err)
}
// Exit with the test runner's exit code — MUST call os.Exit
os.Exit(code)
}
func TestConfigExists(t *testing.T) {
configPath := filepath.Join(testDir, "config.json")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
t.Errorf("config file not created at %s", configPath)
}
}
func TestConfigContent(t *testing.T) {
configPath := filepath.Join(testDir, "config.json")
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("read config: %v", err)
}
if string(data) != `{"version": 1, "debug": false}` {
t.Errorf("config content = %q; want JSON with version and debug", string(data))
}
}
Scoring Log¶
Record your performance honestly. Include the date and whether you used hints.
| Exercise | Date | Score | Used Hints? | Notes |
|---|---|---|---|---|
| Exercise 1 — Table-Driven Test | — | —/1 | — | — |
| Exercise 2 — Error-Returning Function | — | —/1 | — | — |
| Exercise 3 — Benchmark Comparison | — | —/2 | — | — |
| Exercise 4 — Fuzz Test | — | —/3 | — | — |
| Exercise 5 — HTTP Handler Test | — | —/2 | — | — |
| Exercise 6 — Example Function | — | —/1 | — | — |
| Exercise 7 — TestMain (Challenge) | — | —/5 | — | — |
| Total | —/15 |
Passing threshold: 10/15 (67%). Aim for 13/15 (87%) before taking the test.