Skip to content

Go Testing — Table Tests Benchmarks Coverage and Test Suites Explained

DodaTech Updated 2026-06-28 11 min read

In this tutorial, you will learn about Go Testing. We cover key concepts, practical examples, and best practices to help you master this topic.

Go testing uses the built-in testing package with t *testing.T for assertions, table-driven tests for multiple cases, test coverage via go test -cover, and benchmarks with t *testing.B for performance measurement.

What You'll Learn

  • Writing unit tests with the testing package
  • Table-driven test patterns
  • Running benchmarks
  • Test coverage analysis

Why It Matters

Testing is built into Go's toolchain. Docker runs thousands of tests on every commit. Kubernete uses extensive table-driven tests. DodaZIP tests all archive formats with benchmarks. Go's testing philosophy: tests are code, not magic.

Real-World Use

CI/CD pipelines, pre-commit hooks, code review gates. Every Go project uses the same testing package with the same patterns.

flowchart LR
    A["Testing"] --> B["Unit Tests"]
    B --> C["Table Tests"]
    C --> D["Benchmarks"]
    D --> E["Coverage"]
    A:::current --> B
    style A fill:#2563eb,stroke:#2563eb,color:#fff
    style B fill:#dbeafe,stroke:#2563eb,color:#1e40af
    style C fill:#dbeafe,stroke:#2563eb,color:#1e40af
    style D fill:#dbeafe,stroke:#2563eb,color:#1e40af
    style E fill:#f1f5f9,stroke:#94a3b8,color:#64748b

Basic Unit Test

// math.go
package math

func Add(a, b int) int {
    return a + b
}

func Divide(a, b int) (int, error) {
    if b == 0 {
        return 0, fmt.Errorf("division by zero")
    }
    return a / b, nil
}
// math_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    expected := 5

    if result != expected {
        t.Errorf("Add(2, 3) = %d; want %d", result, expected)
    }
}

func TestDivide(t *testing.T) {
    result, err := Divide(10, 2)
    if err != nil {
        t.Fatal("unexpected error:", err)
    }
    if result != 5 {
        t.Errorf("Divide(10, 2) = %d; want 5", result)
    }
}
go test -v
# === RUN   TestAdd
# --- PASS: TestAdd (0.00s)
# === RUN   TestDivide
# --- PASS: TestDivide (0.00s)
# PASS
# ok      example.com/math  0.002s

Table-Driven Tests

func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {name: "positive numbers", a: 2, b: 3, expected: 5},
        {name: "negative numbers", a: -1, b: -2, expected: -3},
        {name: "zero", a: 0, b: 5, expected: 5},
        {name: "large numbers", a: 1000000, b: 2000000, expected: 3000000},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := Add(tt.a, tt.b)
            if result != tt.expected {
                t.Errorf("Add(%d, %d) = %d; want %d",
                    tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

func TestDivide(t *testing.T) {
    tests := []struct {
        name      string
        a, b      int
        expected  int
        wantError bool
    }{
        {name: "normal division", a: 10, b: 2, expected: 5, wantError: false},
        {name: "division by zero", a: 5, b: 0, wantError: true},
        {name: "negative division", a: -6, b: 3, expected: -2, wantError: false},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := Divide(tt.a, tt.b)

            if tt.wantError {
                if err == nil {
                    t.Error("expected error but got none")
                }
                return
            }

            if err != nil {
                t.Fatal("unexpected error:", err)
            }
            if result != tt.expected {
                t.Errorf("Divide(%d, %d) = %d; want %d",
                    tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

Helper Functions

func TestUserValidation(t *testing.T) {
    user := &User{Name: "Alice", Age: 30}
    err := ValidateUser(user)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
}

// Helper for common setup
func setupTest(t *testing.T) *DB {
    t.Helper()  // Marks as helper — stack traces skip it
    db, err := connectToTestDB()
    if err != nil {
        t.Fatal(err)
    }
    t.Cleanup(func() {
        db.Close()
    })
    return db
}

Running Tests

# Run all tests
go test ./...

# Run with verbose output
go test -v ./...

# Run specific test
go test -run TestAdd ./...

# Run tests in package
go test ./math/...

# Run tests with timeout
go test -timeout 30s ./...

# Run tests multiple times (flake detection)
go test -count=3 ./...

# Short mode (skip long tests)
go test -short ./...

Test Coverage

# Run with coverage
go test -cover ./...

# Generate coverage profile
go test -coverprofile=coverage.out ./...

# View coverage in browser
go cover -html=coverage.out

# Coverage per function
go test -cover -covermode=count ./...

Writing for Coverage

func Process(input string) string {
    if input == "" {
        return "default"
    }
    if len(input) < 3 {
        return input
    }
    return input[:3]
}

func TestProcess(t *testing.T) {
    tests := []struct {
        name     string
        input    string
        expected string
    }{
        {name: "empty", input: "", expected: "default"},
        {name: "short", input: "ab", expected: "ab"},
        {name: "long", input: "abcdef", expected: "abc"},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := Process(tt.input)
            if result != tt.expected {
                t.Errorf("got %q; want %q", result, tt.expected)
            }
        })
    }
}

Benchmarking

// Benchmark basic operation
func BenchmarkAdd(b *testing.B) {
    a, c := 5, 3
    for i := 0; i < b.N; i++ {
        Add(a, c)
    }
}

Realistic Benchmark

// functions.go
func SumSlice(items []int) int {
    total := 0
    for _, v := range items {
        total += v
    }
    return total
}

// functions_test.go
func BenchmarkSumSlice(b *testing.B) {
    items := make([]int, 1000)
    for i := range items {
        items[i] = i
    }

    b.ResetTimer()  // Exclude setup time
    for i := 0; i < b.N; i++ {
        SumSlice(items)
    }
}

// Running specific benchmark
// go test -bench=BenchmarkSumSlice -benchmem ./...

Benchmark Comparison

func BenchmarkStringConcat(b *testing.B) {
    parts := []string{"a", "b", "c", "d", "e"}

    b.Run("plus", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            result := ""
            for _, p := range parts {
                result += p
            }
            _ = result
        }
    })

    b.Run("strings.Join", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            result := strings.Join(parts, "")
            _ = result
        }
    })

    b.Run("strings.Builder", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            var sb strings.Builder
            for _, p := range parts {
                sb.WriteString(p)
            }
            _ = sb.String()
        }
    })
}

// Example output:
// BenchmarkStringConcat/plus-8         5000000   312 ns/op
// BenchmarkStringConcat/strings.Join-8 10000000  152 ns/op
// BenchmarkStringConcat/Builder-8      10000000  134 ns/op

Subtests

func TestUser(t *testing.T) {
    // Setup shared across subtests
    db := setupTestDB(t)

    t.Run("create", func(t *testing.T) {
        u, err := CreateUser(db, "Alice", "alice@test.com")
        if err != nil {
            t.Fatal(err)
        }
        if u.Name != "Alice" {
            t.Errorf("got name %q; want %q", u.Name, "Alice")
        }
    })

    t.Run("find", func(t *testing.T) {
        u, err := FindUser(db, 1)
        if err != nil {
            t.Fatal(err)
        }
        if u == nil {
            t.Fatal("user not found")
        }
    })

    t.Run("delete", func(t *testing.T) {
        err := DeleteUser(db, 1)
        if err != nil {
            t.Fatal(err)
        }
    })
}

Test Fixtures

// testdata/testdata.go
package testdata

func LoadTestUsers() []User {
    return []User{
        {Name: "Alice", Age: 30},
        {Name: "Bob", Age: 25},
    }
}

// test file
func TestWithFixtures(t *testing.T) {
    users := testdata.LoadTestUsers()
    if len(users) != 2 {
        t.Fatal("expected 2 users")
    }
}

Common Mistakes

1. Not Using t.Helper()

func assertEqual(t *testing.T, got, want interface{}) {
    // Without t.Helper(), failure line points here, not the caller
    t.Helper()
    if got != want {
        t.Errorf("got %v; want %v", got, want)
    }
}

2. Using t.Error Instead of t.Fatal in Setup

// Bad — continues on failure
db, err := connectDB()
if err != nil {
    t.Error(err)  // Test continues without valid DB
}

// Good — stops immediately
db, err := connectDB()
if err != nil {
    t.Fatal(err)  // Stops test
}

3. Not Running Tests in Clean State

// Tests sharing state without cleanup
var cache map[string]string

func TestCache(t *testing.T) {
    // Bad — previous test's data persists
    cache = make(map[string]string)  // Should be in setup
}

4. Only Testing the Happy Path

// Bad — no error cases
func TestDivide(t *testing.T) {
    result, _ := Divide(10, 2)
    assertEqual(t, result, 5)
}

// Good — test errors too
func TestDivide_Error(t *testing.T) {
    _, err := Divide(10, 0)
    if err == nil {
        t.Error("expected error for division by zero")
    }
}

5. Not Using -count=1 for Cached Results

# Go caches test results. Use -count=1 to disable:
go test -count=1 ./...

Practice Questions

1. How do you write a table-driven test?

Define a slice of test cases (struct with name, inputs, expected outputs), iterate with t.Run(tt.name, ...), and check results. This pattern is idiomatic Go.

2. What's the difference between t.Error and t.Fatal?

t.Error marks the test as failed but continues execution. t.Fatal marks as failed and stops the current test immediately. Use t.Fatal in setup; use t.Error for assertions.

3. How do you run a specific test?

go test -run TestAdd runs tests matching "TestAdd". go test -run TestAdd/positive runs the "positive" subtest. -run takes a regex pattern.

4. How do you measure test coverage?

go test -cover shows coverage percentage. go test -coverprofile=out generates a profile. go tool cover -html=out opens an HTML visualization showing covered/uncovered lines.

Challenge: Write a comprehensive test suite for a StringSet type (a set of strings) including table-driven tests for all operations and a benchmark comparing different implementations.

Solution
// stringset.go
package stringset

type StringSet struct {
    items map[string]struct{}
}

func New() *StringSet {
    return &StringSet{items: make(map[string]struct{})}
}

func (s *StringSet) Add(item string) {
    s.items[item] = struct{}{}
}

func (s *StringSet) Remove(item string) {
    delete(s.items, item)
}

func (s *StringSet) Contains(item string) bool {
    _, ok := s.items[item]
    return ok
}

func (s *StringSet) Size() int {
    return len(s.items)
}

func (s *StringSet) Items() []string {
    result := make([]string, 0, len(s.items))
    for item := range s.items {
        result = append(result, item)
    }
    return result
}

// stringset_test.go
package stringset

import "testing"

func TestStringSet(t *testing.T) {
    tests := []struct {
        name     string
        ops      func(s *StringSet)
        check    func(t *testing.T, s *StringSet)
    }{
        {
            name: "add and contains",
            ops:  func(s *StringSet) { s.Add("a"); s.Add("b"); s.Add("a") },
            check: func(t *testing.T, s *StringSet) {
                if !s.Contains("a") { t.Error("expected contains a") }
                if !s.Contains("b") { t.Error("expected contains b") }
                if s.Contains("c") { t.Error("unexpected contains c") }
            },
        },
        {
            name: "remove",
            ops:  func(s *StringSet) { s.Add("a"); s.Add("b"); s.Remove("a") },
            check: func(t *testing.T, s *StringSet) {
                if s.Contains("a") { t.Error("unexpected contains a") }
                if !s.Contains("b") { t.Error("expected contains b") }
            },
        },
        {
            name: "size",
            ops:  func(s *StringSet) { s.Add("a"); s.Add("b"); s.Add("c") },
            check: func(t *testing.T, s *StringSet) {
                if s.Size() != 3 { t.Errorf("got size %d; want 3", s.Size()) }
            },
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            s := New()
            if tt.ops != nil {
                tt.ops(s)
            }
            tt.check(t, s)
        })
    }
}

func BenchmarkStringSet(b *testing.B) {
    elements := []string{"a", "b", "c", "d", "e"}
    s := New()

    b.Run("add", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            s.Add(elements[i%len(elements)])
        }
    })

    b.Run("contains", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            s.Contains(elements[i%len(elements)])
        }
    })
}
go test -v -bench=. -cover

FAQ

{{< faq question="What file naming convention do tests use?" >}} Files ending with _test.go. Example: math_test.go tests math.go. Test functions start with Test. Benchmark functions start with Benchmark. {{< /faq >}}

{{< faq question="Can I put tests in a different package?" >}} Yes. Use package math_test for external tests (black-box) or package math for internal tests (white-box). External tests can only access exported identifiers. {{< /faq >}}

{{< faq question="How do I test HTTP handlers?" >}} Use httptest.NewRecorder and httptest.NewServer. Create a request, call the handler, inspect the response recorder. No need to start a real server. {{< /faq >}}

{{< faq question="What is go test -short?" >}} A flag for tests to skip long-running or integration tests. Check testing.Short() in your test and call t.Skip("skipping in short mode"). {{< /faq >}}

{{< faq question="How do I test main()?" >}} Rarely needed. Extract logic into functions and test those. If needed, use os.Args manipulation and call main() directly. {{< /faq >}}

Try It Yourself

// calculator.go
package calculator

import "errors"

func Add(a, b float64) float64 { return a + b }
func Subtract(a, b float64) float64 { return a - b }
func Multiply(a, b float64) float64 { return a * b }
func Divide(a, b float64) (float64, error) {
    if b == 0 { return 0, errors.New("division by zero") }
    return a / b, nil
}

// calculator_test.go
package calculator

import "testing"

func TestOperations(t *testing.T) {
    tests := []struct {
        name string
        op   func() (float64, error)
        want float64
    }{
        {name: "add", op: func() (float64, error) { return Add(5, 3), nil }, want: 8},
        {name: "subtract", op: func() (float64, error) { return Subtract(10, 4), nil }, want: 6},
        {name: "multiply", op: func() (float64, error) { return Multiply(3, 7), nil }, want: 21},
        {name: "divide", op: func() (float64, error) { return Divide(15, 3) }, want: 5},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := tt.op()
            if err != nil { t.Fatal(err) }
            if got != tt.want { t.Errorf("got %v; want %v", got, tt.want) }
        })
    }
}

func TestDivideByZero(t *testing.T) {
    _, err := Divide(5, 0)
    if err == nil { t.Error("expected error") }
}
go test -v -cover

What's Next

Now that you understand testing, learn about goroutines for concurrent programming in Go.

Topic Description Link
Go Goroutines Concurrency, goroutines, sync {{< ref "17-goroutines" >}}
Go Channels Communication, buffering, select {{< ref "18-channels" >}}
Rust Testing Compare Rust's testing framework Rust

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro

Home Browse Go