Testing
Lesson 23: Testing
Life Analogy
Imagine you open a restaurant. Before every dish is served, the chef tastes it first — to confirm the seasoning is right and the cooking is done properly. Software testing is like this "tasting" process: before code is delivered to users, it's verified automatically to ensure it works as expected. Going live without testing is like serving untasted food to customers — sooner or later, problems will arise.
Core Concepts
Go has a complete built-in testing toolchain, with no need for third-party frameworks. Core concepts are as follows:
| Concept | Description |
|---|---|
testing.T |
Unit test context object, used for reporting test failures |
testing.B |
Benchmark test context object, used for measuring performance |
testing.M |
Test entry object, used for TestMain global setup |
*_test.go |
Test file naming convention, only compiled during testing |
go test |
Command-line tool for running tests |
| Table-driven tests | Idiomatic pattern for driving multiple test cases with a data table |
httptest |
Test helper package for HTTP requests |
Basic Syntax and Usage
Test File Conventions
Test files must end with _test.go and be placed in the same package as the code being tested:
myapp/
├── math.go
└── math_test.go
Test Function Signature
func TestXxx(t *testing.T) {
// Test logic
}
Function name must start with Test, and the parameter is *testing.T.
Assertion Methods
Go has no built-in assert function — you need to check manually:
func TestAdd(t *testing.T) {
result := Add(2, 3)
if result != 5 {
t.Errorf("Add(2, 3) = %d, expected 5", result)
}
}
Common methods:
| Method | Purpose |
|---|---|
t.Error() / t.Errorf() |
Report error, continue execution |
t.Fatal() / t.Fatalf() |
Report error, immediately terminate current test |
t.Skip() / t.Skipf() |
Skip current test |
t.Log() / t.Logf() |
Output log (only shown with -v) |
t.Run(name, func) |
Run subtest |
t.Helper() |
Mark as helper function, error reports point to caller |
💡 Tip 2: Use t.Helper() to mark your assertion helper functions, so failures report the caller's location instead of inside the helper function.
💡 Tip 3: go test -v shows detailed output, go test -run=regex only runs matching tests, -count=1 disables caching.
Running Tests
# Run all tests in the current package
go test
# Detailed output
go test -v
# Run tests matching a name
go test -run TestAdd
# Run in current directory and subdirectories
go test ./...
# Show coverage
go test -cover
Examples
Example: Basic Unit Tests (Difficulty ⭐)
File structure:
calculator/
├── calc.go
└── calc_test.go
calc.go:
package calculator
// Add returns the sum of two numbers
func Add(a, b int) int {
return a + b
}
// Divide returns the quotient and whether there is an error
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("divisor cannot be zero")
}
return a / b, nil
}
calc_test.go:
package calculator
import (
"testing"
)
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2, 3) = %d, expected %d", got, want)
}
}
func TestDivide(t *testing.T) {
got, err := Divide(10, 3)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
want := 3.3333333333333335
if got != want {
t.Errorf("Divide(10, 3) = %f, expected %f", got, want)
}
}
func TestDivideByZero(t *testing.T) {
_, err := Divide(10, 0)
if err == nil {
t.Error("Dividing by zero should return an error")
}
}
Output:
$ go test -v
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
=== RUN TestDivide
--- PASS: TestDivide (0.00s)
=== RUN TestDivideByZero
--- PASS: TestDivideByZero (0.00s)
PASS
ok calculator 0.003s
Example: Table-Driven Tests and Subtests (Difficulty ⭐⭐)
Table-driven tests are the most praised testing pattern in the Go community — put inputs and expected outputs in a slice, then loop and verify.
stringutil.go:
package stringutil
import "unicode"
// Reverse reverses a string
func Reverse(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}
// IsPalindrome checks if a string is a palindrome
func IsPalindrome(s string) bool {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
if unicode.ToLower(runes[i]) != unicode.ToLower(runes[j]) {
return false
}
}
return true
}
stringutil_test.go:
package stringutil
import "testing"
func TestReverse(t *testing.T) {
// Define test case table
tests := []struct {
name string // Case name
input string
want string
}{
{"empty string", "", ""},
{"single character", "a", "a"},
{"normal string", "hello", "olleh"},
{"Chinese", "SalutonMondo", "odnoMnotulaS"},
{"palindrome", "racecar", "racecar"},
}
for _, tt := range tests {
// t.Run creates a subtest, each case runs independently
t.Run(tt.name, func(t *testing.T) {
got := Reverse(tt.input)
if got != tt.want {
t.Errorf("Reverse(%q) = %q, expected %q", tt.input, got, tt.want)
}
})
}
}
func TestIsPalindrome(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
{"English palindrome", "racecar", true},
{"palindrome", "racecar", true},
{"not palindrome", "hello", false},
{"case insensitive", "RaceCar", true},
{"empty string", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsPalindrome(tt.input)
if got != tt.want {
t.Errorf("IsPalindrome(%q) = %v, expected %v", tt.input, got, tt.want)
}
})
}
}
Running a specific subtest:
# Only run the Chinese palindrome case
$ go test -v -run=TestIsPalindrome/Chinese_palindrome
=== RUN TestIsPalindrome/Chinese_palindrome
--- PASS: TestIsPalindrome/Chinese_palindrome (0.00s)
PASS
Example: Benchmark, TestMain, and httptest (Difficulty ⭐⭐⭐)
handler.go:
package handler
import (
"encoding/json"
"net/http"
)
type Response struct {
Message string `json:"message"`
Code int `json:"code"`
}
// HelloHandler handles /hello requests
func HelloHandler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
name = "World"
}
resp := Response{
Message: "Hello, " + name,
Code: 200,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
handler_test.go:
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
)
// TestMain runs before all tests, can be used for global setup and teardown
func TestMain(m *testing.M) {
// Here you could set up database connections, initialize config, etc.
setup()
// Run all tests
code := m.Run()
// Clean up resources
teardown()
// Use test result as exit code
os.Exit(code)
}
func setup() {
// Initialization code (e.g., load test config)
}
func teardown() {
// Cleanup code (e.g., close database connection)
}
func TestHelloHandler(t *testing.T) {
tests := []struct {
name string
queryParam string
wantMsg string
wantCode int
}{
{"default name", "", "Hello, World", 200},
{"custom name", "Go", "Hello, Go", 200},
{"custom name", "Alex", "Hello, Alex", 200},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Construct request
url := "/hello"
if tt.queryParam != "" {
url += "?name=" + tt.queryParam
}
req := httptest.NewRequest(http.MethodGet, url, nil)
// Create response recorder
rr := httptest.NewRecorder()
// Call handler
HelloHandler(rr, req)
// Verify status code
if rr.Code != tt.wantCode {
t.Errorf("Status code = %d, expected %d", rr.Code, tt.wantCode)
}
// Verify response body
var resp Response
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if resp.Message != tt.wantMsg {
t.Errorf("Message = %q, expected %q", resp.Message, tt.wantMsg)
}
})
}
}
// BenchmarkHelloHandler benchmark test, measures handler performance
func BenchmarkHelloHandler(b *testing.B) {
req := httptest.NewRequest(http.MethodGet, "/hello?name=Go", nil)
// b.N is automatically adjusted by the test framework to ensure stable results
for i := 0; i < b.N; i++ {
rr := httptest.NewRecorder()
HelloHandler(rr, req)
}
}
// BenchmarkReverse benchmarks string reversal
func BenchmarkReverse(b *testing.B) {
s := "Hello, World! This is a test string"
for i := 0; i < b.N; i++ {
_ = Reverse(s) // Assuming Reverse is in the same package
}
}
Running benchmarks:
$ go test -bench=. -benchmem -count=3
goos: linux
goarch: amd64
pkg: handler
BenchmarkHelloHandler-8 200000 7523 ns/op 1248 B/op 18 allocs/op
BenchmarkHelloHandler-8 200000 7401 ns/op 1248 B/op 18 allocs/op
BenchmarkHelloHandler-8 200000 7350 ns/op 1248 B/op 18 allocs/op
PASS
ok handler 4.832s
Viewing coverage:
# Generate coverage report
$ go test -coverprofile=coverage.out
# View coverage per function in terminal
$ go tool cover -func=coverage.out
total: (statements) 85.7%
# Generate HTML report (open in browser)
$ go tool cover -html=coverage.out -o coverage.html
Scenario Applications
Scenario 1: Testing Middleware
In web development, middleware like authentication and logging needs to be tested independently:
package middleware
import (
"net/http"
"strings"
)
// AuthMiddleware checks Token in request headers
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if !strings.HasPrefix(token, "Bearer ") {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
Testing:
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestAuthMiddleware(t *testing.T) {
// Create a simple downstream handler
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("Passed"))
})
middleware := AuthMiddleware(nextHandler)
t.Run("no token should return 401", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/data", nil)
rr := httptest.NewRecorder()
middleware.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("Status code = %d, expected %d", rr.Code, http.StatusUnauthorized)
}
})
t.Run("valid token should pass", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/data", nil)
req.Header.Set("Authorization", "Bearer my-secret-token")
rr := httptest.NewRecorder()
middleware.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("Status code = %d, expected %d", rr.Code, http.StatusOK)
}
})
t.Run("invalid prefix should return 401", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/data", nil)
req.Header.Set("Authorization", "Basic abc123")
rr := httptest.NewRecorder()
middleware.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("Status code = %d, expected %d", rr.Code, http.StatusUnauthorized)
}
})
}
Scenario 2: Testing Database Operations (Interface Isolation)
Isolate database dependencies through interfaces, injecting mocks during testing:
package user
import "context"
// UserRepository defines the user data access interface
type UserRepository interface {
GetByID(ctx context.Context, id int) (*User, error)
Create(ctx context.Context, user *User) error
}
// User model
type User struct {
ID int
Name string
}
// Service user business logic
type Service struct {
repo UserRepository
}
// NewService creates a user service
func NewService(repo UserRepository) *Service {
return &Service{repo: repo}
}
// GetUser gets a user, returns error if not found
func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
if id <= 0 {
return nil, fmt.Errorf("invalid user ID: %d", id)
}
return s.repo.GetByID(ctx, id)
}
Mock and Testing:
package user
import (
"context"
"testing"
)
// mockRepo implements UserRepository interface for testing
type mockRepo struct {
users map[int]*User
}
func (m *mockRepo) GetByID(_ context.Context, id int) (*User, error) {
if u, ok := m.users[id]; ok {
return u, nil
}
return nil, fmt.Errorf("user %d does not exist", id)
}
func (m *mockRepo) Create(_ context.Context, user *User) error {
m.users[user.ID] = user
return nil
}
func TestGetUser(t *testing.T) {
mock := &mockRepo{
users: map[int]*User{
1: {ID: 1, Name: "Alice"},
2: {ID: 2, Name: "Bob"},
},
}
svc := NewService(mock)
t.Run("existing user", func(t *testing.T) {
user, err := svc.GetUser(context.Background(), 1)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if user.Name != "Alice" {
t.Errorf("Name = %q, expected %q", user.Name, "Alice")
}
})
t.Run("non-existing user", func(t *testing.T) {
_, err := svc.GetUser(context.Background(), 99)
if err == nil {
t.Error("Expected error to be returned")
}
})
t.Run("invalid ID", func(t *testing.T) {
_, err := svc.GetUser(context.Background(), -1)
if err == nil {
t.Error("Expected error to be returned")
}
})
}
❓ FAQ
Q1: What if test files are not recognized?
Make sure the file name ends with _test.go and the package name is correct. Test files are ignored during go build and only compiled during go test. If the test file is in a _test package (i.e., package foo_test), it can only test exported identifiers.
Q2: How to only run failed tests?
Use the -run parameter with a regex:
# Run a specific test by name
go test -run TestDivideByZero -v
# If using go test -v, failed test names will be shown in output
You can also combine with -count=1 to disable caching and ensure tests actually re-run:
go test -run TestSomething -count=1 -v
Q3: What's the difference between t.Fatal and t.Error?
t.Error()/t.Errorf(): Reports error, continues executing remaining code in the current test function.t.Fatal()/t.Fatalf(): Reports error, immediately terminates the current test function.
General principle: If subsequent assertions depend on previous results (e.g., checking err before dereferencing a pointer), use Fatal; for independent multiple assertions, use Error.
Q4: How to skip time-consuming tests?
func TestSlowOperation(t *testing.T) {
if testing.Short() {
t.Skip("Skipping time-consuming test (use -short flag)")
}
// Time-consuming operation...
}
Run with the -short flag to skip:
go test -short
📖 Summary
This lesson covered the core content of Go testing:
- Test Functions: Start with
Test, parameter*testing.T, useError/Fatalto report failures - Table-Driven Tests: Define cases with a slice, loop +
t.Runto run — the idiomatic Go community pattern - Subtests:
t.Run(name, func)lets cases run independently, making it easy to locate issues - Benchmark Tests: Start with
Benchmark, parameter*testing.B, loopb.Ntimes to measure performance - TestMain: Global entry point for setup/teardown, calls
m.Run()to execute all tests - httptest:
httptest.NewRequest+httptest.NewRecorderfor testing HTTP handlers - Coverage:
go test -coverto view,-coverprofileto generate detailed reports
📝 Exercises
Exercise 1: Write Tests for String Processing Functions
Write tests for the following function, containing at least 5 table-driven cases:
// Truncate truncates a string to the specified length, replacing excess with "..."
func Truncate(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen-3]) + "..."
}
Exercise 2: Write Benchmarks for HTTP Handlers
Write benchmarks for an API that returns a JSON array, comparing the performance difference between json.Marshal and json.Encoder:
// ListHandler returns a user list
func ListHandler(w http.ResponseWriter, r *http.Request) {
users := []User{{1, "Alice"}, {2, "Bob"}, {3, "Charlie"}}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
}
Requirements:
- Write two Benchmarks, one using
json.Marshaland one usingjson.NewEncoder - Use
-benchmemto compare memory allocations
Exercise 3: Use TestMain to Implement Test Database
Design a testing solution using TestMain:
- Create a temporary SQLite database in
TestMain - Run migrations to create tables
- Execute all tests
- Delete the temporary database after tests complete
Hint: Use the return value of m.Run() as the parameter for os.Exit().



