Skip to content

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

假设 ResultHasFailedinternal/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/checker

t.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 testgo vetgo fmt 都会通过退出码告诉外层系统是否失败。脚本里如果把错误吞掉,流水线就会显示成功,实际问题已经留到发布阶段。