为什么单模块全绿,整条链路仍会错
前十二章的测试各自守住了边界:trade-model 守住整数分,ownership 守住字段归属,price-breakdown 守住同一份明细,submit 守住幂等键,支付与退款模块各自守住状态迁移。单独看,每一组用例都能通过。
但交易事故很少发生在某个模块内部。它更常出现在模块交接的那一行:购物车算出的金额传给下单时换了口径,下单返回的订单号传给支付时为空,支付成功后调用退款时用了支付前的订单快照。单模块测试不经过这些交接点,所以它们全绿,链路却错了。
端到端场景的作用不是「再测一遍」,而是把交接点变成断言。它只回答四个问题:
- 用户看到的金额、提交的金额、订单的金额、支付的金额、退款的金额,是不是同一个数?
- 一次点击最终只产生一笔订单吗?
- 支付结果不确定时,系统是去查询,还是直接退款或再付一次?
- 失败停在哪一步,后面哪些动作绝对没有发生?
这四个问题无法由任何单个模块回答。
场景边界:一条主线,五个阶段
场景只保留一条最短主线,不覆盖所有分支。分支由前十二章的分模块测试负责,端到端负责「串起来仍然成立」。
| 阶段 | 输入 | 关键断言 | 失败后停在哪 |
|---|---|---|---|
| 浏览 | SKU、数量、地区 | 可售、未超限购 | 浏览 |
| 加购 | 单价、数量、优惠、运费 | 购物车小计与明细一致 | 购物车 |
| 下单 | 幂等键、实付金额 | 只产生一笔订单 | 下单 |
| 支付 | 订单号、金额 | 金额一致;未知先查询 | 支付 |
| 退款 | 支付单号、退款金额 | 不超过实付;未支付不退款 | 退款 |
这张表里最重要的不是「做什么」,而是最后一列「失败后停在哪」。很多实现失败后仍然继续往下走:下单被拒绝,页面却已经拉起支付;支付结果未知,代码却进入退款分支。端到端场景通过「提前返回」把这条线钉死。
另一个边界是不接真实渠道。真实签名、真实网络延迟、真实回调丢包属于 L3,本节保持 L2:进程内假服务端加假时钟。这样才能在毫秒级反复跑,而不是每次等几十秒回调。
关键决策:真模块、窄端口、假实现
写端到端最容易走两个极端:全部用真模块,测试变成集成环境;全部用假对象,测试退化成自说自话。本节的分工是:
- 真模块:购物车汇总与价格明细直接调用第 3 章的
summarize与buildBreakdown,确保金额口径不是场景自己编的。 - 窄端口:商品、下单、支付、退款只暴露场景真正需要的五个方法,不把内部状态机类型泄漏进来。
- 假实现放测试侧:源文件只保留端口与编排,内存世界写在测试文件里。支付与对账模块仍在演进,这样替换真实实现时不用改场景代码。
端口设计有三条约束:
- 每个方法都带时间参数或由时钟注入,逻辑里没有
Date.now()。 - 提交与退款都接受幂等键,场景不假设「只点一次」。
- 查询是独立方法,不能把「发起支付」和「查询结果」合成一个调用。
第三条尤其重要。把两者合成一个 payAndConfirm,场景就无法表达「支付超时后只查询、不再扣款」这个关键分支。
场景代码
源文件只做编排:按顺序调用端口,记录轨迹,检查金额与订单数,任何一步失败就提前返回。
// file: src/summary/e2e-scenario.ts
// tier: L2
// verify: node --test test/summary/e2e-scenario.test.ts
import { summarize } from "../cart/selection.ts";
import { buildBreakdown } from "../cart/price-breakdown.ts";
export type E2eStage = "browse" | "cart" | "submit" | "pay" | "refund";
export interface TraceEntry { stage: E2eStage; at: number; action: string }
export interface Quote {
skuId: string; unitCents: number; available: number; maxPerOrder: number;
}
export interface SubmitCommand {
key: string; skuId: string; quantity: number; payableCents: number;
}
export type SubmitReceipt =
| { status: "accepted"; orderId: string; payableCents: number }
| { status: "unknown"; queryKey: string }
| { status: "rejected"; reason: string };
export type PayReceipt =
| { status: "succeeded"; payId: string }
| { status: "failed"; reason: string }
| { status: "unknown"; payId: string }
| { status: "rejected"; reason: string };
export type PayQuery =
| { status: "succeeded"; payId: string }
| { status: "failed"; reason: string }
| { status: "pending" };
export type RefundReceipt =
| { status: "accepted"; refundId: string; amountCents: number }
| { status: "rejected"; reason: string };
// 场景只依赖五个窄接口;支付与对账实现可以随时替换。
export interface TradePorts {
quote(skuId: string, quantity: number, now: number): Quote | null;
submit(command: SubmitCommand, now: number): SubmitReceipt;
findOrder(key: string): { orderId: string; payableCents: number } | null;
pay(orderId: string, amountCents: number, now: number): PayReceipt;
queryPay(payId: string): PayQuery;
refund(orderId: string, payId: string, amountCents: number, now: number): RefundReceipt;
refundedCents(orderId: string): number;
stats(): { orders: number; payAttempts: number; refunds: number };
}
export interface ScenarioClock { now(): number; advance(ms: number): number }
export interface ScenarioInput {
skuId: string; quantity: number; submitKey: string; now: number;
discountCents?: number; shippingCents?: number;
}
export type ScenarioOutcome = "refunded" | "paid" | "payment_failed" | "blocked";
export interface ScenarioResult {
ok: boolean; outcome: ScenarioOutcome; payableCents: number | null;
orderId: string | null; payId: string | null; refundId: string | null;
refundedCents: number; trace: TraceEntry[]; problems: string[];
}
function note(
rows: TraceEntry[], stage: E2eStage, at: number, action: string,
): void {
rows.push({ stage, at, action });
}
// 一条主线:选品 → 加购 → 下单 → 支付 → 退款。每一步只做一次,失败立即停。
export function runScenario(
ports: TradePorts,
input: ScenarioInput,
clock: ScenarioClock,
refundCents?: number,
): ScenarioResult {
const rows: TraceEntry[] = [];
const problems: string[] = [];
const now = (): number => clock.now();
const quote = ports.quote(input.skuId, input.quantity, now());
note(rows, "browse", now(), "quote");
if (quote === null) {
problems.push("商品不可售或数量超出限购");
return finish("blocked", null, null, null, null, rows, problems, ports);
}
if (quote.available < input.quantity) {
problems.push("可售库存不足");
return finish("blocked", null, null, null, null, rows, problems, ports);
}
const cart = summarize([{
skuId: quote.skuId, unitCents: quote.unitCents, quantity: input.quantity,
maxQuantity: quote.maxPerOrder, status: "active", selected: true,
}]);
const breakdown = buildBreakdown({
version: 1,
lines: [{ skuId: quote.skuId, unitCents: quote.unitCents, quantity: input.quantity }],
discountCents: input.discountCents ?? 0,
shippingCents: input.shippingCents ?? 0,
taxCents: 0,
source: "server",
});
note(rows, "cart", now(), "summarize");
if (breakdown.payableCents !==
cart.selectedSubtotalCents + (input.shippingCents ?? 0) -
(input.discountCents ?? 0)) {
problems.push("购物车小计与价格明细不一致");
}
const receipt = ports.submit({
key: input.submitKey, skuId: input.skuId, quantity: input.quantity,
payableCents: breakdown.payableCents,
}, now());
note(rows, "submit", now(), `submit:${receipt.status}`);
if (receipt.status === "rejected") {
problems.push(`下单被拒绝:${receipt.reason}`);
return finish("blocked", breakdown.payableCents, null, null, null, rows, problems, ports);
}
let orderId: string;
let payableCents: number;
if (receipt.status === "accepted") {
orderId = receipt.orderId;
payableCents = receipt.payableCents;
} else {
const found = ports.findOrder(receipt.queryKey);
note(rows, "submit", now(), "query_order");
if (found === null) {
problems.push("提交结果未知且查询不到订单");
return finish("blocked", breakdown.payableCents, null, null, null, rows, problems, ports);
}
orderId = found.orderId;
payableCents = found.payableCents;
}
if (payableCents !== breakdown.payableCents) {
problems.push("订单实付与结算明细不一致");
}
const paid = ports.pay(orderId, payableCents, now());
note(rows, "pay", now(), `pay:${paid.status}`);
let payId: string;
if (paid.status === "succeeded") {
payId = paid.payId;
} else if (paid.status === "failed") {
return finish("payment_failed", payableCents, orderId, null, null, rows, problems, ports);
} else if (paid.status === "rejected") {
problems.push(`支付被拒绝:${paid.reason}`);
return finish("blocked", payableCents, orderId, null, null, rows, problems, ports);
} else {
payId = paid.payId;
let resolved: PayQuery | null = null;
for (let i = 0; i < 3; i += 1) {
clock.advance(1_000);
resolved = ports.queryPay(payId);
note(rows, "pay", now(), `query:${resolved.status}`);
if (resolved.status !== "pending") break;
}
if (resolved === null || resolved.status === "pending") {
problems.push("支付结果三次查询仍未确认");
return finish("payment_failed", payableCents, orderId, payId, null, rows, problems, ports);
}
if (resolved.status === "failed") {
return finish("payment_failed", payableCents, orderId, payId, null, rows, problems, ports);
}
}
const amount = refundCents ?? payableCents;
if (amount <= 0 || amount > payableCents) {
problems.push("退款金额不在可退范围内");
return finish("paid", payableCents, orderId, payId, null, rows, problems, ports);
}
const refund = ports.refund(orderId, payId, amount, now());
note(rows, "refund", now(), `refund:${refund.status}`);
if (refund.status === "rejected") {
problems.push(`退款被拒绝:${refund.reason}`);
return finish("paid", payableCents, orderId, payId, null, rows, problems, ports);
}
return finish("refunded", payableCents, orderId, payId, refund.refundId, rows, problems, ports);
}
function finish(
outcome: ScenarioOutcome,
payableCents: number | null,
orderId: string | null,
payId: string | null,
refundId: string | null,
rows: TraceEntry[],
problems: string[],
ports: TradePorts,
): ScenarioResult {
const stats = ports.stats();
if (stats.orders > 1) problems.push(`产生了 ${stats.orders} 笔订单`);
const refunded = orderId === null ? 0 : ports.refundedCents(orderId);
if (refunded > 0 && (payId === null || outcome === "payment_failed")) {
problems.push("未确认支付却发生了退款");
}
return {
ok: problems.length === 0, outcome, payableCents, orderId, payId, refundId,
refundedCents: refunded, trace: rows, problems,
};
}
export function stageOrder(result: ScenarioResult): E2eStage[] {
return result.trace.map((entry) => entry.stage);
}
代码里有三个值得单独说明的决定。
金额从明细来,不从商品页来。 场景先调用 buildBreakdown,再把 payableCents 传给下单与支付。这样任何一个环节偷偷换了金额,后面的断言都会失败。
未知不是失败。 支付返回 unknown 时,场景最多查询三次;三次都 pending 才记为问题。它不会在未知时调用退款,也不会再调用一次 pay。
完成函数统一做收口断言。 finish 检查订单数是否大于一、是否在未确认支付时发生退款。所有提前返回都经过它,避免某个分支漏掉检查。
测试与真实输出
测试侧的 MemoryWorld 实现 TradePorts,用脚本控制支付结果是成功、失败还是「先未知、查询后成功」。真实运行结果如下:
$ node --test --test-concurrency=1 test/summary/e2e-scenario.test.ts
✔ 选品到退款的完整主线全部成功 (1.05ms)
✔ 场景按浏览、购物车、提交、支付、退款推进 (0.438125ms)
✔ 服务端无该商品时停在浏览阶段 (0.112792ms)
✔ 购买数量超过限购时不下单 (0.063042ms)
✔ 可售库存不足时停在浏览阶段 (0.070208ms)
✔ 同一个幂等键重复提交只产生一笔订单 (0.101375ms)
✔ 不同幂等键会各自产生订单,场景能识别 (0.103666ms)
✔ 提交结果未知时通过查询键找回同一张订单 (0.093417ms)
✔ 支付超时后查询成功再退款 (0.175083ms)
✔ 支付超时后查询失败则不退款 (0.136333ms)
✔ 渠道明确失败时不进入退款阶段 (0.088542ms)
✔ 退款金额超过实付时被拒绝 (0.072ms)
✔ 退款金额必须为正 (0.058375ms)
✔ 部分退款只记录实际退款金额 (0.051542ms)
ℹ tests 14
ℹ suites 0
ℹ pass 14
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 179.81375
十四条用例覆盖三组:正常主线与阶段顺序、浏览与下单的前置失败、支付与退款的异常分支。它们不验证真实渠道,只验证编排在给定端口行为下是否守住四个问题。
一次「每段都绿、整条链路金额对不上」的复盘
某交易客户端在改版时把结算页拆成了两个团队维护:购物车团队负责小计,下单团队负责实付。两边都有自己的单元测试,覆盖率都很高。
上线后发现一类投诉:用户用了「满 200 减 30」的券,购物车显示应付 170 元,订单详情却是 200 元。复盘发现,购物车团队的小计包含了优惠,下单团队拿到的却是优惠前的金额;两边各自测试都通过,因为没有人测「购物车输出直接喂给下单」这一跳。
修复不是改某一个公式,而是补了一条端到端用例:从加购开始,经过 buildBreakdown,把 payableCents 原样传给下单,再断言订单金额等于明细金额。用例第一次运行就失败,暴露出两个团队对「小计」的定义不同。
第二个问题更隐蔽:支付超时后,编排代码把 unknown 当成了 failed,直接进入退款分支。退款接口因为订单未支付而拒绝,用户看到的是「退款失败」,实际上根本没有扣款。端到端用例补了「未知 → 查询 → 失败」的分支,明确断言退款次数为零。
这两个问题的共同点是:单模块测试验证的是函数,端到端验证的是承诺。承诺在交接处,交接处没有测试,覆盖率再高也守不住。
小结
端到端场景用五个窄端口把选品、加购、下单、支付、退款串成一条主线。它依赖真实的购物车与价格明细,把假实现留在测试侧,用假时钟推进查询。
四条断言构成收口:金额全程一致、只产生一笔订单、未知支付先查询、未确认支付不退款。它们比「每个模块都有测试」更接近用户真正经历的那条路径。
下一节把这条主线拆开,主动注入三类故障:重复提交、支付超时、回调乱序。
阅读导航:上一节:12.3 漏斗与归因的端上边界 · 下一节:13.2 故障注入:重复提交、支付超时、回调乱序 。
常见错误与边界
- 端到端场景直接复用商品页对象,跳过结算时的价格与库存确认。
- 把「发起支付」和「查询结果」合成一个方法,无法表达超时后的正确动作。
- 支付返回未知时按失败处理,提前进入退款分支。
- 场景只断言最终状态,不记录阶段轨迹,失败后无法定位停在哪一步。
- 假实现放在源文件里,替换真实服务端时被迫修改被测代码。
- 端到端测试依赖真实时钟,跑一次要等完整的支付超时时间。
- 只测正常主线,不测浏览、下单、支付的前置失败,异常分支全部留给线上。
- 用端到端测试替代分模块测试,反馈周期变长,边界条件反而覆盖不足。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。