編程基礎 0009_testing詳解

Go testing 詳解

目錄

  1. testing 包基礎
  2. 表格驅動測試
  3. 子測試 t.Run
  4. 基準測試 Benchmark
  5. 測試覆蓋率
  6. TestMain
  7. httptest 包
  8. Mock 和接口測試技巧
  9. 模糊測試 Fuzz

1. testing 包基礎

1.1 測試文件和函數命名規則

Go 測試遵循嚴格的命名約定:

  • 測試文件以 _test.go 結尾(如 user_test.go
  • 測試函數以 Test 開頭,接一個大寫字母開頭的名稱
  • 測試函數接受 *testing.T 參數
  • 測試文件和被測文件放在同一個包中
myproject/
├── math.go          # 被測代碼
├── math_test.go     # 測試代碼
├── user.go
└── user_test.go

1.2 第一個測試

// math.go
package mymath

// Add 返回兩個整數的和
func Add(a, b int) int {
    return a + b
}

// Abs 返回絕對值
func Abs(n int) int {
    if n < 0 {
        return -n
    }
    return n
}
// math_test.go
package mymath

import "testing"

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

func TestAbs(t *testing.T) {
    result := Abs(-5)
    if result != 5 {
        t.Errorf("Abs(-5) = %d; 期望 5", result)
    }
}

運行測試:

# 運行當前包的所有測試
go test

# 顯示詳細輸出
go test -v

# 運行匹配特定名稱的測試
go test -run TestAdd

# 運行項目中所有包的測試
go test ./...

1.3 t.Error, t.Fatal, t.Log 的區別

package mymath

import "testing"

func TestErrorVsFatal(t *testing.T) {
    // t.Log 輸出日誌信息(僅在 -v 模式或測試失敗時顯示)
    t.Log("開始測試...")

    // t.Error / t.Errorf 標記測試失敗,但繼續執行後續代碼
    result := Add(1, 1)
    if result != 2 {
        t.Errorf("Add(1,1) = %d; 期望 2", result)
    }
    t.Log("這行代碼在 t.Error 後仍然會執行")

    // t.Fatal / t.Fatalf 標記測試失敗,並立即停止當前測試函數
    if result == 0 {
        t.Fatal("結果為零,無法繼續後續驗證")
        // 下面的代碼不會執行
    }
    t.Log("這行代碼在 t.Fatal 未觸發時才會執行")
}

// 何時用 Error,何時用 Fatal?
// - Error: 當後續的斷言仍有意義時(想看到所有失敗的斷言)
// - Fatal: 當後續代碼依賴於當前斷言的結果(如 nil 檢查後解引用)

func TestFatalForPrerequisite(t *testing.T) {
    // 典型用法:對 error/nil 檢查用 Fatal
    result := Add(10, 20)

    // 假設這裡是某個可能返回 nil 的操作
    if result == 0 {
        t.Fatal("前置條件不滿足,終止測試")
    }

    // 後續操作依賴 result 不為 0
    t.Logf("結果: %d", result)
}

1.4 t.Helper

package mymath

import "testing"

// assertEqual 是一個測試輔助函數
// t.Helper() 讓錯誤報告的行號指向調用者,而不是這個輔助函數內部
func assertEqual(t *testing.T, got, want int) {
    t.Helper() // 標記為輔助函數
    if got != want {
        t.Errorf("得到 %d; 期望 %d", got, want)
    }
}

func assertNoError(t *testing.T, err error) {
    t.Helper()
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
}

func TestWithHelper(t *testing.T) {
    // 如果斷言失敗,錯誤報告會指向這一行,而不是 assertEqual 內部
    assertEqual(t, Add(1, 2), 3)
    assertEqual(t, Add(-1, 1), 0)
    assertEqual(t, Abs(-10), 10)
}

1.5 t.Cleanup

package mymath

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

func TestWithCleanup(t *testing.T) {
    // 創建臨時文件
    f, err := os.CreateTemp("", "test-*")
    if err != nil {
        t.Fatal(err)
    }

    // t.Cleanup 註冊清理函數,測試結束時自動執行
    // 類似 defer 但由測試框架管理,子測試結束時也會執行
    t.Cleanup(func() {
        f.Close()
        os.Remove(f.Name())
        fmt.Println("清理完畢:", f.Name())
    })

    // 在測試中使用臨時文件
    _, err = f.WriteString("test data")
    if err != nil {
        t.Fatal(err)
    }
    t.Log("臨時文件:", f.Name())
}

1.6 t.Skip 跳過測試

package mymath

import (
    "os"
    "runtime"
    "testing"
)

func TestOnlyOnLinux(t *testing.T) {
    if runtime.GOOS != "linux" {
        t.Skip("此測試僅在 Linux 上運行")
    }
    t.Log("在 Linux 上執行特定測試...")
}

func TestRequiresDatabase(t *testing.T) {
    dsn := os.Getenv("DATABASE_URL")
    if dsn == "" {
        t.Skip("跳過:未設置 DATABASE_URL 環境變量")
    }
    t.Logf("連接數據庫: %s", dsn)
}

func TestSlow(t *testing.T) {
    if testing.Short() {
        t.Skip("跳過慢速測試(使用 -short 標誌時)")
    }
    // 運行耗時較長的測試...
    t.Log("執行慢速測試...")
}

運行方式:

# 跳過慢速測試
go test -short

# 查看跳過原因
go test -v -short

2. 表格驅動測試

2.1 基本的表格驅動測試

表格驅動測試(Table-Driven Tests)是 Go 社區最推薦的測試模式:

package mymath

import "testing"

func TestAdd_TableDriven(t *testing.T) {
    // 定義測試用例表
    tests := []struct {
        name     string // 測試用例名稱
        a, b     int    // 輸入
        expected int    // 期望輸出
    }{
        {"正數相加", 1, 2, 3},
        {"負數相加", -1, -2, -3},
        {"正負相加", -1, 1, 0},
        {"零值", 0, 0, 0},
        {"大數", 1000000, 2000000, 3000000},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := Add(tt.a, tt.b)
            if result != tt.expected {
                t.Errorf("Add(%d, %d) = %d; 期望 %d",
                    tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

2.2 包含錯誤檢查的表格測試

package mymath

import (
    "errors"
    "fmt"
    "testing"
)

// Divide 整數除法
func Divide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("除數不能為零")
    }
    return a / b, nil
}

func TestDivide(t *testing.T) {
    tests := []struct {
        name      string
        a, b      int
        want      int
        wantErr   bool   // 是否期望錯誤
        errMsg    string // 期望的錯誤消息(可選)
    }{
        {
            name: "正常除法",
            a: 10, b: 3,
            want: 3, wantErr: false,
        },
        {
            name: "整除",
            a: 10, b: 2,
            want: 5, wantErr: false,
        },
        {
            name: "除以零",
            a: 10, b: 0,
            want: 0, wantErr: true,
            errMsg: "除數不能為零",
        },
        {
            name: "負數除法",
            a: -10, b: 3,
            want: -3, wantErr: false,
        },
    }

    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.Fatal("期望錯誤,但沒有返回錯誤")
                }
                if tt.errMsg != "" && err.Error() != tt.errMsg {
                    t.Errorf("錯誤消息 = %q; 期望 %q", err.Error(), tt.errMsg)
                }
                return // 有錯誤時不檢查返回值
            }

            if err != nil {
                t.Fatalf("不期望錯誤,但得到: %v", err)
            }

            // 檢查結果
            if got != tt.want {
                t.Errorf("Divide(%d, %d) = %d; 期望 %d",
                    tt.a, tt.b, got, tt.want)
            }
        })
    }
}

// 使用 map 作為測試用例集合(無序但可讀性好)
func TestDivide_Map(t *testing.T) {
    tests := map[string]struct {
        a, b    int
        want    int
        wantErr bool
    }{
        "10/2": {10, 2, 5, false},
        "9/3":  {9, 3, 3, false},
        "1/0":  {1, 0, 0, true},
    }

    for name, tt := range tests {
        t.Run(name, func(t *testing.T) {
            got, err := Divide(tt.a, tt.b)
            if (err != nil) != tt.wantErr {
                t.Fatalf("Divide(%d, %d) error = %v, wantErr %v",
                    tt.a, tt.b, err, tt.wantErr)
            }
            if !tt.wantErr && got != tt.want {
                t.Errorf("got %d, want %d", got, tt.want)
            }
        })
    }
}

func init() {
    _ = fmt.Sprintf // avoid unused import
}

3. 子測試 t.Run

3.1 子測試基礎

package mymath

import "testing"

func TestAbs_Subtests(t *testing.T) {
    // 子測試允許將相關測試組織在一起
    t.Run("正數", func(t *testing.T) {
        if Abs(5) != 5 {
            t.Error("正數的絕對值應該等於自身")
        }
    })

    t.Run("負數", func(t *testing.T) {
        if Abs(-5) != 5 {
            t.Error("負數的絕對值應該等於其相反數")
        }
    })

    t.Run("零", func(t *testing.T) {
        if Abs(0) != 0 {
            t.Error("零的絕對值應該等於零")
        }
    })
}

運行特定子測試:

# 運行 TestAbs_Subtests 下的 "負數" 子測試
go test -run TestAbs_Subtests/負數 -v

# 支持正則表達式
go test -run "TestAbs_Subtests/正" -v

3.2 嵌套子測試與共享 setup

package mymath

import (
    "testing"
)

// UserService 模擬服務
type UserService struct {
    users map[string]int // name -> age
}

func NewUserService() *UserService {
    return &UserService{
        users: map[string]int{
            "Alice": 30,
            "Bob":   25,
        },
    }
}

func (s *UserService) GetAge(name string) (int, bool) {
    age, ok := s.users[name]
    return age, ok
}

func (s *UserService) AddUser(name string, age int) {
    s.users[name] = age
}

func TestUserService(t *testing.T) {
    // 頂層 setup: 所有子測試共享
    svc := NewUserService()

    t.Run("GetAge", func(t *testing.T) {
        t.Run("存在的用戶", func(t *testing.T) {
            age, ok := svc.GetAge("Alice")
            if !ok {
                t.Fatal("用戶 Alice 應該存在")
            }
            if age != 30 {
                t.Errorf("Alice 的年齡 = %d; 期望 30", age)
            }
        })

        t.Run("不存在的用戶", func(t *testing.T) {
            _, ok := svc.GetAge("Charlie")
            if ok {
                t.Error("用戶 Charlie 不應該存在")
            }
        })
    })

    t.Run("AddUser", func(t *testing.T) {
        svc.AddUser("Dave", 35)
        age, ok := svc.GetAge("Dave")
        if !ok {
            t.Fatal("添加後用戶 Dave 應該存在")
        }
        if age != 35 {
            t.Errorf("Dave 的年齡 = %d; 期望 35", age)
        }
    })
}

3.3 並行子測試

package mymath

import (
    "testing"
    "time"
)

// slowOperation 模擬耗時操作
func slowOperation(input int) int {
    time.Sleep(100 * time.Millisecond)
    return input * 2
}

func TestParallelSubtests(t *testing.T) {
    tests := []struct {
        name  string
        input int
        want  int
    }{
        {"double_1", 1, 2},
        {"double_2", 2, 4},
        {"double_5", 5, 10},
        {"double_10", 10, 20},
    }

    for _, tt := range tests {
        tt := tt // 重要!在 Go 1.22 之前需要創建循環變量的副本
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // 標記為可並行執行
            got := slowOperation(tt.input)
            if got != tt.want {
                t.Errorf("slowOperation(%d) = %d; want %d",
                    tt.input, got, tt.want)
            }
        })
    }
    // 所有並行子測試會在 TestParallelSubtests 返回前完成
}

注意:在 Go 1.22 之前,for 循環中的變量在每次迭代時共享同一地址。如果不使用 tt := tt 創建副本,並行子測試可能讀到錯誤的值。Go 1.22+ 已修復此問題(循環變量按迭代創建)。


4. 基準測試 Benchmark

4.1 基準測試基礎

package mymath

import "testing"

// 基準測試函數以 Benchmark 開頭,參數是 *testing.B
func BenchmarkAdd(b *testing.B) {
    // b.N 由測試框架動態調整,直到結果穩定
    for i := 0; i < b.N; i++ {
        Add(100, 200)
    }
}

func BenchmarkAbs(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Abs(-42)
    }
}

運行基準測試:

# 運行所有基準測試
go test -bench=.

# 運行特定基準測試
go test -bench=BenchmarkAdd

# 運行多次以獲得更穩定的結果
go test -bench=. -count=5

# 同時顯示內存分配信息
go test -bench=. -benchmem

# 指定運行時間(默認1秒)
go test -bench=. -benchtime=3s

輸出示例:

BenchmarkAdd-8     1000000000     0.2950 ns/op
BenchmarkAbs-8     1000000000     0.5100 ns/op
  • -8 表示使用 8 個 CPU 核心
  • 1000000000 是 b.N 的值(運行次數)
  • 0.2950 ns/op 是每次操作的耗時

4.2 b.ResetTimer 和 b.StopTimer

package mymath

import (
    "testing"
    "time"
)

// 場景:基準測試需要準備數據,但準備時間不應計入基準

func BenchmarkWithSetup(b *testing.B) {
    // 準備階段(耗時的初始化)
    data := make([]int, 10000)
    for i := range data {
        data[i] = i
    }
    time.Sleep(10 * time.Millisecond) // 模擬耗時初始化

    // 重置計時器,排除準備階段的時間
    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        // 只測量這個操作的性能
        sum := 0
        for _, v := range data {
            sum += v
        }
    }
}

func BenchmarkWithPause(b *testing.B) {
    for i := 0; i < b.N; i++ {
        // 暫停計時:準備每次迭代的輸入
        b.StopTimer()
        data := make([]int, 100)
        for j := range data {
            data[j] = j
        }
        b.StartTimer()

        // 只測量這個操作
        sum := 0
        for _, v := range data {
            sum += v
        }
        _ = sum
    }
}

4.3 b.ReportAllocs 和內存基準

package mymath

import (
    "fmt"
    "strings"
    "testing"
)

// 比較字符串拼接的不同方式

func concatWithPlus(strs []string) string {
    result := ""
    for _, s := range strs {
        result += s
    }
    return result
}

func concatWithBuilder(strs []string) string {
    var b strings.Builder
    for _, s := range strs {
        b.WriteString(s)
    }
    return b.String()
}

func concatWithJoin(strs []string) string {
    return strings.Join(strs, "")
}

func BenchmarkConcatPlus(b *testing.B) {
    strs := make([]string, 100)
    for i := range strs {
        strs[i] = fmt.Sprintf("item%d", i)
    }

    b.ReportAllocs() // 報告內存分配
    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        concatWithPlus(strs)
    }
}

func BenchmarkConcatBuilder(b *testing.B) {
    strs := make([]string, 100)
    for i := range strs {
        strs[i] = fmt.Sprintf("item%d", i)
    }

    b.ReportAllocs()
    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        concatWithBuilder(strs)
    }
}

func BenchmarkConcatJoin(b *testing.B) {
    strs := make([]string, 100)
    for i := range strs {
        strs[i] = fmt.Sprintf("item%d", i)
    }

    b.ReportAllocs()
    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        concatWithJoin(strs)
    }
}

運行結果示例(go test -bench=BenchmarkConcat -benchmem):

BenchmarkConcatPlus-8       5000    300000 ns/op   530000 B/op   99 allocs/op
BenchmarkConcatBuilder-8  200000      8000 ns/op     2048 B/op    8 allocs/op
BenchmarkConcatJoin-8     200000      7500 ns/op     1024 B/op    1 allocs/op

可以清楚看到 + 拼接的內存分配遠高於 BuilderJoin

4.4 子基準測試

package mymath

import (
    "fmt"
    "testing"
)

func BenchmarkAbs_Sizes(b *testing.B) {
    inputs := []int{1, 100, -1, -100, 0}

    for _, input := range inputs {
        b.Run(fmt.Sprintf("input=%d", input), func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                Abs(input)
            }
        })
    }
}
go test -bench="BenchmarkAbs_Sizes" -v
# BenchmarkAbs_Sizes/input=1-8     1000000000    0.30 ns/op
# BenchmarkAbs_Sizes/input=100-8   1000000000    0.30 ns/op
# BenchmarkAbs_Sizes/input=-1-8    1000000000    0.52 ns/op
# ...

5. 測試覆蓋率

5.1 基本覆蓋率

# 查看覆蓋率百分比
go test -cover

# 輸出示例:
# PASS
# coverage: 85.7% of statements
# ok    mypackage    0.003s

# 生成覆蓋率 profile 文件
go test -coverprofile=coverage.out

# 在終端查看每個函數的覆蓋率
go tool cover -func=coverage.out
# 輸出示例:
# mypackage/math.go:3:   Add      100.0%
# mypackage/math.go:8:   Abs       80.0%
# mypackage/math.go:15:  Divide   100.0%
# total:                 (statements) 93.3%

# 在瀏覽器中查看可視化覆蓋率報告(綠色=已覆蓋,紅色=未覆蓋)
go tool cover -html=coverage.out

5.2 覆蓋率模式

# 默認模式:set(行是否被執行)
go test -coverprofile=coverage.out -covermode=set

# count 模式:每行執行了幾次
go test -coverprofile=coverage.out -covermode=count

# atomic 模式:count 的線程安全版本,適合 -race
go test -coverprofile=coverage.out -covermode=atomic

5.3 多包覆蓋率

# 計算所有包的覆蓋率
go test -coverprofile=coverage.out ./...

# 包含所有包(即使沒有測試文件的包也統計)
go test -coverpkg=./... -coverprofile=coverage.out ./...

5.4 在 CI 中使用覆蓋率

#!/bin/bash
# ci_test.sh - CI 中的測試腳本

# 運行測試並生成覆蓋率報告
go test -coverprofile=coverage.out -covermode=atomic ./...

# 提取總覆蓋率
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')

echo "總覆蓋率: ${COVERAGE}%"

# 檢查覆蓋率是否達到最低要求(如 80%)
MIN_COVERAGE=80.0
if [ "$(echo "$COVERAGE < $MIN_COVERAGE" | bc)" -eq 1 ]; then
    echo "覆蓋率 ${COVERAGE}% 低於最低要求 ${MIN_COVERAGE}%"
    exit 1
fi

6. TestMain

6.1 TestMain 基礎

TestMain 可以控制測試的整體生命週期,在所有測試執行前後做 setup 和 teardown。

package mymath

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

// TestMain 每個包最多只能有一個
// 它在所有測試之前運行,控制測試流程
func TestMain(m *testing.M) {
    // ===== Setup: 在所有測試之前執行 =====
    fmt.Println(">>> 全局 Setup: 初始化測試環境")

    // 例如:創建測試數據庫、啓動測試服務器、加載測試數據等

    // ===== 運行所有測試 =====
    // m.Run() 運行所有 Test* 函數,返回退出碼
    exitCode := m.Run()

    // ===== Teardown: 在所有測試之後執行 =====
    fmt.Println(">>> 全局 Teardown: 清理測試環境")

    // 例如:刪除測試數據庫、關閉連接等

    // 必須調用 os.Exit,否則測試結果不會正確報告
    os.Exit(exitCode)
}

func TestExample1(t *testing.T) {
    t.Log("運行測試1")
}

func TestExample2(t *testing.T) {
    t.Log("運行測試2")
}

輸出:

>>> 全局 Setup: 初始化測試環境
=== RUN   TestExample1
    運行測試1
--- PASS: TestExample1
=== RUN   TestExample2
    運行測試2
--- PASS: TestExample2
>>> 全局 Teardown: 清理測試環境
PASS

6.2 實際應用:數據庫測試

package db_test

import (
    "database/sql"
    "fmt"
    "os"
    "testing"

    _ "github.com/mattn/go-sqlite3" // SQLite 驅動
)

var testDB *sql.DB

func TestMain(m *testing.M) {
    // Setup: 創建測試數據庫
    var err error
    testDB, err = sql.Open("sqlite3", ":memory:")
    if err != nil {
        fmt.Fprintf(os.Stderr, "無法創建測試數據庫: %v\n", err)
        os.Exit(1)
    }

    // 創建表結構
    _, err = testDB.Exec(`
        CREATE TABLE users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL,
            email TEXT UNIQUE NOT NULL
        )
    `)
    if err != nil {
        fmt.Fprintf(os.Stderr, "無法創建表: %v\n", err)
        os.Exit(1)
    }

    // 運行測試
    exitCode := m.Run()

    // Teardown: 關閉數據庫
    testDB.Close()

    os.Exit(exitCode)
}

func TestInsertUser(t *testing.T) {
    // 每個測試前清理數據(或使用事務回滾)
    t.Cleanup(func() {
        testDB.Exec("DELETE FROM users")
    })

    _, err := testDB.Exec(
        "INSERT INTO users (name, email) VALUES (?, ?)",
        "Alice", "alice@example.com",
    )
    if err != nil {
        t.Fatalf("插入用戶失敗: %v", err)
    }

    var count int
    err = testDB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
    if err != nil {
        t.Fatalf("查詢失敗: %v", err)
    }
    if count != 1 {
        t.Errorf("用戶數量 = %d; 期望 1", count)
    }
}

func TestUniqueEmail(t *testing.T) {
    t.Cleanup(func() {
        testDB.Exec("DELETE FROM users")
    })

    testDB.Exec("INSERT INTO users (name, email) VALUES (?, ?)",
        "Alice", "alice@example.com")

    // 插入重復郵箱應該失敗
    _, err := testDB.Exec("INSERT INTO users (name, email) VALUES (?, ?)",
        "Bob", "alice@example.com")

    if err == nil {
        t.Error("期望唯一約束錯誤,但沒有返回錯誤")
    }
}

7. httptest 包

7.1 測試 HTTP Handler

package api

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

// ========== 被測代碼 ==========

// HealthHandler 健康檢查
func HealthHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{
        "status": "ok",
    })
}

// GreetHandler 問候接口
func GreetHandler(w http.ResponseWriter, r *http.Request) {
    name := r.URL.Query().Get("name")
    if name == "" {
        http.Error(w, "缺少 name 參數", http.StatusBadRequest)
        return
    }
    w.Header().Set("Content-Type", "text/plain; charset=utf-8")
    fmt.Fprintf(w, "你好, %s!", name)
}

// CreateUserHandler 創建用戶
func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "只接受 POST 請求", http.StatusMethodNotAllowed)
        return
    }

    var user struct {
        Name  string `json:"name"`
        Email string `json:"email"`
    }

    if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
        http.Error(w, "無效的 JSON", http.StatusBadRequest)
        return
    }

    if user.Name == "" || user.Email == "" {
        http.Error(w, "name 和 email 不能為空", http.StatusBadRequest)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(map[string]interface{}{
        "id":    1,
        "name":  user.Name,
        "email": user.Email,
    })
}

// ========== 測試代碼 ==========

func TestHealthHandler(t *testing.T) {
    // 創建請求
    req := httptest.NewRequest(http.MethodGet, "/health", nil)

    // 創建 ResponseRecorder(模擬的 http.ResponseWriter)
    rr := httptest.NewRecorder()

    // 調用 handler
    HealthHandler(rr, req)

    // 檢查狀態碼
    if rr.Code != http.StatusOK {
        t.Errorf("狀態碼 = %d; 期望 %d", rr.Code, http.StatusOK)
    }

    // 檢查 Content-Type
    contentType := rr.Header().Get("Content-Type")
    if contentType != "application/json" {
        t.Errorf("Content-Type = %s; 期望 application/json", contentType)
    }

    // 檢查響應體
    var body map[string]string
    if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
        t.Fatalf("解析響應體失敗: %v", err)
    }
    if body["status"] != "ok" {
        t.Errorf("status = %s; 期望 ok", body["status"])
    }
}

func TestGreetHandler(t *testing.T) {
    tests := []struct {
        name       string
        queryName  string
        wantCode   int
        wantBody   string
    }{
        {
            name:      "正常請求",
            queryName: "Alice",
            wantCode:  http.StatusOK,
            wantBody:  "你好, Alice!",
        },
        {
            name:      "缺少參數",
            queryName: "",
            wantCode:  http.StatusBadRequest,
            wantBody:  "缺少 name 參數\n",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            url := "/greet"
            if tt.queryName != "" {
                url += "?name=" + tt.queryName
            }

            req := httptest.NewRequest(http.MethodGet, url, nil)
            rr := httptest.NewRecorder()

            GreetHandler(rr, req)

            if rr.Code != tt.wantCode {
                t.Errorf("狀態碼 = %d; 期望 %d", rr.Code, tt.wantCode)
            }
            if rr.Body.String() != tt.wantBody {
                t.Errorf("響應體 = %q; 期望 %q", rr.Body.String(), tt.wantBody)
            }
        })
    }
}

func TestCreateUserHandler(t *testing.T) {
    tests := []struct {
        name     string
        method   string
        body     string
        wantCode int
    }{
        {
            name:     "成功創建",
            method:   http.MethodPost,
            body:     `{"name":"Alice","email":"alice@example.com"}`,
            wantCode: http.StatusCreated,
        },
        {
            name:     "錯誤的方法",
            method:   http.MethodGet,
            body:     "",
            wantCode: http.StatusMethodNotAllowed,
        },
        {
            name:     "無效JSON",
            method:   http.MethodPost,
            body:     "not json",
            wantCode: http.StatusBadRequest,
        },
        {
            name:     "缺少字段",
            method:   http.MethodPost,
            body:     `{"name":"Alice"}`,
            wantCode: http.StatusBadRequest,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            var body *strings.Reader
            if tt.body != "" {
                body = strings.NewReader(tt.body)
            } else {
                body = strings.NewReader("")
            }

            req := httptest.NewRequest(tt.method, "/users", body)
            req.Header.Set("Content-Type", "application/json")
            rr := httptest.NewRecorder()

            CreateUserHandler(rr, req)

            if rr.Code != tt.wantCode {
                t.Errorf("狀態碼 = %d; 期望 %d\n響應: %s",
                    rr.Code, tt.wantCode, rr.Body.String())
            }
        })
    }
}

7.2 httptest.NewServer 測試完整的 HTTP 流程

package api

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

func TestWithTestServer(t *testing.T) {
    // 創建一個真正監聽端口的測試服務器
    mux := http.NewServeMux()
    mux.HandleFunc("/health", HealthHandler)
    mux.HandleFunc("/greet", GreetHandler)

    server := httptest.NewServer(mux)
    defer server.Close() // 測試結束後關閉服務器

    t.Logf("測試服務器地址: %s", server.URL)

    // 像真正的客戶端一樣發送請求
    t.Run("health", func(t *testing.T) {
        resp, err := http.Get(server.URL + "/health")
        if err != nil {
            t.Fatalf("請求失敗: %v", err)
        }
        defer resp.Body.Close()

        if resp.StatusCode != http.StatusOK {
            t.Errorf("狀態碼 = %d; 期望 200", resp.StatusCode)
        }

        var body map[string]string
        json.NewDecoder(resp.Body).Decode(&body)
        if body["status"] != "ok" {
            t.Errorf("status = %s; 期望 ok", body["status"])
        }
    })

    t.Run("greet", func(t *testing.T) {
        resp, err := http.Get(server.URL + "/greet?name=World")
        if err != nil {
            t.Fatalf("請求失敗: %v", err)
        }
        defer resp.Body.Close()

        bodyBytes, _ := io.ReadAll(resp.Body)
        if string(bodyBytes) != "你好, World!" {
            t.Errorf("響應 = %q; 期望 %q", string(bodyBytes), "你好, World!")
        }
    })
}

// 測試 TLS 服務器
func TestWithTLSServer(t *testing.T) {
    server := httptest.NewTLSServer(http.HandlerFunc(HealthHandler))
    defer server.Close()

    // server.Client() 返回配置好 TLS 證書的 http.Client
    client := server.Client()

    resp, err := client.Get(server.URL + "/health")
    if err != nil {
        t.Fatalf("TLS 請求失敗: %v", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        t.Errorf("狀態碼 = %d; 期望 200", resp.StatusCode)
    }
}

8. Mock 和接口測試技巧

8.1 使用接口實現 Mock

Go 推薦通過接口來實現依賴注入和 Mock,而不是使用複雜的 Mock 框架。

package service

import (
    "errors"
    "testing"
)

// ========== 定義接口 ==========

// UserRepository 用戶數據訪問接口
type UserRepository interface {
    GetByID(id int) (*User, error)
    Save(user *User) error
    Delete(id int) error
}

// EmailSender 郵件發送接口
type EmailSender interface {
    Send(to, subject, body string) error
}

type User struct {
    ID    int
    Name  string
    Email string
}

// ========== 被測服務 ==========

// UserService 依賴接口而不是具體實現
type UserService struct {
    repo  UserRepository
    email EmailSender
}

func NewUserService(repo UserRepository, email EmailSender) *UserService {
    return &UserService{repo: repo, email: email}
}

func (s *UserService) GetUser(id int) (*User, error) {
    return s.repo.GetByID(id)
}

func (s *UserService) CreateUser(name, email string) (*User, error) {
    user := &User{Name: name, Email: email}
    if err := s.repo.Save(user); err != nil {
        return nil, err
    }

    // 發送歡迎郵件
    err := s.email.Send(email, "歡迎", "歡迎加入!")
    if err != nil {
        // 郵件發送失敗不影響用戶創建
        // 實際項目中可能記錄日誌或放入隊列
    }

    return user, nil
}

// ========== Mock 實現 ==========

// MockUserRepo 模擬用戶倉庫
type MockUserRepo struct {
    users    map[int]*User
    saveErr  error // 可以注入錯誤
    saveCalled bool
}

func NewMockUserRepo() *MockUserRepo {
    return &MockUserRepo{
        users: make(map[int]*User),
    }
}

func (m *MockUserRepo) GetByID(id int) (*User, error) {
    user, ok := m.users[id]
    if !ok {
        return nil, errors.New("用戶未找到")
    }
    return user, nil
}

func (m *MockUserRepo) Save(user *User) error {
    m.saveCalled = true
    if m.saveErr != nil {
        return m.saveErr
    }
    user.ID = len(m.users) + 1
    m.users[user.ID] = user
    return nil
}

func (m *MockUserRepo) Delete(id int) error {
    delete(m.users, id)
    return nil
}

// MockEmailSender 模擬郵件發送
type MockEmailSender struct {
    sentEmails []struct {
        To, Subject, Body string
    }
    sendErr error
}

func (m *MockEmailSender) Send(to, subject, body string) error {
    if m.sendErr != nil {
        return m.sendErr
    }
    m.sentEmails = append(m.sentEmails, struct {
        To, Subject, Body string
    }{to, subject, body})
    return nil
}

// ========== 測試 ==========

func TestUserService_GetUser(t *testing.T) {
    repo := NewMockUserRepo()
    repo.users[1] = &User{ID: 1, Name: "Alice", Email: "alice@test.com"}

    svc := NewUserService(repo, &MockEmailSender{})

    t.Run("用戶存在", func(t *testing.T) {
        user, err := svc.GetUser(1)
        if err != nil {
            t.Fatalf("不期望錯誤: %v", err)
        }
        if user.Name != "Alice" {
            t.Errorf("Name = %s; 期望 Alice", user.Name)
        }
    })

    t.Run("用戶不存在", func(t *testing.T) {
        _, err := svc.GetUser(999)
        if err == nil {
            t.Fatal("期望錯誤,但沒有返回")
        }
    })
}

func TestUserService_CreateUser(t *testing.T) {
    t.Run("成功創建併發送郵件", func(t *testing.T) {
        repo := NewMockUserRepo()
        emailer := &MockEmailSender{}
        svc := NewUserService(repo, emailer)

        user, err := svc.CreateUser("Bob", "bob@test.com")
        if err != nil {
            t.Fatalf("創建失敗: %v", err)
        }
        if user.ID == 0 {
            t.Error("用戶ID不應為0")
        }
        if !repo.saveCalled {
            t.Error("Save 應該被調用")
        }
        if len(emailer.sentEmails) != 1 {
            t.Errorf("發送了 %d 封郵件; 期望 1", len(emailer.sentEmails))
        }
        if emailer.sentEmails[0].To != "bob@test.com" {
            t.Errorf("郵件發送給 %s; 期望 bob@test.com",
                emailer.sentEmails[0].To)
        }
    })

    t.Run("保存失敗", func(t *testing.T) {
        repo := NewMockUserRepo()
        repo.saveErr = errors.New("數據庫錯誤")
        svc := NewUserService(repo, &MockEmailSender{})

        _, err := svc.CreateUser("Bob", "bob@test.com")
        if err == nil {
            t.Fatal("期望錯誤")
        }
    })

    t.Run("郵件失敗不影響創建", func(t *testing.T) {
        repo := NewMockUserRepo()
        emailer := &MockEmailSender{sendErr: errors.New("SMTP錯誤")}
        svc := NewUserService(repo, emailer)

        user, err := svc.CreateUser("Charlie", "charlie@test.com")
        if err != nil {
            t.Fatalf("用戶創建不應失敗: %v", err)
        }
        if user.Name != "Charlie" {
            t.Errorf("Name = %s; 期望 Charlie", user.Name)
        }
    })
}

8.2 函數類型作為輕量 Mock

package service

import (
    "fmt"
    "testing"
)

// Fetcher 使用函數類型定義接口(更輕量)
type Fetcher func(url string) ([]byte, error)

// CachedFetcher 帶緩存的獲取器
type CachedFetcher struct {
    fetch Fetcher
    cache map[string][]byte
}

func NewCachedFetcher(fetch Fetcher) *CachedFetcher {
    return &CachedFetcher{
        fetch: fetch,
        cache: make(map[string][]byte),
    }
}

func (cf *CachedFetcher) Get(url string) ([]byte, error) {
    if data, ok := cf.cache[url]; ok {
        return data, nil // 緩存命中
    }

    data, err := cf.fetch(url)
    if err != nil {
        return nil, fmt.Errorf("獲取 %s: %w", url, err)
    }

    cf.cache[url] = data
    return data, nil
}

func TestCachedFetcher(t *testing.T) {
    callCount := 0

    // 直接用閉包作為 Mock
    mockFetch := Fetcher(func(url string) ([]byte, error) {
        callCount++
        return []byte("response from " + url), nil
    })

    cf := NewCachedFetcher(mockFetch)

    // 第一次調用:應該調用 fetch
    data, err := cf.Get("https://example.com")
    if err != nil {
        t.Fatal(err)
    }
    if string(data) != "response from https://example.com" {
        t.Errorf("響應 = %s", string(data))
    }
    if callCount != 1 {
        t.Errorf("fetch 被調用 %d 次; 期望 1", callCount)
    }

    // 第二次調用相同 URL:應該命中緩存,不調用 fetch
    _, err = cf.Get("https://example.com")
    if err != nil {
        t.Fatal(err)
    }
    if callCount != 1 {
        t.Errorf("fetch 被調用 %d 次; 期望仍為 1(緩存命中)", callCount)
    }

    // 不同 URL:應該再次調用 fetch
    _, err = cf.Get("https://other.com")
    if err != nil {
        t.Fatal(err)
    }
    if callCount != 2 {
        t.Errorf("fetch 被調用 %d 次; 期望 2", callCount)
    }
}

8.3 接口設計的測試原則

package service

import "testing"

// 測試技巧:在消費者包中定義小接口
// 不要在提供者包中定義大接口

// 差的做法:定義一個大而全的接口
// type BigRepository interface {
//     GetByID(id int) (*User, error)
//     GetByEmail(email string) (*User, error)
//     GetAll() ([]*User, error)
//     Save(user *User) error
//     Update(user *User) error
//     Delete(id int) error
//     Count() (int, error)
//     ... 更多方法
// }

// 好的做法:按需定義小接口(接口隔離原則)
type UserGetter interface {
    GetByID(id int) (*User, error)
}

type UserSaver interface {
    Save(user *User) error
}

// 函數只依賴它真正需要的方法
func getUserName(getter UserGetter, id int) (string, error) {
    user, err := getter.GetByID(id)
    if err != nil {
        return "", err
    }
    return user.Name, nil
}

// 測試時只需要 mock 用到的方法
type simpleGetter struct {
    user *User
    err  error
}

func (g *simpleGetter) GetByID(id int) (*User, error) {
    return g.user, g.err
}

func TestGetUserName(t *testing.T) {
    // Mock 非常簡單,只需實現一個方法
    getter := &simpleGetter{
        user: &User{ID: 1, Name: "Alice"},
    }

    name, err := getUserName(getter, 1)
    if err != nil {
        t.Fatal(err)
    }
    if name != "Alice" {
        t.Errorf("name = %s; want Alice", name)
    }
}

9. 模糊測試 Fuzz

9.1 模糊測試基礎(Go 1.18+)

模糊測試(Fuzz Testing)自動生成大量隨機輸入來發現邊界情況和潛在的 bug。

package mymath

import (
    "testing"
    "unicode/utf8"
)

// Reverse 反轉字符串(按字符而非字節)
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)
}

// FuzzReverse 模糊測試函數以 Fuzz 開頭
func FuzzReverse(f *testing.F) {
    // 添加種子語料庫(seed corpus)
    // 模糊測試引擎會基於這些種子生成更多測試輸入
    f.Add("hello")
    f.Add("世界")
    f.Add("")
    f.Add("a")
    f.Add("Hello, 世界! 🌍")

    // f.Fuzz 接收的函數參數類型必須與 f.Add 的參數類型一致
    f.Fuzz(func(t *testing.T, original string) {
        // 對每個自動生成的輸入執行測試

        // 屬性1: 反轉兩次應該得到原始字符串
        reversed := Reverse(original)
        doubleReversed := Reverse(reversed)
        if original != doubleReversed {
            t.Errorf("反轉兩次不等於原始值:\n原始: %q\n反轉: %q\n雙反: %q",
                original, reversed, doubleReversed)
        }

        // 屬性2: 反轉後的字符串長度應該與原始相同
        if utf8.RuneCountInString(original) != utf8.RuneCountInString(reversed) {
            t.Errorf("長度不一致:\n原始: %q (len=%d)\n反轉: %q (len=%d)",
                original, utf8.RuneCountInString(original),
                reversed, utf8.RuneCountInString(reversed))
        }

        // 屬性3: 反轉後的字符串應該是有效的 UTF-8
        if !utf8.ValidString(reversed) {
            t.Errorf("反轉後不是有效的 UTF-8: %q", reversed)
        }
    })
}

運行模糊測試:

# 僅運行種子語料庫(不做模糊測試)
go test -run FuzzReverse

# 運行模糊測試(持續運行直到找到 bug 或被中斷)
go test -fuzz FuzzReverse

# 限制模糊測試運行時間
go test -fuzz FuzzReverse -fuzztime 30s

# 限制模糊測試迭代次數
go test -fuzz FuzzReverse -fuzztime 1000x

9.2 支持的參數類型

模糊測試的 f.Addf.Fuzz 支持以下類型:

package mymath

import "testing"

// 所有支持的參數類型
func FuzzAllTypes(f *testing.F) {
    f.Add(
        "string",           // string
        []byte("bytes"),    // []byte
        int(42),            // int
        int8(8),            // int8
        int16(16),          // int16
        int32(32),          // int32
        int64(64),          // int64
        uint(42),           // uint
        uint8(8),           // uint8
        uint16(16),         // uint16
        uint32(32),         // uint32
        uint64(64),         // uint64
        float32(3.14),      // float32
        float64(2.718),     // float64
        rune('A'),          // rune (= int32)
        byte(0xFF),         // byte (= uint8)
        true,               // bool
    )

    f.Fuzz(func(t *testing.T,
        s string, b []byte,
        i int, i8 int8, i16 int16, i32 int32, i64 int64,
        u uint, u8 uint8, u16 uint16, u32 uint32, u64 uint64,
        f32 float32, f64 float64,
        r rune, by byte, bl bool,
    ) {
        // 使用自動生成的各類型值進行測試
        _ = s
        _ = b
    })
}

9.3 實際應用:測試 JSON 解析

package mymath

import (
    "encoding/json"
    "testing"
)

// Config 應用配置
type Config struct {
    Name    string `json:"name"`
    Port    int    `json:"port"`
    Debug   bool   `json:"debug"`
}

// ParseConfig 解析配置 JSON
func ParseConfig(data []byte) (*Config, error) {
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, err
    }
    return &cfg, nil
}

func FuzzParseConfig(f *testing.F) {
    // 種子:有效的 JSON
    f.Add([]byte(`{"name":"app","port":8080,"debug":true}`))
    f.Add([]byte(`{}`))
    f.Add([]byte(`{"name":""}`))

    // 種子:無效的輸入
    f.Add([]byte(`invalid json`))
    f.Add([]byte(``))
    f.Add([]byte(`null`))

    f.Fuzz(func(t *testing.T, data []byte) {
        cfg, err := ParseConfig(data)
        if err != nil {
            // 解析失敗是正常的(對於隨機輸入)
            // 關鍵是不能 panic
            return
        }

        // 如果解析成功,重新序列化應該不 panic
        reEncoded, err := json.Marshal(cfg)
        if err != nil {
            t.Errorf("重新編碼失敗: %v", err)
        }

        // 重新解析應該成功
        var cfg2 Config
        if err := json.Unmarshal(reEncoded, &cfg2); err != nil {
            t.Errorf("重新解析失敗: %v", err)
        }

        // 屬性:name 字段應該保持一致
        if cfg.Name != cfg2.Name {
            t.Errorf("Name 不一致: %q vs %q", cfg.Name, cfg2.Name)
        }
    })
}

9.4 模糊測試發現 bug 後的工作流

# 1. 運行模糊測試
go test -fuzz FuzzReverse -fuzztime 1m

# 如果發現 bug,測試框架會將導致失敗的輸入保存到
# testdata/fuzz/FuzzReverse/ 目錄下

# 2. 查看失敗的輸入
cat testdata/fuzz/FuzzReverse/xxxxxxxxx

# 3. 修復 bug 後,再次運行測試
# 種子語料庫中保存的失敗用例會自動成為回歸測試
go test -run FuzzReverse

# 4. 繼續模糊測試確認修復
go test -fuzz FuzzReverse -fuzztime 1m

9.5 模糊測試實用示例:URL 解析

package mymath

import (
    "net/url"
    "testing"
)

func FuzzURLParse(f *testing.F) {
    f.Add("https://example.com/path?query=value#fragment")
    f.Add("http://localhost:8080")
    f.Add("ftp://user:pass@host/dir")
    f.Add("/relative/path")
    f.Add("")

    f.Fuzz(func(t *testing.T, rawURL string) {
        // 解析 URL
        parsed, err := url.Parse(rawURL)
        if err != nil {
            return // 無效 URL 是可接受的
        }

        // 屬性:重新編碼後再解析應該得到相同結果
        reEncoded := parsed.String()
        reParsed, err := url.Parse(reEncoded)
        if err != nil {
            t.Errorf("重新編碼的 URL 無法解析: %q -> %q -> error: %v",
                rawURL, reEncoded, err)
            return
        }

        // 關鍵字段應該一致
        if parsed.Scheme != reParsed.Scheme {
            t.Errorf("Scheme 不一致: %q vs %q", parsed.Scheme, reParsed.Scheme)
        }
        if parsed.Host != reParsed.Host {
            t.Errorf("Host 不一致: %q vs %q", parsed.Host, reParsed.Host)
        }
        if parsed.Path != reParsed.Path {
            t.Errorf("Path 不一致: %q vs %q", parsed.Path, reParsed.Path)
        }
    })
}

總結

功能 核心用法 運行命令
單元測試 func TestXxx(t *testing.T) go test -v
表格驅動 tests := []struct{...} + t.Run go test -run TestXxx
子測試 t.Run("name", func(t *testing.T){...}) go test -run TestXxx/sub
並行測試 t.Parallel() go test -parallel 4
基準測試 func BenchmarkXxx(b *testing.B) go test -bench=.
內存分析 b.ReportAllocs() go test -bench=. -benchmem
覆蓋率 -cover / -coverprofile go test -cover
TestMain func TestMain(m *testing.M) 自動調用
HTTP 測試 httptest.NewRequest + httptest.NewRecorder go test -v
測試服務器 httptest.NewServer(handler) go test -v
Mock 定義接口 + 實現 Mock 結構體 go test -v
模糊測試 func FuzzXxx(f *testing.F) go test -fuzz FuzzXxx

常用命令速查

# 運行所有測試
go test ./...

# 詳細輸出
go test -v ./...

# 跳過慢測試
go test -short ./...

# 檢測數據競爭
go test -race ./...

# 覆蓋率報告
go test -coverprofile=c.out ./... && go tool cover -html=c.out

# 基準測試
go test -bench=. -benchmem ./...

# 模糊測試(30秒)
go test -fuzz=FuzzXxx -fuzztime=30s

# 運行特定測試
go test -run "TestUser/創建" -v

主題測試文章,只做測試使用。發佈者:Walker,轉轉請注明出處:https://walker-learn.xyz/archives/6723

(0)
Walker的頭像Walker
上一篇 11小時前
下一篇 15小時前

相關推薦

  • Go工程師體系課 010

    es 安裝 elasticsearch(理解為庫) kibana(理解為連接工具)es 和 kibana(5601) 的版本要保持一致 MySQL 對照學習 Elasticsearch(ES) 術語對照 MySQL Elasticsearch database index(索引) table type(7.x 起固定為 _doc,8.x 徹底移除多 type…

  • 編程基礎 0004_Web_beego開發

    beego 開始 2 文章的添加與刪除 創建 TopicController // controllers中添加topic.go package controllers import "github.com/astaxie/beego" type TopicController struct { beego.Controller } fu…

    後端開發 15小時前
    100
  • Go工程師體系課 012

    Go 中集成 Elasticsearch 1. 客戶端庫選擇 1.1 主流 Go ES 客戶端 olivere/elastic:功能最全面,API 設計優雅,支持 ES 7.x/8.x elastic/go-elasticsearch:官方客戶端,輕量級,更接近原生 REST API go-elasticsearch/elasticsearch:社區維護的官…

  • Go工程師體系課 001

    轉型 想在短時間系統轉到Go工程理由 提高CRUD,無自研框架經驗 拔高技術深度,做專、做精需求的同學 進階工程化,擁有良好開發規範和管理能力的 工程化的重要性 高級開的期望 良好的代碼規範 深入底層原理 熟悉架構 熟悉k8s的基礎架構 擴展知識廣度,知識的深度,規範的開發體系 四個大的階段 go語言基礎 微服務開發的(電商項目實戰) 自研微服務 自研然後重…

  • Go工程師體系課 020

    性能優化與 pprof 1. 先測量後優化 "Premature optimization is the root of all evil." — Donald Knuth 優化流程:1. 先寫正確的代碼2. 用 Benchmark 確認性能瓶頸3. 用 pprof 定位具體位置4. 優化 → 再測量 → 對比 2. pprof 工具 2.1 在 HTTP …

簡體中文 繁體中文 English