《互联网产品的服务端架构·交易、增长与平台化》13.2 券与活动的端到端验证

从一次单测全绿、上线后却退款不退券的活动事故切入,说明券与活动的端到端验证必须覆盖库存、预算、防刷、退款回补四类不变量。给出用 httptest 真跑多步 HTTP 场景、失败后继续执行并捕获 panic 的 Go 实现与测试,复盘模块级 mock 掩盖的预算重复占用。

单测全绿的券活动,上线后退款不退券

「拾集」在中秋前做了一轮满减券活动:用户领券后下单,满 199 元减 30 元,券的数量和活动预算都有上限。券服务、订单服务、支付服务、退款服务各自有单元测试,覆盖率看起来不错,发布评审也顺利通过。

活动上线第三天,财务发现预算消耗比预期快了 18%。运营查后台,券的领取量没有超,但核销量和退款量对不上:一部分订单退款成功后,券没有回到用户的券包,也没有回到活动库存;用户重新下单时又领了一张新券,预算被同一笔交易占用了两次。

更麻烦的是,这个问题在单元测试里完全看不出来。券服务的单测只验证“核销时库存减一”,退款服务的单测只验证“退款金额正确”,两边都 mock 了对方,返回的都是成功。真正串起来跑的时候,退款服务根本没有调用券服务的回补接口,因为需求评审时没人把“退款要退券”写进接口契约。

券与活动的验证难点不在单个模块,而在跨模块的状态不变量。库存、预算、防刷、退款回补这四类约束,任何一类只在模块内部成立、在链路上不成立,活动就会以看起来正常的方式亏钱。

端到端验证要覆盖的四类不变量

端到端验证不是把所有接口都点一遍,而是选一条真实用户路径,在每一步之后检查不变量是否仍然成立。券活动的路径通常是:领券、下单、支付、退款。每一步都会改变至少一个计数器,验证要盯住这些计数器之间的关系。

不变量具体含义在哪一步断言常见破坏方式
库存已领取数加剩余数等于发行总量领券后、退款回补后并发领取超发;回补时重复加库存
预算已核销金额加已退款金额不超过活动预算支付后、退款后退款不回补预算;同一订单重复核销
防刷同一用户、设备、手机号在窗口内只能领有限张领券后、换号重试后只校验用户 ID,不校验设备和手机号
退款回补退款成功后券回到可用状态或退回活动库存退款后退款服务不知道券的存在;回补非幂等

这四类不变量有一个共同点:它们都不是单个服务的私有状态,而是多个服务共享的账。库存由券服务管,预算由营销服务管,防刷由风控管,退款回补横跨退款与券两个服务。只测单个服务,等于只检查账本的某一页。

端到端验证要真跑 HTTP,而不是直接调用 service 方法。直接调方法会绕过参数绑定、路由、中间件、事务边界和错误码,这些恰恰是跨模块问题最喜欢藏身的地方。用 httptest 在进程内起一个真实 handler,既能覆盖完整的请求处理链,又不需要部署环境。

验证还要允许失败后继续。券活动的故障往往有连锁反应:退款没退券之后,库存是否仍然被占用、预算是否还能继续核销、用户能否再次领取,这些都需要在同一个场景里看到。如果第一个断言失败就中断,后面几个问题会被掩盖。

失败场景:退款不退券,预算被重复占用

「拾集」这轮活动的案例假设值如下:

  • 发行券 50,000 张,活动预算 150 万元,单券面额 30 元。
  • 活动期间支付订单 18,400 笔,退款订单 2,300 笔,退款率约 12.5%。
  • 退款成功的订单中,约 61% 的券没有回补,涉及 1,403 张券。
  • 这些券对应的预算被重复占用,财务测算多消耗约 4.2 万元。
  • 单元测试全部通过,集成环境只验了“领券后能下单”,没有验退款路径。

根因有三个,按发现顺序排列:

  1. 退款服务只处理资金,不知道订单用了券。退款成功事件没有携带券 ID,券服务也没有订阅退款事件。
  2. 券服务的回补接口要求传券 ID 和订单号,但没有做幂等。即使接上了,重试也会把同一张券回补两次。
  3. 活动预算按“核销”扣减,退款后没有对应的预算回补动作。预算表只增不减,活动越到后期越早触发上限。

修复时先补契约,再补幂等,最后补监控。契约层面,退款事件必须携带订单使用的券 ID 和分摊金额;幂等层面,回补接口以订单号加券 ID 作为唯一键;监控层面,每天对比“退款成功数”和“券回补成功数”,差额超过阈值就告警。

这套修复没有增加什么高深技术,只是把“退款要退券”从一句口头约定变成了可执行的断言。端到端测试的价值就在这里:它逼着团队把跨模块的约定写下来,并且在每次发布前跑一遍。

Go 实现:Run 跑真实 HTTP 场景

下面的代码来自本书配套的 webapp-lab。Run 接收一个场景和一个 http.Handler,用 httptest.NewRequest 和 httptest.NewRecorder 逐步执行。它不启动真实端口,但在进程内走完整的 handler 链,路由、方法匹配、状态码和响应写入都会真实发生。

单步失败不会中断后续步骤。Failed 里记录步骤名和原因,Passed 只统计完全符合期望的步骤。这样一次运行就能看到“退款返回 409”之后,库存和预算相关的后续请求是否还能通过,而不是在第一个失败处停下。

handler 的 panic 会被 recover 转成失败记录。端到端验证跑的是别人写的路由,一个空指针或越界不应该让整轮验证崩掉。方法或路径为空时直接记为失败,避免 httptest.NewRequest 在构造请求时 panic。

// file: internal/growthbase/e2e.go
// tier: L1
// verify: go test ./internal/growthbase/

package growthbase

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
)

// RequestStep 描述一次端到端请求及其期望状态码。
type RequestStep struct {
	Name       string
	Method     string
	Path       string
	Body       string
	WantStatus int
}

// Scenario 是一组按顺序执行的请求。
type Scenario struct {
	Name  string
	Steps []RequestStep
}

// Result 汇总一次场景执行的结果。Failed 中是失败步骤及其原因。
type Result struct {
	Passed int
	Failed []string
}

// Run 在进程内用 httptest 真跑整个场景。
//
// 单步失败不会中断后续步骤:端到端验证要看到“退款没退券”之后
// 库存是否仍被占用,而不是在第一个断言失败时就停下。handler 的 panic
// 被转换成失败记录,避免一个坏路由拖垮整轮验证。
func Run(sc Scenario, h http.Handler) (Result, error) {
	if h == nil {
		return Result{}, fmt.Errorf("growthbase: scenario %q has nil handler", sc.Name)
	}
	if len(sc.Steps) == 0 {
		return Result{}, fmt.Errorf("growthbase: scenario %q has no steps", sc.Name)
	}
	var res Result
	for _, st := range sc.Steps {
		code, err := runStep(st, h)
		if err != nil {
			res.Failed = append(res.Failed, fmt.Sprintf("%s: %v", st.Name, err))
			continue
		}
		if code != st.WantStatus {
			res.Failed = append(res.Failed, fmt.Sprintf("%s: status %d, want %d", st.Name, code, st.WantStatus))
			continue
		}
		res.Passed++
	}
	return res, nil
}

func runStep(st RequestStep, h http.Handler) (code int, err error) {
	if strings.TrimSpace(st.Method) == "" {
		return 0, fmt.Errorf("empty method")
	}
	if strings.TrimSpace(st.Path) == "" {
		return 0, fmt.Errorf("empty path")
	}
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("handler panic: %v", r)
		}
	}()
	req := httptest.NewRequest(st.Method, st.Path, strings.NewReader(st.Body))
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return rec.Code, nil
}

有一个边界需要说清楚:Run 只负责执行和记录,不负责判断业务不变量。它不知道“退款后券应该回到可用状态”,只知道“这个请求返回了 200”。业务断言要么写在 handler 的响应里,要么在场景之后单独查询状态。把业务判断塞进 Run 会让这个工具变成某个活动的专用测试器。

另一个边界是状态共享。Run 按顺序调用同一个 handler,handler 背后的存储需要自己保证状态连续。如果每个请求都新建一个内存存储,场景就退化成一组独立请求,跨步骤的不变量根本验不出来。测试里的 handler 要模拟一个有状态的系统,而不是一组无状态的路由。

测试:失败继续、panic 与空场景

测试里的 tradeHandler 用 http.ServeMux 模拟了四个路由:查看购物车、创建订单、支付、退款。退款路由故意返回 409,用来验证“失败后继续”确实生效,而不是在第三个步骤就提前退出。

// file: internal/growthbase/e2e_test.go
// tier: L1
// verify: go test ./internal/growthbase/

package growthbase

import (
	"net/http"
	"strings"
	"testing"
)

func tradeHandler() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /cart", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})
	mux.HandleFunc("POST /orders", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusCreated)
	})
	mux.HandleFunc("POST /orders/o-1/pay", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})
	mux.HandleFunc("POST /orders/o-1/refund", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusConflict)
	})
	return mux
}

func TestRunContinuesAfterFailure(t *testing.T) {
	sc := Scenario{Name: "trade-e2e", Steps: []RequestStep{
		{Name: "view-cart", Method: "GET", Path: "/cart", WantStatus: http.StatusOK},
		{Name: "create-order", Method: "POST", Path: "/orders", Body: `{"sku":"a"}`, WantStatus: http.StatusCreated},
		{Name: "pay-order", Method: "POST", Path: "/orders/o-1/pay", WantStatus: http.StatusOK},
		{Name: "refund", Method: "POST", Path: "/orders/o-1/refund", WantStatus: http.StatusOK},
	}}
	res, err := Run(sc, tradeHandler())
	if err != nil {
		t.Fatalf("run: %v", err)
	}
	if res.Passed != 3 {
		t.Fatalf("passed = %d, want 3", res.Passed)
	}
	if len(res.Failed) != 1 {
		t.Fatalf("failed = %v, want one entry", res.Failed)
	}
	if !strings.Contains(res.Failed[0], "status 409, want 200") {
		t.Fatalf("unexpected failure %q", res.Failed[0])
	}
}

func TestRunRejectsEmptyScenario(t *testing.T) {
	if _, err := Run(Scenario{Name: "empty"}, tradeHandler()); err == nil {
		t.Fatal("want error for empty scenario")
	}
}

func TestRunRejectsNilHandler(t *testing.T) {
	if _, err := Run(Scenario{Name: "nil", Steps: []RequestStep{{Name: "x", Method: "GET", Path: "/"}}}, nil); err == nil {
		t.Fatal("want error for nil handler")
	}
}

func TestRunRecoversHandlerPanic(t *testing.T) {
	h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		panic("boom")
	})
	res, err := Run(Scenario{Name: "panic", Steps: []RequestStep{
		{Name: "boom", Method: "GET", Path: "/boom", WantStatus: http.StatusOK},
	}}, h)
	if err != nil {
		t.Fatalf("run returned error: %v", err)
	}
	if res.Passed != 0 || len(res.Failed) != 1 {
		t.Fatalf("passed = %d, failed = %v", res.Passed, res.Failed)
	}
	if !strings.Contains(res.Failed[0], "handler panic") {
		t.Fatalf("unexpected failure %q", res.Failed[0])
	}
}

func TestRunRejectsEmptyMethodAndPath(t *testing.T) {
	res, err := Run(Scenario{Name: "bad", Steps: []RequestStep{
		{Name: "no-method", Method: " ", Path: "/cart", WantStatus: http.StatusOK},
		{Name: "no-path", Method: "GET", Path: " ", WantStatus: http.StatusOK},
	}}, tradeHandler())
	if err != nil {
		t.Fatalf("run: %v", err)
	}
	if res.Passed != 0 || len(res.Failed) != 2 {
		t.Fatalf("passed = %d, failed = %v", res.Passed, res.Failed)
	}
}

TestRunContinuesAfterFailure 断言 Passed 为 3、Failed 只有一条,并且失败原因是状态码不符。如果 Run 在退款失败时中断,Passed 仍然是 3,但测试无法区分“继续执行后没有更多步骤”和“提前退出”,所以场景里把退款放在最后,用失败条目数量来锁定行为。

TestRunRecoversHandlerPanic 用一个必定 panic 的 handler,验证 Run 返回正常结果而不是让测试进程崩溃。这里 Run 的 error 为 nil,panic 被记入 Failed。这是刻意的:单个步骤 panic 是场景失败,不是工具失败;只有空场景、nil handler 这类调用方错误才返回 error。

TestRunRejectsEmptyMethodAndPath 覆盖了请求构造前的校验。空方法和空路径如果不拦,httptest.NewRequest 可能 panic,recover 虽然能兜住,但错误信息会变成“handler panic”,掩盖真正的原因。提前校验能让失败原因更准确。

测试没有覆盖响应体内容。当前 Result 只记录状态码,因为券活动的很多不变量体现在后续请求的结果里,而不是单个响应体。如果业务需要断言响应字段,应该在 handler 里把关键状态编码进状态码,或者扩展 Result 增加响应体快照,而不是在测试里解析 HTML。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/growthbase/ -run 'TestRun' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestRunContinuesAfterFailure
--- PASS: TestRunContinuesAfterFailure (0.00s)
=== RUN   TestRunRejectsEmptyScenario
--- PASS: TestRunRejectsEmptyScenario (0.00s)
=== RUN   TestRunRejectsNilHandler
--- PASS: TestRunRejectsNilHandler (0.00s)
=== RUN   TestRunRecoversHandlerPanic
--- PASS: TestRunRecoversHandlerPanic (0.00s)
=== RUN   TestRunRejectsEmptyMethodAndPath
--- PASS: TestRunRejectsEmptyMethodAndPath (0.00s)
PASS
ok  	webapp/internal/growthbase	0.367s

输出通过说明执行器按预期工作,不说明券活动的不变量被覆盖了。真正的覆盖度取决于场景里放了多少步骤、每个步骤之后断言了什么。一个只有“领券、下单、支付”的场景,永远发现不了退款不退券。

小结

券与活动的端到端验证,核心不是接口覆盖率,而是四类共享账本:库存、预算、防刷、退款回补。它们横跨多个服务,单元测试的 mock 会让每一页账看起来都对,只有串起来真跑才能发现账本之间的差额。

Run 做的是执行和记录:用 httptest 真跑 handler,单步失败继续,panic 转成失败。它不替业务做判断,也不假装自己懂券的规则。把场景设计好、把不变量断言放在正确的位置,才是端到端验证真正的成本。

阅读导航:上一节:13.1 下单→支付→履约→对账 · 下一节:13.3 进入卷四 。

常见错误与边界

  • 只测正向路径。领券、下单、支付都通过,不代表退款、取消、超时关闭也能正确回补。
  • 在单元测试里 mock 掉跨模块调用。mock 永远返回成功,恰好掩盖了“根本没调用”的问题。
  • 退款回补不做幂等。退款重试或消息重复投递时,同一张券会被回补多次。
  • 预算只扣不回。退款后预算不回补,活动会提前触发上限,后续用户无法参与。
  • 防刷只校验用户 ID。换设备、换手机号、换支付账号都能绕过,必须组合多个维度。
  • 端到端场景直接调用 service 方法。绕过路由和中间件后,参数绑定和错误码问题测不出来。
  • 第一个断言失败就终止场景。跨模块故障往往有连锁反应,后面的不变量同样需要观察。
  • 把状态码当成业务断言。200 只说明请求被处理,不说明券回补了、预算释放了。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查