Appearance
38|测试与 Mock
ops-checker 里的配置解析、HTTP 检查、Kubernetes 检查、并发调度和结果判断,都需要测试覆盖。Go 自带 testing 包,不需要额外框架。测试文件以 _test.go 结尾,测试函数以 Test 开头,执行入口是 go test。本章介绍基础测试、表格驱动测试、HTTP 测试、Mock 和 race detector。
一、测试文件
Go 的测试文件和被测文件放在同一个包里,文件名以 _test.go 结尾:
text
ops-checker/
├── internal/
│ └── checker/
│ ├── result.go
│ └── result_test.go
└── go.mod假设 Result 和 HasFailed 在 internal/checker/result.go:
go
package checker
type Result struct {
Name string `json:"name"`
OK bool `json:"ok"`
StatusCode int `json:"status_code"`
Message string `json:"message"`
CostMS int64 `json:"cost_ms"`
}
func HasFailed(results []Result) bool {
for _, result := range results {
if !result.OK {
return true
}
}
return false
}internal/checker/result_test.go:
go
package checker
import "testing"
func TestHasFailed(t *testing.T) {
results := []Result{
{Name: "api", OK: true},
{Name: "web", OK: false},
}
if !HasFailed(results) {
t.Fatal("expected failed result")
}
}运行:
bash
go test ./internal/checkert.Fatal 标记测试失败并停止当前测试函数。go test ./... 递归测试当前模块下所有包。
二、表格驱动测试
同一个函数有多组输入输出时,Go 里常用表格驱动测试:
go
func TestHasFailed(t *testing.T) {
cases := []struct {
name string
results []Result
want bool
}{
{
name: "empty results",
results: nil,
want: false,
},
{
name: "all ok",
results: []Result{
{Name: "api", OK: true},
{Name: "web", OK: true},
},
want: false,
},
{
name: "one failed",
results: []Result{
{Name: "api", OK: true},
{Name: "web", OK: false},
},
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := HasFailed(tc.results)
if got != tc.want {
t.Fatalf("HasFailed() = %v, want %v", got, tc.want)
}
})
}
}t.Run 把每个用例拆成子测试。失败时能直接看到是哪组输入出问题。
三、配置解析测试
配置解析容易出错的地方通常是路径、JSON 格式、字段名和默认值。测试时用临时目录,不需要在项目里放很多一次性文件。
假设配置读取函数在 internal/config/target.go:
go
package config
import (
"encoding/json"
"fmt"
"os"
)
type Target struct {
Type string `json:"type"`
Name string `json:"name"`
URL string `json:"url"`
TimeoutSeconds int `json:"timeout_seconds"`
}
func LoadTargets(path string) ([]Target, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}
var targets []Target
if err := json.Unmarshal(data, &targets); err != nil {
return nil, fmt.Errorf("parse config %s: %w", path, err)
}
return targets, nil
}internal/config/target_test.go:
go
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadTargets(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "targets.json")
data := []byte(`[
{"type":"http","name":"api","url":"https://example.com","timeout_seconds":3}
]`)
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatalf("write temp config: %v", err)
}
targets, err := LoadTargets(path)
if err != nil {
t.Fatalf("LoadTargets() error = %v", err)
}
if len(targets) != 1 {
t.Fatalf("len(targets) = %d, want 1", len(targets))
}
if targets[0].Name != "api" {
t.Fatalf("target name = %q, want api", targets[0].Name)
}
}t.TempDir() 为测试创建临时目录,测试结束后自动清理。配置测试里用它比写固定路径更稳,避免不同机器上路径不一致。
非法 JSON 也要测:
go
func TestLoadTargetsInvalidJSON(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "targets.json")
if err := os.WriteFile(path, []byte(`{bad json}`), 0644); err != nil {
t.Fatalf("write temp config: %v", err)
}
if _, err := LoadTargets(path); err == nil {
t.Fatal("expected parse error")
}
}这类测试覆盖的是工具自身错误。配置坏了时,入口应该走退出码 2,而不是当成检查目标失败。
四、HTTP 测试
HTTP 检查不能依赖真实外部站点。外部网络会受 DNS、代理、证书、对方限流影响,测试结果不稳定。Go 标准库里的 httptest 可以启动一个临时 HTTP 服务。
假设 CheckHTTP 读取 Target.URL 并返回 Result:
go
func CheckHTTP(ctx context.Context, target config.Target) Resultinternal/checker/http_test.go:
go
package checker
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"example.com/ops-checker/internal/config"
)
func TestCheckHTTPStatusOK(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
result := CheckHTTP(context.Background(), config.Target{
Name: "api",
URL: server.URL,
TimeoutSeconds: 1,
})
if !result.OK {
t.Fatalf("result.OK = false, message = %s", result.Message)
}
if result.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", result.StatusCode, http.StatusOK)
}
}
func TestCheckHTTPStatusFailed(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer server.Close()
result := CheckHTTP(context.Background(), config.Target{
Name: "api",
URL: server.URL,
TimeoutSeconds: 1,
})
if result.OK {
t.Fatal("expected failed result")
}
if result.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", result.StatusCode, http.StatusServiceUnavailable)
}
}httptest.NewServer 分配一个本地端口,server.URL 就是临时服务地址。测试结束时要 server.Close(),否则端口和后台 goroutine 会继续占着。
HTTP 检查里,503 是远端服务状态,Result.OK=false 是工具判断结果。测试要同时覆盖这两层含义。
五、Mock 与 Fake
Go 里的 mock 不必一开始上框架。把外部依赖收成接口,再在测试里写一个很小的 fake 实现。
例如 Kubernetes Pod 检查直接依赖 *kubernetes.Clientset 时,测试会比较重。先抽一个只包含当前需要方法的接口:
go
type PodLister interface {
ListPods(ctx context.Context, namespace string, labelSelector string) ([]corev1.Pod, error)
}检查函数依赖接口:
go
func CheckPods(ctx context.Context, lister PodLister, target config.Target) Result {
pods, err := lister.ListPods(ctx, target.Namespace, target.LabelSelector)
if err != nil {
return Result{Name: target.Name, OK: false, Message: err.Error()}
}
ready := 0
for _, pod := range pods {
if isPodReady(pod) {
ready++
}
}
return Result{Name: target.Name, OK: ready >= target.MinReady}
}测试里写 fake:
go
type fakePodLister struct {
pods []corev1.Pod
err error
}
func (f fakePodLister) ListPods(ctx context.Context, namespace string, labelSelector string) ([]corev1.Pod, error) {
return f.pods, f.err
}这个 fake 只服务当前测试。没有多个调用方时,不需要把它抽成公共测试工具。
六、Race Detector
并发代码容易有数据竞争——多个 goroutine 同时读写同一个变量,结果不可预期。Go 内置 race detector:
bash
go test -race ./...race detector 会在运行测试时监控内存访问,发现竞争就打印报告。它会让测试变慢 5-10 倍,通常放在 CI 或本地专项检查里跑,不是每次 go test 都开。
七、CI 检查命令
CI 里放格式化、测试和静态检查三条命令:
bash
go fmt ./...
go vet ./...
go test ./...并发代码较多时加 race:
bash
go test -race ./...| 命令 | 作用 |
|---|---|
go test ./... | 跑所有测试 |
go test -race ./... | 检查数据竞争,适合并发代码 |
go test -cover ./... | 看测试覆盖率 |
go vet ./... | 做静态检查,比如格式化字符串参数错误 |
go fmt ./... | 格式化代码 |
CI 里最容易忽略的是退出码。go test、go vet、go fmt 都会通过退出码告诉外层系统是否失败。脚本里如果把错误吞掉,流水线就会显示成功,实际问题已经留到发布阶段。