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

GO
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:

GO
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 1: Test function parameters and return values should be as simple as possible for easy verification. Use table-driven tests for complex inputs.

💡 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

BASH
# 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
▶ Try it Yourself

calc.go:

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:

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:

BASH
$ 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:

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
}
▶ Try it Yourself

stringutil_test.go:

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:

BASH
# Only run the Chinese palindrome case
$ go test -v -run=TestIsPalindrome/Chinese_palindrome
=== RUN   TestIsPalindrome/Chinese_palindrome
--- PASS: TestIsPalindrome/Chinese_palindrome (0.00s)
PASS
💡 The benefit of table-driven tests: adding a new case only requires adding a line to the slice, no need to write a new test function.


Example: Benchmark, TestMain, and httptest (Difficulty ⭐⭐⭐)

handler.go:

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)
}
▶ Try it Yourself

handler_test.go:

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:

BASH
$ 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:

BASH
# 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:

GO
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:

GO
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:

GO
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:

GO
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:

BASH
# 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:

BASH
go test -run TestSomething -count=1 -v

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

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?

GO
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:

BASH
go test -short

📖 Summary

This lesson covered the core content of Go testing:

  1. Test Functions: Start with Test, parameter *testing.T, use Error/Fatal to report failures
  2. Table-Driven Tests: Define cases with a slice, loop + t.Run to run — the idiomatic Go community pattern
  3. Subtests: t.Run(name, func) lets cases run independently, making it easy to locate issues
  4. Benchmark Tests: Start with Benchmark, parameter *testing.B, loop b.N times to measure performance
  5. TestMain: Global entry point for setup/teardown, calls m.Run() to execute all tests
  6. httptest: httptest.NewRequest + httptest.NewRecorder for testing HTTP handlers
  7. Coverage: go test -cover to view, -coverprofile to 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:

GO
// 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:

GO
// 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:

Exercise 3: Use TestMain to Implement Test Database

Design a testing solution using TestMain:

  1. Create a temporary SQLite database in TestMain
  2. Run migrations to create tables
  3. Execute all tests
  4. Delete the temporary database after tests complete

Hint: Use the return value of m.Run() as the parameter for os.Exit().


Next Lesson

Next Lesson: Regex and Date →

Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏