Skip to main content

    Lesson 7 • Advanced

    Testing in Go 🐹

    By the end of this lesson you'll write real Go tests with the built-in testing package — single tests, table-driven tests with subtests, and benchmarks — and run them with go test. No third-party libraries needed; it's all in the standard library.

    What You'll Learn in This Lesson

    • Put tests in _test.go files and write func TestXxx(t *testing.T)
    • Report failures with t.Errorf (keep going) and t.Fatalf (stop now)
    • Write table-driven tests and run each case as a t.Run subtest
    • Run tests with go test, -v, -run, and measure with -cover
    • Add a benchmark with Benchmark + *testing.B and the -bench flag
    • Avoid classic traps like comparing slices or maps with ==

    1️⃣ Your First Test

    Go's test runner is built into the language — there's no framework to install. Three conventions are all you need: the file name ends in _test.go, each test function is named Test + a capitalised name, and it takes a single t *testing.T. Go has no assert keyword: you compare the values yourself and call a method on t when something's wrong. Read this worked example, then run it.

    Worked example: a test file and TestAdd
    // math.go — the code you want to test.
    package mathutil
    
    func Add(a, b int) int {
        return a + b
    }
    
    // math_test.go — tests MUST live in a file ending in _test.go,
    // in the same package. The compiler treats _test.go files specially:
    // they are only built when you run "go test", never in your app.
    package mathutil
    
    import "testing"
    
    // Rules for a test function:
    //   - name starts with Test followed by a Capital letter (TestAdd)
    //   - it takes exactly one argument: t *testing.T
    func TestAdd(t *testing.T) {
        got := Add(2, 3)     // call the thing under test
        want := 5            // what you expect
    
        // There is no assert keyword in Go. You compare yourself and,
        // on failure, report it via t. t.Errorf marks the test failed
        // but keeps running the rest of the function.
        if got != want {
            t.Errorf("Add(2, 3) = %d; want %d", got, want)
        }
    }
    Output
    $ go test
    ok      myproject/mathutil  0.003s
    
    $ go test -v
    === RUN   TestAdd
    --- PASS: TestAdd (0.00s)
    PASS
    ok      myproject/mathutil  0.003s
    Save the second block as math_test.go in a Go module and run go test -v in your own terminal.

    2️⃣ Reporting Failures: t.Errorf vs t.Fatalf

    When a check fails you tell the test runner with a method on t. t.Errorf records the failure but keeps running the rest of the function, so one run can surface several issues. t.Fatalf records it and stops the test right there — use it after setup or an error check, when the lines below would otherwise crash. Both take a printf-style format string.

    Worked example: Errorf keeps going, Fatalf stops
    package mathutil
    
    import "testing"
    
    func Divide(a, b int) int { return a / b }
    
    func TestDivide(t *testing.T) {
        // t.Fatalf reports the failure AND stops this test immediately
        // (via runtime.Goexit). Use it when continuing makes no sense —
        // here, if b is 0 the next line would panic, so we bail out first.
        b := 2
        if b == 0 {
            t.Fatalf("b is 0; cannot divide")   // stops the test now
        }
    
        got := Divide(10, b)
        // t.Errorf reports the failure but KEEPS GOING, so one run can
        // surface several problems at once.
        if got != 5 {
            t.Errorf("Divide(10, 2) = %d; want 5", got)
        }
    }
    Output
    $ go test -v
    === RUN   TestDivide
    --- PASS: TestDivide (0.00s)
    PASS
    ok      myproject/mathutil  0.002s
    Run with go test -v. Try changing want to a wrong value to see a failure message.

    Now you try. The test below is almost complete — fill in the two blanks marked ___ using the hints, then run it. You're writing the expected value and the comparison.

    🎯 Your turn: complete TestDouble
    package mathutil
    
    import "testing"
    
    func Double(n int) int { return n * 2 }
    
    // 🎯 YOUR TURN — finish this test, then run "go test -v".
    func TestDouble(t *testing.T) {
        got := Double(4)
        want := ___          // 👉 the number you expect Double(4) to return
    
        // 👉 compare got and want; on mismatch call t.Errorf with a message
        if got ___ want {    // 👉 use the "not equal" operator here
            t.Errorf("Double(4) = %d; want %d", got, want)
        }
    }
    
    // ✅ Expected output when correct:
    // === RUN   TestDouble
    // --- PASS: TestDouble (0.00s)
    // PASS
    Replace each ___, then run go test -v and check your output matches the expected lines in the comment.

    3️⃣ Table-Driven Tests with Subtests

    This is the pattern you'll write most in Go. Instead of copy-pasting a near-identical test for every input, you describe the cases as data in a slice of structs, then loop over them. Wrapping each case in t.Run(name, func) turns it into a named subtest, so the report tells you precisely which case broke and you can re-run just that one. Adding a new case is a single line.

    Worked example: table-driven TestAdd
    package mathutil
    
    import "testing"
    
    // Table-driven tests are Go's signature pattern: describe each case
    // as DATA in a slice, then loop and run each one as a named subtest.
    func TestAdd(t *testing.T) {
        // An anonymous struct describes the shape of one test case.
        tests := []struct {
            name string   // a label for this case (shows up in -v output)
            a, b int      // inputs
            want int      // expected result
        }{
            {"two positives", 2, 3, 5},
            {"two negatives", -1, -2, -3},
            {"with zero", 0, 7, 7},
            {"mixed signs", -5, 10, 5},
        }
    
        for _, tt := range tests {
            // t.Run starts a SUBTEST named after the case. Each subtest
            // passes or fails on its own, so you see exactly which case broke.
            t.Run(tt.name, func(t *testing.T) {
                if got := Add(tt.a, tt.b); got != tt.want {
                    t.Errorf("Add(%d, %d) = %d; want %d",
                        tt.a, tt.b, got, tt.want)
                }
            })
        }
    }
    Output
    $ go test -v
    === RUN   TestAdd
    === RUN   TestAdd/two_positives
    === RUN   TestAdd/two_negatives
    === RUN   TestAdd/with_zero
    === RUN   TestAdd/mixed_signs
    --- PASS: TestAdd (0.00s)
        --- PASS: TestAdd/two_positives (0.00s)
        --- PASS: TestAdd/two_negatives (0.00s)
        --- PASS: TestAdd/with_zero (0.00s)
        --- PASS: TestAdd/mixed_signs (0.00s)
    PASS
    ok      myproject/mathutil  0.004s
    Run go test -v to see each subtest reported on its own line (spaces in case names become underscores).

    Your turn again. The table below tests a Max function but is missing a case. Add one row that covers two equal numbers, then run it.

    🎯 Your turn: add a table row
    package mathutil
    
    import "testing"
    
    func Max(a, b int) int {
        if a > b {
            return a
        }
        return b
    }
    
    func TestMax(t *testing.T) {
        tests := []struct {
            name string
            a, b int
            want int
        }{
            {"a bigger", 9, 4, 9},
            {"b bigger", 2, 8, 8},
            // 🎯 YOUR TURN — add ONE more case to the table below.
            // 👉 a case where both numbers are equal, e.g. 5 and 5.
            ___,   // 👉 {"name", a, b, want}  — fill in all four fields
        }
    
        for _, tt := range tests {
            t.Run(tt.name, func(t *testing.T) {
                if got := Max(tt.a, tt.b); got != tt.want {
                    t.Errorf("Max(%d, %d) = %d; want %d",
                        tt.a, tt.b, got, tt.want)
                }
            })
        }
    }
    
    // ✅ Expected: your new subtest appears and passes, e.g.
    //     --- PASS: TestMax/equal (0.00s)
    Fill in the ___ with a struct literal like {"equal", 5, 5, 5}, then run go test -v and confirm your new subtest passes.

    4️⃣ Running Tests: go test, -v, -run, -cover

    You drive everything from the command line. go test runs the package's tests and prints ok or FAIL. Add -v for a line per test, -run to filter by a name regex (great with subtests), and -cover for the percentage of your code the tests exercise. ./... means "this package and everything below it".

    The flags you'll use every day
    # Run every test in the current package
    go test
    
    # -v: verbose — show each test and subtest as it runs
    go test -v
    
    # -run: only tests whose name matches a regex.
    #   "TestAdd" runs TestAdd; "TestAdd/zero" targets one subtest.
    go test -run TestAdd
    go test -run 'TestAdd/with_zero' -v
    
    # -cover: what percentage of statements your tests exercise
    go test -cover
    
    # ./... means "this package and every package below it"
    go test ./...
    
    # Combine them freely:
    go test -v -run TestMax -cover ./...
    Output
    $ go test -cover
    PASS
    coverage: 92.3% of statements
    ok      myproject/mathutil  0.004s
    
    $ go test -run 'TestAdd/with_zero' -v
    === RUN   TestAdd
    === RUN   TestAdd/with_zero
    --- PASS: TestAdd (0.00s)
        --- PASS: TestAdd/with_zero (0.00s)
    PASS
    ok      myproject/mathutil  0.003s
    These are shell commands — run them in your project folder in your own terminal.

    5️⃣ Benchmarks (a quick note)

    Once your code is correct, you may want to know if it's fast. A benchmark is named Benchmark + capital, takes a b *testing.B, and runs your code in a for i := 0; i < b.N; i++ loop. The framework raises b.N until the timing is stable. Benchmarks don't run with a plain go test — you opt in with -bench.

    Worked example: a benchmark with b.N
    package mathutil
    
    import "testing"
    
    // A BENCHMARK measures speed. Name it Benchmark + Capital, take a
    // *testing.B, and loop exactly b.N times. The framework keeps raising
    // b.N until the timing is stable, then reports the per-operation cost.
    func BenchmarkAdd(b *testing.B) {
        for i := 0; i < b.N; i++ {
            Add(2, 3)
        }
    }
    
    // Benchmarks DON'T run with a plain "go test" — you opt in with -bench.
    //   go test -bench=.            run all benchmarks
    //   go test -bench=. -benchmem  also report memory per operation
    Output
    $ go test -bench=. -benchmem
    goos: linux
    goarch: amd64
    pkg: myproject/mathutil
    BenchmarkAdd-8   1000000000   0.27 ns/op   0 B/op   0 allocs/op
    PASS
    ok      myproject/mathutil  0.34s
    Benchmarks only run with the -bench flag: try go test -bench=. -benchmem in your terminal.

    Pro Tips

    • 💡 Name failure messages well: the convention is "Add(2, 3) = %d; want %d" — show the call, the result you got, and what you want.
    • 💡 Compare slices/maps with reflect.DeepEqual (or slices.Equal / maps.Equal in Go 1.21+) — never with ==.
    • 💡 Run go test -race ./... in CI — the race detector catches concurrency bugs that only appear under load.
    • 💡 Use t.Helper() inside an assertion helper so failures point at the calling test, not at the helper.

    Common Errors (and the fix)

    • "no test files": your file isn't named _test.go. A name like math-test.go or tests.go is ignored by go test. Rename it to end in exactly _test.go.
    • Test silently never runs: the function name must be Test + a Capital letter. func testAdd or func Testadd won't be picked up — use func TestAdd.
    • "invalid operation: ... (slice can only be compared to nil)": you used == on slices or maps. Use reflect.DeepEqual(got, want) instead.
    • "TestXxx has wrong signature": a test must take exactly t *testing.T — not *testing.B and no extra parameters.
    • Benchmark "ran 0 times": you looped a fixed number instead of b.N. The loop must be for i := 0; i < b.N; i++.

    📋 Quick Reference — Go Testing

    TaskCode / Command
    Test file namesomething_test.go
    Test functionfunc TestX(t *testing.T)
    Fail, keep goingt.Errorf("got %d; want %d", g, w)
    Fail, stop nowt.Fatalf("setup failed: %v", err)
    Subtestt.Run(name, func(t *testing.T){…})
    Run all / verbosego test · go test -v
    Run one (sub)testgo test -run 'TestX/case'
    Coveragego test -cover ./...
    Benchmarkgo test -bench=. -benchmem

    Frequently Asked Questions

    Q: Why must my test file end in _test.go?

    The go tool only compiles _test.go files when you run go test — they are excluded from your normal build, so test code never ships in your binary. A file named tests.go or math-test.go is treated as ordinary source, its Test functions are ignored by go test, and you will see 'no test files'. The exact suffix _test.go is required.

    Q: What is the difference between t.Errorf and t.Fatalf?

    Both mark the test as failed and print a formatted message. t.Errorf keeps running the rest of the test function, so a single run can report several problems at once. t.Fatalf stops the current test immediately. Use Fatalf when continuing makes no sense — for example after a setup step or an error check fails and the following lines would panic.

    Q: Why do table-driven tests use t.Run for each case?

    t.Run creates a named subtest, so each row of your table passes or fails independently and the -v output tells you exactly which case broke (e.g. TestAdd/with_zero). You can also target one case with go test -run 'TestAdd/with_zero'. Without t.Run, a single failing row just fails the whole test with no clear label.

    Q: How do I run only one test or one subtest?

    Use the -run flag with a regex. go test -run TestAdd runs only TestAdd. go test -run 'TestAdd/with_zero' targets a single subtest inside it. Combine with -v to see what ran. To test every package in a project, add ./... — for example go test -run TestMax ./...

    Q: Why can't I compare two slices or maps with ==?

    In Go, == only works on comparable types. Slices and maps are not comparable, so got == want is a compile error ('invalid operation: slice can only be compared to nil'). Compare them with reflect.DeepEqual(got, want), or in Go 1.21+ use slices.Equal / maps.Equal for slices and maps of comparable elements.

    Q: Do benchmarks run when I run go test?

    No. A plain go test runs your Test functions but skips Benchmark functions. You opt into benchmarks with the -bench flag, e.g. go test -bench=. to run them all, and add -benchmem to also report bytes and allocations per operation.

    Mini-Challenge: Test a String Reverser

    No blanks this time — just a brief and an outline. The Reverse function is written for you; write a table-driven test for it from scratch, run it, and check each case passes. This is exactly the kind of test you'll write every day in real Go projects.

    🎯 Mini-Challenge: write TestReverse
    package strutil
    
    import "testing"
    
    // The function under test (already written):
    func Reverse(s string) string {
        r := []rune(s)
        for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
            r[i], r[j] = r[j], r[i]
        }
        return string(r)
    }
    
    // 🎯 MINI-CHALLENGE: write a table-driven test for Reverse.
    // 1. Name it TestReverse and take t *testing.T.
    // 2. Build a []struct slice of cases: a name, an input, and a want.
    //    Include at least: "hello" -> "olleh", "" -> "", "a" -> "a".
    // 3. Loop the cases with t.Run(tt.name, ...) as a subtest.
    // 4. Inside, if Reverse(tt.input) != tt.want, call t.Errorf.
    //
    // ✅ Expected: go test -v shows one PASS subtest per case, e.g.
    //     --- PASS: TestReverse/hello (0.00s)
    //     --- PASS: TestReverse/empty (0.00s)
    
    // your code here
    Write your test below the outline, then run go test -v and confirm a PASS subtest per case.

    🎉 Lesson Complete!

    • ✅ Tests live in _test.go files; functions are func TestXxx(t *testing.T)
    • t.Errorf fails but continues; t.Fatalf fails and stops the test
    • ✅ Table-driven tests describe cases as data and run each with t.Run subtests
    • ✅ Drive runs with go test, -v, -run, and -cover
    • ✅ Benchmarks use Benchmark + b.N and run with -bench
    • ✅ Compare slices and maps with reflect.DeepEqual, never ==
    • You've finished the Go course — you can now build, test, and ship production Go. Next, explore Go modules, database access, and Docker deployment.

    Sign up for free to track which lessons you've completed and get learning reminders.

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service