Cristhian Villegas
Backend10 min read2 views

Go Course #9: Testing and Benchmarking in Go

Go Course #9: Testing and Benchmarking in Go

Testing and Benchmarking in Go - Part 9 of 10

Go Logo

Welcome to article 9 of 10 in the Go Course from Scratch. So far we have learned to build complete programs with functions, structs, interfaces, goroutines and more. But there is a question every professional developer must answer: how do you know your code actually works?

The answer is testing. Go has first-class support for testing built directly into its standard library, with no need to install external frameworks. In this article you will learn how to write unit tests, table-driven tests, benchmarks and much more.

Note: This is the second-to-last article in the course. In the next one (Part 10 of 10) we will build a complete final project: a REST API.

Why Testing Matters

Writing tests is not optional in professional development. Tests give you the confidence to:

  • Refactor without fear of breaking existing functionality
  • Document the expected behavior of your code
  • Catch bugs before they reach production
  • Collaborate with other developers safely

Go was designed from the start with testing in mind. The testing package is included in the standard library and the go test command is built into the Go toolchain. You do not need anything external to get started.

The testing Package and Conventions

Go has strict conventions for test files:

  • Test files must end in _test.go
  • Test files go in the same package as the code they test
  • Test functions start with Test followed by a descriptive name with a capital first letter
  • Each test function receives a *testing.T parameter

Suppose you have a file math.go with math functions:

go
1// math.go
2package mathutil
3
4// Add returns the sum of two integers.
5func Add(a, b int) int {
6    return a + b
7}
8
9// Factorial calculates the factorial of a non-negative number.
10func Factorial(n int) int {
11    if n <= 1 {
12        return 1
13    }
14    return n * Factorial(n-1)
15}
16
17// IsPrime checks whether a number is prime.
18func IsPrime(n int) bool {
19    if n < 2 {
20        return false
21    }
22    for i := 2; i*i <= n; i++ {
23        if n%i == 0 {
24            return false
25        }
26    }
27    return true
28}

The corresponding test file would be math_test.go:

go
1// math_test.go
2package mathutil
3
4import "testing"
5
6func TestAdd(t *testing.T) {
7    result := Add(2, 3)
8    if result != 5 {
9        t.Errorf("Add(2, 3) = %d; want 5", result)
10    }
11}
12
13func TestFactorial(t *testing.T) {
14    result := Factorial(5)
15    if result != 120 {
16        t.Errorf("Factorial(5) = %d; want 120", result)
17    }
18}
19
20func TestIsPrime(t *testing.T) {
21    if !IsPrime(7) {
22        t.Error("IsPrime(7) should be true")
23    }
24    if IsPrime(4) {
25        t.Error("IsPrime(4) should be false")
26    }
27}
Tip: The test name should describe what is being tested. Use names like TestAdd, TestFactorialWithZero, TestIsPrimeWithNegativeNumbers. This makes it easy to identify what failed when a test does not pass.

Table-Driven Tests: Go's Idiomatic Pattern

One of the most important and common patterns in Go is the table-driven test. Instead of writing one test function per case, you define a table (slice of structs) with all cases and loop through them:

go
1func TestAddTable(t *testing.T) {
2    tests := []struct {
3        name     string
4        a, b     int
5        expected int
6    }{
7        {"positive numbers", 2, 3, 5},
8        {"with zero", 0, 5, 5},
9        {"negative numbers", -1, -2, -3},
10        {"mixed signs", -1, 5, 4},
11        {"large numbers", 1000000, 2000000, 3000000},
12    }
13
14    for _, tt := range tests {
15        t.Run(tt.name, func(t *testing.T) {
16            result := Add(tt.a, tt.b)
17            if result != tt.expected {
18                t.Errorf("Add(%d, %d) = %d; want %d",
19                    tt.a, tt.b, result, tt.expected)
20            }
21        })
22    }
23}
24
25func TestIsPrimeTable(t *testing.T) {
26    tests := []struct {
27        name     string
28        input    int
29        expected bool
30    }{
31        {"negative", -1, false},
32        {"zero", 0, false},
33        {"one", 1, false},
34        {"two", 2, true},
35        {"three", 3, true},
36        {"four", 4, false},
37        {"seven", 7, true},
38        {"ten", 10, false},
39        {"thirteen", 13, true},
40    }
41
42    for _, tt := range tests {
43        t.Run(tt.name, func(t *testing.T) {
44            result := IsPrime(tt.input)
45            if result != tt.expected {
46                t.Errorf("IsPrime(%d) = %v; want %v",
47                    tt.input, result, tt.expected)
48            }
49        })
50    }
51}
Note: t.Run creates subtests. Each subtest has its own name and can be run independently. If a subtest fails, you can see exactly which case failed without checking all the others. You can run a specific subtest with go test -run TestIsPrimeTable/seven.

Helpers and Auxiliary Functions in Tests

When you have repeated logic in your tests, you can extract it into helper functions. Go provides t.Helper() to mark a function as auxiliary, which improves error messages:

go
1func assertEqual(t *testing.T, got, want int) {
2    t.Helper() // marks this function as a helper
3    if got != want {
4        t.Errorf("got %d, want %d", got, want)
5    }
6}
7
8func TestWithHelper(t *testing.T) {
9    assertEqual(t, Add(1, 2), 3)
10    assertEqual(t, Add(0, 0), 0)
11    assertEqual(t, Add(-1, 1), 0)
12}

Without t.Helper(), the error message would point to the line inside assertEqual. With t.Helper(), the message points to the line where assertEqual was called in the test, which is much more useful.

t.Fatal vs t.Error

There is an important difference between the reporting methods:

  • t.Error() and t.Errorf(): report the failure but continue running the test
  • t.Fatal() and t.Fatalf(): report the failure and stop the test immediately
go
1func TestWithFatal(t *testing.T) {
2    conn := GetConnection()
3    if conn == nil {
4        t.Fatal("connection is nil, cannot continue")
5        // The test stops here
6    }
7    // This code only runs if conn is not nil
8    conn.Execute()
9}
Tip: Use t.Fatal when a failure makes it impossible to continue the rest of the test (for example, if a required resource is nil). Use t.Error when you want to report the failure but keep checking other aspects.

Test Coverage

Go includes built-in tools to measure the coverage of your tests. This tells you what percentage of your code is being executed by your tests:

bash
1# See coverage percentage in the terminal
2go test -cover ./...
3
4# Generate a detailed coverage profile
5go test -coverprofile=coverage.out ./...
6
7# View the report in HTML (opens in browser)
8go tool cover -html=coverage.out
9
10# See coverage per function
11go tool cover -func=coverage.out

The output of go test -cover looks like this:

bash
1ok      mathutil    0.003s    coverage: 85.7% of statements

The HTML report is especially useful because it highlights covered lines in green and uncovered lines in red.

Warning: 100% coverage does not guarantee that your code is correct. It is possible to have 100% coverage and still have bugs. What matters is covering critical paths, edge cases and error scenarios.

Benchmarking: Measuring Performance

Go lets you write benchmarks alongside your tests. Benchmarks measure how long a function takes to execute, which is useful for optimizing critical code:

go
1func BenchmarkFactorial(b *testing.B) {
2    for i := 0; i < b.N; i++ {
3        Factorial(20)
4    }
5}
6
7func BenchmarkIsPrime(b *testing.B) {
8    for i := 0; i < b.N; i++ {
9        IsPrime(7919) // a large prime number
10    }
11}
12
13func BenchmarkAdd(b *testing.B) {
14    for i := 0; i < b.N; i++ {
15        Add(1000, 2000)
16    }
17}

To run the benchmarks:

bash
1# Run all benchmarks
2go test -bench=. ./...
3
4# Run a specific benchmark
5go test -bench=BenchmarkFactorial ./...
6
7# Include memory statistics
8go test -bench=. -benchmem ./...

The output looks something like this:

bash
1BenchmarkFactorial-8    5000000    230 ns/op    0 B/op    0 allocs/op
2BenchmarkIsPrime-8      10000000   105 ns/op    0 B/op    0 allocs/op

This tells you that Factorial(20) takes approximately 230 nanoseconds per operation and makes no memory allocations.

Example Functions: Executable Documentation

Go has a special kind of test called an Example. These functions serve as documentation that can be verified automatically:

go
1func ExampleAdd() {
2    fmt.Println(Add(2, 3))
3    fmt.Println(Add(-1, 1))
4    // Output:
5    // 5
6    // 0
7}
8
9func ExampleFactorial() {
10    fmt.Println(Factorial(0))
11    fmt.Println(Factorial(5))
12    fmt.Println(Factorial(10))
13    // Output:
14    // 1
15    // 120
16    // 3628800
17}

The // Output: comment at the end is key. Go runs the function and compares the actual output with the expected one. If they do not match, the test fails. Additionally, these examples appear automatically in the documentation generated by go doc.

Testing HTTP Handlers with httptest

The net/http/httptest package lets you test HTTP handlers without starting a real server:

go
1package main
2
3import (
4    "encoding/json"
5    "net/http"
6    "net/http/httptest"
7    "testing"
8)
9
10func GreetingHandler(w http.ResponseWriter, r *http.Request) {
11    w.Header().Set("Content-Type", "application/json")
12    json.NewEncoder(w).Encode(map[string]string{
13        "message": "Hello, world",
14    })
15}
16
17func TestGreetingHandler(t *testing.T) {
18    // Create a test request
19    req := httptest.NewRequest(http.MethodGet, "/greeting", nil)
20    // Create a ResponseRecorder to capture the response
21    rec := httptest.NewRecorder()
22
23    // Execute the handler
24    GreetingHandler(rec, req)
25
26    // Verify the status code
27    if rec.Code != http.StatusOK {
28        t.Errorf("status = %d; want %d", rec.Code, http.StatusOK)
29    }
30
31    // Verify the Content-Type
32    ct := rec.Header().Get("Content-Type")
33    if ct != "application/json" {
34        t.Errorf("Content-Type = %s; want application/json", ct)
35    }
36
37    // Verify the body
38    var body map[string]string
39    json.NewDecoder(rec.Body).Decode(&body)
40    if body["message"] != "Hello, world" {
41        t.Errorf("message = %s; want Hello, world", body["message"])
42    }
43}
Tip: httptest.NewRecorder() implements http.ResponseWriter, so you can pass it directly to any handler. This makes it very easy to test HTTP endpoints without a real server.

Mocking with Interfaces

Go does not need a complex mocking framework. Thanks to interfaces, you can create mocks simply and naturally:

go
1// Define an interface for the service
2type EmailNotifier interface {
3    Send(to, subject, body string) error
4}
5
6// Real implementation (for production)
7type SMTPNotifier struct {
8    Host string
9}
10
11func (s *SMTPNotifier) Send(to, subject, body string) error {
12    // Real email sending logic...
13    return nil
14}
15
16// Mock for testing
17type MockNotifier struct {
18    CalledWithTo      string
19    CalledWithSubject string
20    ErrorToReturn     error
21}
22
23func (m *MockNotifier) Send(to, subject, body string) error {
24    m.CalledWithTo = to
25    m.CalledWithSubject = subject
26    return m.ErrorToReturn
27}
28
29// Service that uses the interface
30type UserService struct {
31    Notifier EmailNotifier
32}
33
34func (s *UserService) Register(name, email string) error {
35    // ... registration logic ...
36    return s.Notifier.Send(email, "Welcome", "Hello "+name)
37}
38
39// Test using the mock
40func TestRegisterUser(t *testing.T) {
41    mock := &MockNotifier{}
42    service := &UserService{Notifier: mock}
43
44    err := service.Register("Ana", "[email protected]")
45    if err != nil {
46        t.Fatalf("unexpected error: %v", err)
47    }
48    if mock.CalledWithTo != "[email protected]" {
49        t.Errorf("to = %s; want [email protected]",
50            mock.CalledWithTo)
51    }
52    if mock.CalledWithSubject != "Welcome" {
53        t.Errorf("subject = %s; want Welcome",
54            mock.CalledWithSubject)
55    }
56}
Note: This pattern is one of the reasons why interfaces in Go are so powerful. By depending on interfaces instead of concrete implementations, you can swap the real implementation for a mock without changing any production code.

Running Tests: Essential Commands

Here is a summary of the most useful commands for running tests in Go:

bash
1# Run all tests in the current package
2go test
3
4# Run all tests in all packages
5go test ./...
6
7# Verbose mode (shows the name of each test)
8go test -v ./...
9
10# Run only tests matching a pattern
11go test -run TestAdd ./...
12
13# Run a specific subtest
14go test -run TestAddTable/positive_numbers ./...
15
16# Run tests with coverage
17go test -cover ./...
18
19# Run benchmarks (does not run normal tests)
20go test -bench=. -run=^$ ./...
21
22# Run tests with a time limit
23go test -timeout 30s ./...
24
25# Run tests with the race detector
26go test -race ./...
Warning: The -race flag is especially important if you use goroutines. It detects race conditions that could cause intermittent and hard-to-reproduce bugs. Always use it in your CI/CD pipeline.

Summary and Next Article

In this article we learned everything about testing and benchmarking in Go:

  • Go conventions for test files and functions
  • How to write unit tests with the testing package
  • The table-driven tests pattern, Go's most idiomatic approach
  • Subtests with t.Run to organize and run individual cases
  • Helpers with t.Helper() for clear error messages
  • Coverage with go test -cover and HTML reports
  • Benchmarks with BenchmarkXxx to measure performance
  • Example functions as executable documentation
  • Testing HTTP handlers with httptest
  • Mocking with interfaces to isolate dependencies

In the next and final article (Part 10 of 10) we will build a complete final project: a REST API for managing tasks using everything we have learned in this course. Get ready for the grand finale!

Share:
CV

Cristhian Villegas

Software Engineer specializing in Java, Spring Boot, Angular & AWS. Building scalable distributed systems with clean architecture.

Comments

Sign in to leave a comment

No comments yet. Be the first!

Related Articles