← 中文资源

简体中文版本

可观测性驱动测试(Observability-Driven Testing)

可观测性驱动测试把 trace、指标和日志作为测试证据,不仅验证返回值,还验证重试、回退和跨服务调用等内部行为。

阅读英文原文

超越响应体

请求可以返回 200,却在内部做错事:重试三次、回退到过期缓存、跳过审计事件或调用已弃用端点。功能断言只能看到响应。可观测性驱动测试把 trace、指标和日志当作一等测试证据:断言预期的 span 存在、重试次数为零、缓存被查询、延迟在预算内。

断言什么

断言稳定的语义:span 名称、父子关系、状态码和文档化属性,例如 HTTP 路由、数据库操作或消息目的地。断言敏感字段不存在。断言依赖失败产生了预期回退,而不是未处理错误。指标断言可以检查计数器和直方图,但需要确定的作用域,避免并行测试互相干扰。

埋点即功能

遥测也是代码。把 span 属性和指标标签视为被测接口的一部分,像 API 契约一样评审。缺失 span 或高基数标签是缺陷,不是外观问题。无法观测的功能难以运维,因此测试其遥测也是测试功能的一部分。与只测输出相比,这种方法能更早发现静默劣化。

测试环境与局限

在集成和端到端测试中使用内存或本地导出器运行这些断言,避免依赖共享可观测性后端,否则测试会变慢且不稳定。例如,测试可以在内存中捕获 span,断言下游超时恰好产生一次重试和一个回退 span。如果断言依赖自由文本日志或实现细节,遥测断言会很脆弱;它们还增加搭建成本并可能拖慢套件。只在行为本来不可见的地方使用:重试、回退、异步工作和跨服务流程。与仪表盘和合成检查配合,让相同信号也用于生产。

代码示例

package main

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

func normalizeEmail(raw string) (string, error) {
	email := strings.TrimSpace(strings.ToLower(raw))
	at := strings.IndexByte(email, '@')
	if at <= 0 || at == len(email)-1 || strings.Count(email, "@") != 1 {
		return "", fmt.Errorf("invalid email %q", raw)
	}
	return email, nil
}

func TestNormalizeEmail(t *testing.T) {
	t.Parallel()
	cases := []struct {
		name    string
		input   string
		want    string
		wantErr bool
	}{
		{"lowercases and trims", "  [email protected] ", "[email protected]", false},
		{"rejects missing at", "ada.example.com", "", true},
		{"rejects empty local part", "@example.com", "", true},
		{"rejects double at", "ada@@example.com", "", true},
	}

	for _, testCase := range cases {
		testCase := testCase
		t.Run(testCase.name, func(t *testing.T) {
			t.Parallel()
			got, err := normalizeEmail(testCase.input)
			if testCase.wantErr {
				if err == nil {
					t.Fatalf("expected error, got %q", got)
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if got != testCase.want {
				t.Fatalf("got %q, want %q", got, testCase.want)
			}
		})
	}
}

func ExampleNormalizeEmail() {
	email, _ := normalizeEmail("  [email protected] ")
	fmt.Println(email)
	// Output: [email protected]
}
package main

import (
	"errors"
	"testing"
)

var errNotFound = errors.New("order not found")

func ShippingCost(weightKg int, express bool, tier string) (int, error) {
	if weightKg <= 0 {
		return 0, errors.New("weight must be positive")
	}
	if weightKg > 30 {
		return 0, errors.New("weight exceeds courier limit")
	}

	base := 8
	if weightKg > 10 {
		base += (weightKg - 10) * 2
	}
	if express {
		base *= 2
	}
	if tier == "gold" {
		base -= 4
	}
	return base, nil
}

func TestShippingCost(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name    string
		weight  int
		express bool
		tier    string
		want    int
		wantErr error
	}{
		{name: "light standard", weight: 2, express: false, tier: "", want: 8},
		{name: "light express", weight: 2, express: true, tier: "", want: 16},
		{name: "heavy adds per kg", weight: 15, express: false, tier: "", want: 18},
		{name: "heavy express gold", weight: 15, express: true, tier: "gold", want: 32},
		{name: "gold floor at zero", weight: 1, express: false, tier: "gold", want: 4},
		{name: "zero weight rejected", weight: 0, express: false, tier: "", wantErr: errors.New("weight must be positive")},
		{name: "over courier limit", weight: 31, express: false, tier: "", wantErr: errors.New("weight exceeds courier limit")},
	}

	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			got, err := ShippingCost(tt.weight, tt.express, tt.tier)
			if tt.wantErr != nil {
				if err == nil || err.Error() != tt.wantErr.Error() {
					t.Fatalf("ShippingCost(%d, %v, %q) error = %v, want %v",
						tt.weight, tt.express, tt.tier, err, tt.wantErr)
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if got != tt.want {
				t.Errorf("ShippingCost(%d, %v, %q) = %d, want %d",
					tt.weight, tt.express, tt.tier, got, tt.want)
			}
		})
	}
}