概念定义
两个服务之间有一份不成文的协议,每次部署都在悄悄改写它。漂移就是无人宣告的改写的累积——生产环境成了集成测试场。
工作机制
消费方测试生成编码所需交互的 pact 文件:路径、字段、类型、状态码;提供方流水线把 pact 回放到真实服务,任何不匹配都让提供方构建失败。基于模式的变体从实现生成实时规格并与已提交契约 diff,拒绝破坏性变更。
使用场景
多团队服务生态、有外部消费方的公开 API、多订阅者的事件模式。先广播契约变更、让消费方重新验证,然后再部署。
常见误区
“提供方测试通过就安全”是错觉:提供方验证自己,消费方依赖的是提供方可能不知道的子集。静态 OpenAPI 文档自身也会与实现漂移——必须在 CI 中用真实行为对照模式做 diff。pact 只验证录制过的子集:新的消费需求要新增 pact 条目;事件契约要用带兼容模式的模式注册中心,而不是 HTTP 形态的工具。被跳过的验证流水线就是多了几个步骤的漂移。
代码示例
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)
}
})
}
}