Appearance
Go测试与工程化
Go 项目写到 ops-checker 这种程度后,代码里已经有配置读取、HTTP 检查、Kubernetes 检查、并发调度、输出格式和退出码,几块逻辑会互相影响。测试覆盖这些容易出错的边缘:配置解析、结果判断、HTTP 状态码、超时、输出格式。
Go 自带 testing 包,基础阶段不需要额外测试框架。文件以 _test.go 结尾,测试函数以 Test 开头,执行入口是 go test。
一、测试文件
Go 的测试文件和被测文件放在同一个包里。internal/checker/result.go 对应的测试文件通常叫 internal/checker/result_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 里常用表格驱动测试。它不是特殊语法,只是把测试用例放进切片里循环执行。
替换文件:internal/checker/result_test.go
go
package checker
import "testing"
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 会把每个用例拆成子测试。失败时能直接看到是哪组输入出问题,比只打印一行 failed 更容易定位。
三、配置解析
配置解析容易出错的地方通常是路径、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) Result新增文件:internal/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)
}
}httptest.NewServer 会分配一个本地端口,server.URL 就是临时服务地址。测试结束时要 server.Close(),否则端口和后台 goroutine 会继续占着。
失败状态也要测:
go
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)
}
}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 只服务当前测试。没有多个调用方时,不需要把它抽成公共测试工具。
六、日志
Go 1.21 以后标准库有 log/slog。工具型程序里,日志重点是目标名、检查类型、耗时、错误原因和退出码。只写一段自然语言错误,后面很难从日志里筛选。
入口里初始化 logger:
go
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))检查时带字段:
go
logger.Info(
"check finished",
"name", result.Name,
"ok", result.OK,
"status_code", result.StatusCode,
"cost_ms", result.CostMS,
)日志字段名尽量稳定。比如一直用 status_code,就不要一会儿写 status,一会儿写 code。日志被采集后,字段名变化会影响筛选和面板。
七、工程检查
Go 自带几条常用检查命令:
bash
go test ./...
go test -race ./...
go test -cover ./...
go vet ./...
go fmt ./...含义:
| 命令 | 作用 |
|---|---|
go test ./... | 跑所有测试 |
go test -race ./... | 检查数据竞争,适合并发代码 |
go test -cover ./... | 看测试覆盖率 |
go vet ./... | 做静态检查,比如格式化字符串参数错误 |
go fmt ./... | 格式化代码 |
-race 对并发工具很有价值。普通测试未必暴露共享 map、共享切片和全局变量的竞争,race detector 能在运行测试时捕捉这类读写冲突。它会让测试变慢,通常放在 CI 或本地专项检查里。
八、目录整理
测试和工程化加上后,ops-checker 的目录可以保持这样:
text
ops-checker/
├── cmd/
│ └── ops-checker/
│ └── main.go
├── configs/
│ └── targets.json
├── internal/
│ ├── checker/
│ │ ├── http.go
│ │ ├── http_test.go
│ │ ├── result.go
│ │ └── result_test.go
│ ├── config/
│ │ ├── target.go
│ │ └── target_test.go
│ └── output/
│ ├── json.go
│ └── text.go
├── go.mod
└── go.sum测试文件靠近被测文件,读代码时能直接看到这个函数有哪些边缘情况。configs/ 放示例配置,不放真实环境密钥。真实配置通常由部署系统、ConfigMap、Secret 或主机目录提供。
九、CI命令
CI 里放格式化、测试和静态检查三条命令。流程很短,但能挡住格式、编译和基础测试问题。
bash
go fmt ./...
go vet ./...
go test ./...并发代码较多时加 race:
bash
go test -race ./...CI 里最容易忽略的是退出码。go test、go vet、go fmt 都会通过退出码告诉外层系统是否失败。脚本里如果把错误吞掉,流水线就会显示成功,实际问题已经留到发布阶段。