Go: Go Testing

Go's standard library comes with a built-in testing إطار عمل—no third-party testing libraries or assert libraries are needed; you can write high-quality tests using just the basic testing package.

Go's built-in testing toolchain is unique in the industry: go test automatically detects test functions, table-driven testing is a hallmark of the Go community, and benchmarks and code coverage are available right out of the box. In this lesson, you'll master all the core aspects of Go testing.

1. You will learn



2. A True Story of a Refactoring Engineer

(1) Pain point: Changing one دالة caused three modules to crash simultaneously

Alice is a واجهة خلفية engineer on the payments team. She has been asked to refactor the tax calculation logic in the payments module:

"I just changed the name of a field, thinking it would be fine. But after the release, 30% of the orders had incorrect tax calculations—the PM said we lost $5,000. Since we didn't test it, no one knew where they had made the change."

She opened the payment module's code and discovered that the entire project had zero test files:

GO
// payment.go — no corresponding payment_test.go
func CalculateTax(amount float64, country string) float64 {
    // No one knows if this function is correct—there are no tests
    switch country {
    case "US":
        return amount * 0.08
    case "CN":
        return amount * 0.13
    default:
        return amount * 0.10
    }
}

After running the test, Alice immediately spotted the problem—she had previously set the tax rate for "UK" to 20% instead of the correct VAT rate of 20% (it was actually 20%, but under the edge case where amount=0, it returned NaN).

(2) Go Solution: Built-in Testing Framework

GO
// payment_test.go
package main

import "testing"

// Table-driven test
func TestCalculateTax(t *testing.T) {
    tests := []struct {
        name    سلسلة
        amount  float64
        country سلسلة
        want    float64
    }{
        {"US standard", 100.0, "US", 8.0},
        {"CN standard", 100.0, "CN", 13.0},
        {"UK default", 100.0, "UK", 10.0},
        {"zero amount", 0.0, "US", 0.0},
        {"negative amount", -50.0, "US", -4.0},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := CalculateTax(tt.amount, tt.country)
            if got != tt.want {
                t.Errorf("CalculateTax(%v, %s) = %v, want %v",
                    tt.amount, tt.country, got, tt.want)
            }
        })
    }
}

Run Test:

BASH
$ go test -v
=== RUN   TestCalculateTax/US_standard
=== RUN   TestCalculateTax/CN_standard
=== RUN   TestCalculateTax/UK_default
=== RUN   TestCalculateTax/zero_amount
=== RUN   TestCalculateTax/negative_amount
--- PASS: TestCalculateTax (0.00s)
    --- PASS: TestCalculateTax/US_standard (0.00s)
    --- PASS: TestCalculateTax/CN_standard (0.00s)
    --- PASS: TestCalculateTax/UK_default (0.00s)
    --- PASS: TestCalculateTax/zero_amount (0.00s)
    --- PASS: TestCalculateTax/negative_amount (0.00s)
PASS
ok      payment 0.123s

(3) Results: With Testing vs. Without Testing

Dimension No test Test
Rebuilding Confidence Afraid to Change Even a Single Line Run go test Immediately After Making Changes
Positioning Issues User Error Reports After Launch Development Phase Failures
Code Quality Intuition Data-Driven
Getting Started Afraid to Make Changes Once You've Made the Changes and Run the Tests, You Can Rest Easy
Return Costs Manual Verification Automation
💡 Tip: The best practice in the Go community is table-driven testing—organizing test cases into tables (slices of structs), with one subtest per case. This pattern is clearer and more scalable than دالة-level testing.



3. testing.T: Unit Testing Basics

(1) Test Function Rules

GO
// Rules:
// 1. File name must end with _test.go
// 2. Function signature must be func TestXxx(t *testing.T)
// 3. Xxx must start with an uppercase letter

// math_test.go
package main

import "testing"

func TestAdd(t *testing.T) {
    got := Add(2, 3)
    want := 5
    if got != want {
        t.Errorf("Add(2,3) = %d, want %d", got, want)
    }
}

func TestSubtract(t *testing.T) {
    got := Subtract(5, 3)
    want := 2
    if got != want {
        t.Errorf("Subtract(5,3) = %d, want %d", got, want)
    }
}

(2) Common Methods in testing.T

Method Action continue?
t.Log(args...) Print a log (displayed only when -v is specified)
t.Error(args...) Mark as failed + continue execution
t.Errorf(format, args...) Formatted error
t.Fatal(args...) Mark as failed + Stop the current test
t.Fatalf(format, args...) Formatted Fatal
t.Skip(args...) Skip this test

▶ Example: Four Ways to Write a Test Function

GO 📖 Display only
package main

import (
    "fmt"
    "testing"
)

// Function under test
func Divide(a, b float64) (float64, خطأ) {
    if b == 0 {
        return 0, ErrDivisionByZero
    }
    return a / b, nil
}

var ErrDivisionByZero = fmt.Errorf("division by zero")

// Style 1: Simple assertion
func TestDivideBasic(t *testing.T) {
    result, err := Divide(10, 2)
    if err != nil {
        t.Fatalf("unexpected خطأ: %v", err)
    }
    if result != 5.0 {
        t.Errorf("got %f, want %f", result, 5.0)
    }
}

// Style 2: Error vs Fatal
func TestDivideByZero(t *testing.T) {
    _, err := Divide(10, 0)
    if err == nil {
        t.Fatal("expected خطأ, got nil")
    }
    if err.Error() != "division by zero" {
        t.Errorf("wrong خطأ message: %v", err)
    }
}

// Style 3: Table-driven test
func TestDivideTable(t *testing.T) {
    tests := []struct {
        name   سلسلة
        a, b   float64
        want   float64
        wantErr bool
    }{
        {"10/2", 10, 2, 5, false},
        {"0/5", 0, 5, 0, false},
        {"-6/3", -6, 3, -2, false},
        {"1/0", 1, 0, 0, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := Divide(tt.a, tt.b)
            if tt.wantErr {
                if err == nil {
                    t.Error("expected خطأ")
                }
                return
            }
            if err != nil {
                t.Fatalf("unexpected خطأ: %v", err)
            }
            if got != tt.want {
                t.Errorf("got %f, want %f", got, tt.want)
            }
        })
    }
}
60 logic lines (exceeds 40-line limit, display only)

4. Table-Driven Testing (Go's Signature Style)

(1) Standard Template

GO
package main

import "testing"

func TestMax(t *testing.T) {
    // 1. Define test table
    tests := []struct {
        name string     // Test name (for subtests)
        a, b int        // Input
        want int        // Expected output
    }{
        {name: "first larger", a: 10, b: 3, want: 10},
        {name: "second larger", a: 3, b: 10, want: 10},
        {name: "equal", a: 5, b: 5, want: 5},
        {name: "negative", a: -3, b: -10, want: -3},
        {name: "zero", a: 0, b: 5, want: 5},
    }

    // 2. Iterate through table
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // 3. Execute + assert
            got := Max(tt.a, tt.b)
            if got != tt.want {
                t.Errorf("Max(%d, %d) = %d, want %d",
                    tt.a, tt.b, got, tt.want)
            }
        })
    }
}

func Max(a, b int) int {
    if a > b {
        return a
    }
    return b
}

▶ Example: Table-driven + complex input

GO 📖 Display only
package main

import (
    "testing"
)

// Function under test: validate password strength
type PasswordStrength int

const (
    Weak PasswordStrength = iota
    Medium
    Strong
)

func CheckPassword(pwd string) PasswordStrength {
    if len(pwd) < 6 {
        return Weak
    }
    if len(pwd) >= 12 {
        return Strong
    }
    return Medium
}

func TestCheckPassword(t *testing.T) {
    tests := []struct {
        name string
        pwd  string
        want PasswordStrength
    }{
        {"short", "abc", Weak},
        {"medium 6", "abcdef", Medium},
        {"medium 8", "abcdefgh", Medium},
        {"strong 12", "abcdefghijkl", Strong},
        {"empty", "", Weak},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := CheckPassword(tt.pwd)
            if got != tt.want {
                t.Errorf("CheckPassword(%q) = %v, want %v",
                    tt.pwd, got, tt.want)
            }
        })
    }
}
41 logic lines (exceeds 40-line limit, display only)

5. testing.B Benchmark Test

(1) Benchmark Basics

GO
package main

import (
    "testing"
)

// Benchmark دالة: func BenchmarkXxx(b *testing.B)
func BenchmarkAdd(b *testing.B) {
    a, c := 100, 200
    for i := 0; i < b.N; i++ {
        Add(a, c)
    }
}
BASH
$ go test -bench=.
goos: darwin
goarch: amd64
pkg: example
BenchmarkAdd-8    1000000000    0.25 ns/op
PASS
ok      example 0.3s

▶ Example: Comparing the performance of two سلسلة concatenation methods

GO
// concat_test.go
package main

import (
    "strings"
    "testing"
)

func ConcatPlus(n int) string {
    s := ""
    for i := 0; i < n; i++ {
        s += "a"
    }
    return s
}

func ConcatBuilder(n int) string {
    var sb strings.Builder
    sb.Grow(n)
    for i := 0; i < n; i++ {
        sb.WriteByte('a')
    }
    return sb.String()
}

func BenchmarkConcatPlus(b *testing.B) {
    for i := 0; i < b.N; i++ {
        ConcatPlus(1000)
    }
}

func BenchmarkConcatBuilder(b *testing.B) {
    for i := 0; i < b.N; i++ {
        ConcatBuilder(1000)
    }
}
▶ Try it Yourself
BASH
$ go test -bench=. -benchmem
BenchmarkConcatPlus-8          13134     91238 ns/op   530296 allocs/op
BenchmarkConcatBuilder-8      283321      4221 ns/op       56 allocs/op
💡 Tip: Concatenating with + 1,000 times is 20 times slower than using the Builder and allocates 10,000 times more memory—the -benchmem option lets you see the difference in memory allocation.

(3) Interpreting Benchmark Results

Output Item Meaning
BenchmarkConcatBuilder-8 Test Name-8 (8 CPUs)
283321 b.N = 283321 iterations
4221 ns/op 4221 nanoseconds per operation
56 allocs/op 56 memory allocations per operation


6. TestMain: Test Entry Point

(1) TestMain: setup / teardown

GO
// main_test.go
package main

import (
    "fmt"
    "os"
    "testing"
)

func TestMain(m *testing.M) {
    // Setup (runs once for the entire package)
    fmt.Println("=== Setup: Initializing قاعدة بيانات connection ===")

    // Run all tests
    code := m.Run()

    // Teardown
    fmt.Println("=== Teardown: Closing قاعدة بيانات connection ===")

    os.Exit(code)
}

func TestSomething(t *testing.T) {
    t.Log("Test A")
}

func TestAnother(t *testing.T) {
    t.Log("Test B")
}
BASH
$ go test -v
=== Setup: Initializing database connection ===
=== RUN   TestSomething
    main_test.go:16: Test A
--- PASS: TestSomething (0.00s)
=== RUN   TestAnother
    main_test.go:20: Test B
--- PASS: TestAnother (0.00s)
=== Teardown: Closing database connection ===
PASS
ok      example 0.1s

▶ Example: testing.Helper helper دالة

GO
package main

import "testing"

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

func assertEqual(t testing.TB, got, want interface{}) {
    t.Helper()
    if got != want {
        t.Errorf("got %v, want %v", got, want)
    }
}

func TestWithHelper(t *testing.T) {
    assertEqual(t, Add(2, 3), 5)
    assertEqual(t, Add(0, 0), 0)
    assertEqual(t, Add(-1, 1), 0)
}
▶ Try it Yourself
💡 Tip: t.Helper() ensures that error messages are traced back to the caller's line number, rather than within the helper function itself. This is a key best practice when writing test utility functions.



7. Coverage

(1) Basis for Coverage

GO
// math.go
package main

func IsEven(n int) bool {
    return n%2 == 0
}

func IsPositive(n int) bool {
    return n > 0
}
GO
// math_test.go
package main

import "testing"

func TestIsEven(t *testing.T) {
    tests := []struct {
        n    int
        want bool
    }{
        {2, true},
        {3, false},
    }
    for _, tt := range tests {
        if got := IsEven(tt.n); got != tt.want {
            t.Errorf("IsEven(%d) = %v", tt.n, got)
        }
    }
}
BASH
$ go test -coverprofile=coverage.out
ok      example 0.1s    coverage: 50.0% of statements

$ go tool cover -html=coverage.out  # Open coverage report in browser

(2) go test -cover: Common Commands

Command Function
go test -cover Coverage displayed in the terminal
go test -coverprofile=c.out Output coverage profile file
go tool cover -html=c.out View the visual report in a browser
go test -covermode=count Records the number of times each line is executed
🔥 Common Mistake: 100% code coverage ≠ bug-free code. Code coverage only tells you which lines were executed; it doesn't tell you whether the logic is correct. The goal of writing tests isn't to achieve a specific code coverage number, but to gain confidence in the critical path.



8. httptest HTTP Testing

(1) httptest.Server + httptest.ResponseRecorder

GO
package main

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
)

// Handler under test
type UserHandler struct{}

func (h UserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    resp := map[string]string{"status": "ok", "message": "hello"}
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(resp)
}

// Test 1: httptest.ResponseRecorder (testing the handler itself)
func TestUserHandler(t *testing.T) {
    req := httptest.NewRequest("GET", "/", nil)
    rec := httptest.NewRecorder()

    handler := UserHandler{}
    handler.ServeHTTP(rec, req)

    if rec.Code != http.StatusOK {
        t.Errorf("got status %d, want %d", rec.Code, http.StatusOK)
    }

    var resp map[string]string
    json.Unmarshal(rec.Body.Bytes(), &resp)
    if resp["status"] != "ok" {
        t.Errorf("got status %q, want %q", resp["status"], "ok")
    }
}

// Test 2: httptest.Server (testing a complete HTTP service)
func TestUserHandlerWithServer(t *testing.T) {
    server := httptest.NewServer(UserHandler{})
    defer server.Close()

    resp, err := http.Get(server.URL)
    if err != nil {
        t.Fatal(err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        t.Errorf("got %d, want %d", resp.StatusCode, http.StatusOK)
    }
}

▶ Example: httptest + table-driven

GO 📖 Display only
package main

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
)

// Handler under test
func greetingHandler(w http.ResponseWriter, r *http.Request) {
    name := r.URL.Query().Get("name")
    if name == "" {
        name = "World"
    }
    resp := map[string]string{"message": "Hello, " + name + "!"}
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(resp)
}

func TestGreetingHandler(t *testing.T) {
    tests := []struct {
        name   string
        query  string
        want   string
        status int
    }{
        {"with name", "name=Alice", "Hello, Alice!", 200},
        {"empty name", "", "Hello, World!", 200},
        {"with special chars", "name=Go+Lang", "Hello, Go Lang!", 200},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            req := httptest.NewRequest("GET", "/?"+tt.query, nil)
            rec := httptest.NewRecorder()

            greetingHandler(rec, req)

            if rec.Code != tt.status {
                t.Errorf("status = %d, want %d", rec.Code, tt.status)
            }

            var resp map[string]string
            json.Unmarshal(rec.Body.Bytes(), &resp)
            if resp["message"] != tt.want {
                t.Errorf("message = %q, want %q", resp["message"], tt.want)
            }
        })
    }
}
43 logic lines (exceeds 40-line limit, display only)

(3) Two modes of httptest

Mode httptest.NewRecorder httptest.NewServer
Test Subject Single Handler Complete HTTP Service
Startup overhead None Yes (listens on a random port)
Use Cases Unit Testing Integration Testing
Can middleware be tested? ✅ Manually constructed ✅ Automatically through the entire pipeline


9. Complete Example: Refactoring the Payment Module Test Suite

GO
// payment_test.go
package main

import (
    "encoding/json"
    "errors"
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
)

// ---------- Code under test ----------

type PaymentRequest struct {
    UserID  string  `json:"user_id"`
    Amount  float64 `json:"amount"`
    Country string  `json:"country"`
}

type PaymentResponse struct {
    Success bool   `json:"success"`
    Message string `json:"message,omitempty"`
    Tax     float64 `json:"tax,omitempty"`
    Total   float64 `json:"total,omitempty"`
}

func CalculateTax(amount float64, country string) (float64, error) {
    if amount < 0 {
        return 0, errors.New("negative amount")
    }
    switch country {
    case "US":
        return amount * 0.08, nil
    case "CN":
        return amount * 0.13, nil
    case "DE":
        return amount * 0.19, nil
    default:
        return amount * 0.10, nil
    }
}

func paymentHandler(w http.ResponseWriter, r *http.Request) {
    var req PaymentRequest
    json.NewDecoder(r.Body).Decode(&req)

    tax, err := CalculateTax(req.Amount, req.Country)
    if err != nil {
        json.NewEncoder(w).Encode(PaymentResponse{
            Success: false,
            Message: err.Error(),
        })
        return
    }

    json.NewEncoder(w).Encode(PaymentResponse{
        Success: true,
        Tax:     tax,
        Total:   req.Amount + tax,
    })
}

// ---------- Test code ----------

func jsonBody(s string) *strings.Reader {
    return strings.NewReader(s)
}

// 1. Unit test: CalculateTax table-driven
func TestCalculateTax(t *testing.T) {
    tests := []struct {
        name    string
        amount  float64
        country string
        want    float64
        wantErr bool
    }{
        {"US $100", 100, "US", 8.0, false},
        {"CN $100", 100, "CN", 13.0, false},
        {"DE $100", 100, "DE", 19.0, false},
        {"UK default", 100, "UK", 10.0, false},
        {"zero amount", 0, "US", 0, false},
        {"negative amount", -100, "US", 0, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := CalculateTax(tt.amount, tt.country)
            if tt.wantErr {
                if err == nil {
                    t.Error("expected error")
                }
                return
            }
            if err != nil {
                t.Fatalf("unexpected error: %v", err)
            }
            if got != tt.want {
                t.Errorf("CalculateTax(%v, %s) = %v, want %v",
                    tt.amount, tt.country, got, tt.want)
            }
        })
    }
}

// 2. Benchmark: different ways to calculate tax
func BenchmarkCalculateTax(b *testing.B) {
    for i := 0; i < b.N; i++ {
        CalculateTax(100.0, "US")
    }
}

// 3. HTTP test: paymentHandler
func TestPaymentHandler(t *testing.T) {
    tests := []struct {
        name        string
        body        string
        wantStatus  int
        wantSuccess bool
    }{
        {"US payment", `{"user_id":"u1","amount":100,"country":"US"}`, 200, true},
        {"CN payment", `{"user_id":"u2","amount":200,"country":"CN"}`, 200, true},
        {"negative amount", `{"user_id":"u3","amount":-50,"country":"US"}`, 200, false},
        {"invalid JSON", `not json`, 200, false},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            req := httptest.NewRequest("POST", "/pay",
                jsonBody(tt.body))
            req.Header.Set("Content-Type", "application/json")
            rec := httptest.NewRecorder()

            paymentHandler(rec, req)

            if rec.Code != tt.wantStatus {
                t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
            }

            var resp PaymentResponse
            json.Unmarshal(rec.Body.Bytes(), &resp)
            if resp.Success != tt.wantSuccess {
                t.Errorf("success = %v, want %v", resp.Success, tt.wantSuccess)
            }
        })
    }
}
100%
sequenceDiagram
    participant Dev
    participant Terminal as go test
    participant Package as Package Under Test
    participant Coverage as coverage.out

    Dev->>Terminal: go test -v -cover
    Terminal->>Package: 1. Find *_test.go
    Terminal->>Package: 2. Call TestMain(m)
    Package->>Package: 3. Setup
    Package->>Package: 4. Execute all TestXxx
    Package->>Package: 5. Execute BenchmarkXxx
    Package->>Package: 6. Teardown
    Package-->>Terminal: PASS / FAIL
    Terminal-->>Coverage: Coverage data
    Terminal-->>Dev: Result summary
🔥 Common Mistake: Test files must have the _test.go extension; otherwise, go test will not run. The function signature must strictly be func TestXxx(t *testing.T)—the parameter is *testing.T, not *testing.TT or testing.T.


❓ FAQ

Q What are the core functions in the testing package?
A testing.T (unit tests), testing.B (benchmark tests), testing.M (test entry point), testing.Helper() (helper function annotation), testing.Short() (skips long tests).
Q How do I write table-driven tests?
A Define a []struct as the test table, where each case contains name + input + expected output. Iterate through the table and use t.Run(tt.name, ...) to execute subtests. The Go community considers this the standard way to write tests.
Q How do I run a benchmark?
A go test -bench=. runs all benchmarks; go test -bench=FuncName runs a specific function; -benchmem displays memory allocation information. b.N is automatically determined by the framework.
Q How do I check coverage?
A go test -cover displays the percentage; go test -coverprofile=c.out outputs a file; go tool cover -html=c.out provides a browser-based visualization. The standard is 70%+, and 90%+ is recommended for core logic.
Q How do I use httptest to test an HTTP handler?
A There are two ways: httptest.NewRecorder() to test the handler directly (unit testing), and httptest.NewServer(handler) to start a real HTTP server for testing (integration testing). We recommend using the Recorder first.
Q What is the order of Setup and Teardown in TestMain?
A TestMain is executed once before all test functions in the package. Setup is performed before m.Run(), and Teardown is performed afterward. Note: Output is cached, so you must use os.Exit(code) to exit.
Q What does go test cache?
A By default, go test caches test results (based on the code and environment). If there are no changes when you run it again, it displays (cached). Use go test -count=1 to force a re-run.
Q How do I skip certain tests?
A t.Skip("reason") skips the current test; testing.Short() works with go test -short to skip tests that take too long; t.Skipf(format, args...) for formatted skips.

📖 Summary


📝 Exercises

  1. Basic Problem (Difficulty ⭐): Write a table-driven test for max(nums ...int) int from Lesson 4 (Functions), covering the following five cases: positive numbers, negative numbers, a mix of positive and negative numbers, a single element, and empty arguments.

  2. Advanced Exercise (Difficulty ⭐⭐): Write tests for the Exporter from Lesson 10 (File I/O and JSON): Use os.CreateTemp to create a temporary file to test the export; use json.Unmarshal to verify that the content is correct; Use t.Cleanup to clean up the temporary file.

  3. Challenge (Difficulty: ⭐⭐⭐): Write a complete test suite for the multi-payment gateway system from Lesson 7: (1) Table-driven testing for MockPaymentGateway (returns fixed results); (2) Benchmark the speed of the Stripe and PayPal implementations; (3) Use httptest to test the JSON response format of the payment handler; (4) Code coverage ≥ 90%.

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%

🙏 帮我们做得更好

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

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