Go Course #9: Testing and Benchmarking in Go
Testing and Benchmarking in Go - Part 9 of 10

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.
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
Testfollowed by a descriptive name with a capital first letter - Each test function receives a
*testing.Tparameter
Suppose you have a file math.go with math functions:
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:
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}
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:
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}
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:
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()andt.Errorf(): report the failure but continue running the testt.Fatal()andt.Fatalf(): report the failure and stop the test immediately
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}
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:
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:
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.
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:
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:
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:
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:
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:
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}
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:
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}
Running Tests: Essential Commands
Here is a summary of the most useful commands for running tests in Go:
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 ./...
-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
testingpackage - The table-driven tests pattern, Go's most idiomatic approach
- Subtests with
t.Runto organize and run individual cases - Helpers with
t.Helper()for clear error messages - Coverage with
go test -coverand HTML reports - Benchmarks with
BenchmarkXxxto 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!
Comments
Sign in to leave a comment
No comments yet. Be the first!