Testing in Go

Reviewed & published by Brayan K

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.

Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

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.

// 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)
    }
}

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.

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)
    }
}

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.

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

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.

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)
            }
        })
    }
}

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.

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)

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".

# 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 ./...

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.

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

Pro Tips

Common Errors (and the fix)

📋 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

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.

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

🎉 Lesson Complete!

Practice quiz

Where must Go test functions live?

  • In a file named tests.go
  • In any .go file with a //test comment
  • In a file ending in _test.go
  • In a folder called tests/

Answer: In a file ending in _test.go. The go tool only compiles _test.go files when you run go test.

What signature must a test function have?

  • func TestX(t *testing.T)
  • func TestX(b *testing.B)
  • func testX(t *testing.T)
  • func TestX() error

Answer: func TestX(t *testing.T). Name is Test + Capital, taking exactly t *testing.T.

Which call reports a failure but keeps running the rest of the test?

  • t.Fatalf
  • t.Skip
  • panic
  • t.Errorf

Answer: t.Errorf. t.Errorf records the failure and continues; t.Fatalf stops the test now.

What does t.Fatalf do after reporting the failure?

  • Continues to the next line
  • Stops the current test immediately
  • Skips the whole package
  • Retries the test

Answer: Stops the current test immediately. Use Fatalf when continuing makes no sense, e.g. before a line that would panic.

In a table-driven test, what turns each case into a named subtest?

  • t.Run(tt.name, func(t *testing.T){...})
  • t.Subtest(tt.name)
  • go test -sub
  • t.Case(tt.name)

Answer: t.Run(tt.name, func(t *testing.T){...}). t.Run starts a subtest that passes or fails on its own.

For a subtest case named "with zero", how does it appear in -v output?

  • TestAdd/with zero
  • TestAdd.with_zero
  • TestAdd/with_zero
  • TestAdd-with-zero

Answer: TestAdd/with_zero. Spaces in case names become underscores in the subtest path.

Which flag filters which tests run by a name regex?

  • -only
  • -run
  • -filter
  • -match

Answer: -run. go test -run 'TestAdd/with_zero' targets one subtest.

What does ./... mean in go test ./...?

  • Only the current file
  • Re-run failed tests
  • The parent package only
  • This package and every package below it

Answer: This package and every package below it. It recurses into all subpackages.

Do plain go test runs execute Benchmark functions?

  • Yes, always
  • No, you opt in with -bench
  • Only with -v
  • Only if named TestBenchmark

Answer: No, you opt in with -bench. Benchmarks run only with the -bench flag, e.g. go test -bench=.

How should you compare two slices in a test?

  • got == want
  • got.Equals(want)
  • reflect.DeepEqual(got, want)
  • compare(got, want)

Answer: reflect.DeepEqual(got, want). Slices are not comparable with ==; use reflect.DeepEqual or slices.Equal.

Continue this course

Frequently asked questions

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.

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.

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.

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 ./...

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.

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.