1. 先把场景摸清:购物车到底在测什么
购物车这个模块,我在几个电商项目里都从头到尾跟过测试方案,说实话它是最容易被低估的一块。新人一看,不就是个列表吗,加、删、改数量,能有多复杂。真上手才发现,一个购物车页面背后牵扯着商品中心、库存中心、营销中心、价格中心、订单中心五六个服务,任何一处状态不同步,用户在结算页看到的数字就会对不上。
这也是它在软件测试面试和实际项目里都高频出现的原因:它足够小,能在一两个小时内讲清楚;又足够深,能一路挖到分布式一致性和性能边界。下面我会把购物车的测试用例从加购入口一直拆到结算跳转,同时正面回答一个经常被问到、但很少有人系统讲清楚的问题——电商平台为什么要给购物车设置宝贝上限,这个上限值到底是怎么定出来的,验证的时候又该怎么下手。
全文以某头部电商平台的购物车作为参照样本,逻辑和参数放到同类产品上也一样通用。适合三类人看:刚转测试、需要一份能直接抄的用例清单的;带项目、需要给团队成员做用例评审的;以及想搞明白"业务规则背后的工程权衡"这件事的。
1.1 购物车的业务模型拆解
在写第一条用例之前,我一般会先把购物车的数据结构列出来,不然后面全是拍脑袋。一个典型的购物车条目,至少包含这些字段:用户 ID、SKU ID、规格属性(颜色/尺码/容量等)、购买数量、选中状态、加入时间、来源渠道、加购时命中的活动标记、以及一个"最近一次校验时间"。
这里要特别注意 SKU 和 SPU 的区别。SPU 是"商品",SKU 是"具体到规格的可售卖单元"。同一件衣服,黑色 M 码和白色 L 码是两个 SKU,在购物车里是两条独立记录。这个区分直接决定了后面用例怎么设计——比如"加购上限"到底是按条目数算还是按件数算,很多时候线上出问题就出在这个口径没对齐。
购物车本身还分两层。一层是"购物车头",记录用户维度的一些状态,比如全选状态、上一次进入购物车的时间、结算页暂存信息;另一层是"购物车明细",就是一条条的商品记录。很多项目做重构的时候会把这两层拆到不同的服务里,一旦拆开,就必须处理一致性问题:明细加成功了但购物车头的统计没更新,用户看到的就是"商品在,但角标数字不对"。
还有一类经常被忽略的数据:失效快照。商品下架、删除、规格变更之后,购物车里那条记录不能直接物理删除,否则用户会觉得"我明明加过的东西怎么没了"。常见的做法是保留记录、打上失效标记、把加购时的价格和标题存成快照,展示时用一个灰色的失效区隔开。这套快照机制本身就是一堆用例的来源。
1.2 测试用例设计的整体思路与分层
购物车的用例我习惯按四层来铺,从下往上分别是数据层、服务层、接口层、UI 层。比例大概是金字塔形:UI 层只保留最核心的主流程和跨端一致性,大量的边界、异常、组合逻辑全部下沉到接口层去覆盖。
为什么这么分?因为购物车的绝大多数 bug 不在"界面长什么样",而在"状态算得对不对"。UI 层跑一条用例要几十秒,接口层跑一条几百毫秒,同样一份边界值矩阵,放接口层能在一分钟内跑完,放 UI 层得跑一下午。而且 UI 层特别容易受加载动画、弹窗、网络抖动干扰,失败率一高,团队就没人看报告了。
服务层主要盯两件事:一是依赖服务的降级和超时,比如库存服务挂了,购物车列表应该还能展示,只是库存状态显示"未知"而不是报错;二是缓存和数据库的一致性,购物车这种高频读写的数据基本都会走缓存,缓存没失效就是经典的"删了还在"。
数据层则要覆盖脏数据的容错。真实环境里一定存在这种情况:某个 SKU 被物理删除了,购物车里还挂着引用。系统要么静默过滤,要么展示成失效,绝不能直接抛异常把整页打挂。
1.3 为什么动手之前要先画状态机
这是我个人的习惯,也是我觉得最省时间的一步。购物车条目其实是一个很典型的状态机,把状态画出来,用例覆盖度基本就清楚了。
一条购物车记录常见的状态有:正常可购买、库存不足、已售罄、已下架、已删除、价格已变动、活动已结束、预售未开始、限购超限、区域不可售。每个状态还要配上迁移条件,比如"正常 → 售罄"是因为库存被抢完,"售罄 → 正常"是因为补货,"正常 → 下架"是运营操作。
画完状态机你会立刻发现两个高频缺陷区:一是不可逆迁移,比如商品被删除后再恢复,购物车里的记录能不能自动活过来;二是多状态并存,比如一个商品同时满足"价格变动"和"活动结束",前端到底弹几条提示、算哪个价格。
我见过最典型的一个问题:某次大促后商品批量下架,购物车里的失效区一下子堆了几十条,用户想清理却只能一条条删,删除接口还有频率限制,结果就是用户体验崩了。这类问题在状态机上一眼就能看出来,但如果不画,很容易漏在用例之外。
2. 核心测试用例拆解:从加购到结算的全链路
这一部分我把用例按链路顺序摊开,每条都尽量写清楚"测什么"和"为什么这么测"。你可以直接拿去当评审底稿。
2.1 加购入口与数量边界
加购的入口远比想象中多:商品详情页、搜索结果列表、店铺首页、活动会场、扫码、分享链接、购物车内的"再买一次"、以及批量加购。每个入口传的参数不一样,比如列表页快速加购可能默认数量为 1 且不能选规格,详情页加购必须选完规格。入口不同,后端走的接口甚至可能不是同一个,所以入口矩阵必须单独列一遍。
数量边界是必测项,而且要用等价类和边界值一起做。有效等价类是 1 到上限值,无效等价类是 0、负数、超过上限、非数字、小数、科学计数法、全角数字、首尾空格、以及超长字符串。边界值取 1、上限-1、上限、上限+1。
这里有个很实际的坑:前端输入框通常会做一层拦截,但接口层未必做。我测过的项目里,起码有三分之一的情况是前端拦住了、直接调接口照样能加进去。所以数量边界的用例一定要在接口层再跑一遍,把前端校验当成"体验优化"而不是"安全边界"。
另一个必测点是重复加购的行为。同一个 SKU 再次加购,是数量累加还是新增一条记录?如果累加,累加后超过上限怎么处理——是拦截整次操作,还是加到上限为止?这两种策略对用户的心理感受差别很大,产品文档里经常写得模棱两可,必须逼着产品明确。
还要测并发加购。同一个账号在两台设备上同时点加购,或者弱网下用户连点三次,最终购物车里应该是几条?这就涉及接口的幂等设计。常见做法是带一个客户端生成的请求 ID 做去重,测试时可以用脚本并发打同一个请求 ID,看结果是不是只有一条。
2.2 商品状态变化与失效处理
这一块是我认为购物车用例里价值最高的部分,也是最容易出线上事故的地方。
要覆盖的状态变化包括:商品下架、商品删除、库存归零、规格变更(比如原本的"黑色 M"被合并成"黑色 L")、价格上调或下调、限购规则变化、活动开始或结束、店铺关闭、以及配送区域变更导致不可售。
每一条都要验证三件事:购物车列表怎么展示、选中状态怎么处理、点击结算时的拦截提示是什么。举个例子,商品下架后,那条记录应该自动取消勾选并且置灰,用户点全选时不能把它选上,点结算时如果强行带上应该被后端拦下来。这三步任何一步漏了,都会导致用户走到结算页才被拦,体验极差。
价格变动的处理尤其微妙。大部分平台的做法是购物车里展示最新价格,同时给一个"较加入时降价 X 元"的提示。但这里有个隐蔽的问题:如果用户加购时用的是活动价,活动结束后恢复原价,这个"降价提示"到底该跟哪个价格比?我见过有项目直接拿当前价和快照价比,结果活动结束后反而提示"涨价",用户投诉一片。用例里必须把这个场景单列出来。
失效商品还有一个必测点:它们占不占上限名额。这个我放到第 3 章详细说,因为它直接关系到上限规则的定义。
2.3 价格、优惠与结算金额校验
金额算错是购物车最严重的缺陷类型,没有之一。用户对这个极度敏感,一旦发现结算金额和预期不符,信任感直接崩塌。所以这一块的用例要写到"每一分钱都能对上"的程度。
我一般会先构造一个可控的小数据集,然后手算一遍期望值,再和系统结果比对。举个具体的例子:
| 商品 | 单价 | 数量 | 小计 |
|---|---|---|---|
| 商品 A | 99.00 | 2 | 198.00 |
| 商品 B | 59.00 | 1 | 59.00 |
| 商品 C | 39.00 | 1 | 39.00 |
| 合计 | 296.00 |
假设规则是:店铺满 200 减 30,平台券满 150 减 20,全场满 99 包邮(不满收 8 元运费)。
正确的计算顺序应该是:商品小计 296 → 店铺满减(296 ≥ 200,减 30)→ 266 → 平台券门槛判断(按 296 还是按 266?这就是关键的歧义点)→ 如果按 296 算,减 20 得 246;如果按 266 算,仍然 ≥ 150,也减 20,得 246。这个例子里两者结果一样,所以要故意构造一个能区分两者的数据:把商品 C 去掉,小计变成 257,店铺满减后 227,都 ≥ 150,还是分不出来。真正能区分的是把总额调到 210 左右:店铺满减后 180,平台券门槛 150 仍然满足;但如果平台券门槛是 200,那就只有按原价算才满足。
所以用例设计的关键不是"多凑几个商品",而是刻意构造能区分计算顺序的临界数据。这类数据我一般会在 Excel 里单独维护一张"歧义数据表",每次回归都跑一遍。
还要覆盖的金额场景包括:优惠券过期、优惠券被其他订单占用、优惠券不适用当前商品、多个店铺商品混合结算时的运费合并规则、积分抵扣的上限、以及金额的小数精度(尤其是 0.1 + 0.2 这类浮点问题,虽然是老生常谈,但每年都能测出来)。
2.4 多端同步与异常场景
现在的购物车基本都是多端共享的,App、小程序、网页、平板同时登录一个账号很常见。同步策略一般有两种:实时同步(一端改动立刻推给其他端)和拉取同步(其他端下次刷新时更新)。两种策略的用例完全不同。
实时同步要测:一端删除,另一端多久消失;一端改数量,另一端会不会覆盖用户正在编辑的输入;断网重连后状态怎么对齐。拉取同步相对简单,但要测"用户在两端的操作冲突"——比如手机上删了,网页上还在编辑数量并提交,这时候数量提交应该失败并提示"商品已移除",而不是默默加回去。
弱网和异常是另一大块。要测的场景包括:请求超时后重试导致重复加购、加购过程中切后台再回来、加购成功后立刻断网导致前端状态和后端不一致、以及支付前置校验失败后的回滚。
还有一个容易被漏掉的:账号切换。同一台设备上 A 账号退出、B 账号登录,购物车数据必须彻底隔离。这个如果做不好就是严重的隐私问题。测试时要注意本地缓存有没有清理干净,尤其是图片缓存和价格缓存。
3. 上限问题的正面回答:为什么购物车要设置宝贝上限
终于说到正题。这个问题我在面试里问过不少人,绝大多数回答都是"防止用户加太多"——这等于没说。真正的答案要从技术成本、业务风控、用户体验三个维度一起看,而且这三个维度的权重在不同阶段是不一样的。
3.1 从技术成本算一笔账
先算存储。假设一个中等规模的电商平台,日活 8000 万,其中有购物车行为的活跃用户按 3000 万算。一条购物车明细的原始字段加起来大约 100 字节,但加上主键索引、用户维度二级索引、以及各种冗余字段,实际落盘大约 300 到 500 字节,取中间值 400 字节。
如果平均每人 50 条,那就是 3000 万 × 50 × 400B ≈ 6TB。如果上限放开、平均条数涨到 80 条,就是 9.6TB,涨了 60%。这个数字看着还能接受,但购物车是高频读写的场景,不是冷数据。每次进购物车都要全量拉取,每次加购都要写一次,存储成本只是开始,真正的压力在 IO 上。
再算渲染。购物车列表接口每条商品要返回主图、标题、规格、价格、库存状态、活动标签、优惠预估,序列化后大约 800 字节到 1.5KB,取 1KB。100 条就是 100KB 的响应体,gzip 压缩后约 25KB。500 条就是 500KB,压缩后 120KB 以上。在弱网环境下,这 100KB 的差距就是首屏多等一秒和多等五秒的区别。
最要命的是结算校验。用户点结算,后端要对每一条商品做库存校验、价格重算、活动可用性判断、限购判断。这些校验通常要调下游服务,批量接口单次能处理的条数一般有上限,常见是 50 或 100 条。如果是 500 条,就得分 5 到 10 批去调,每批的响应时间叠加起来,结算页的 loading 可能要转好几秒。而结算页的转化率对加载时间极其敏感,这个损失是实打实的。
所以上限的第一个作用是给系统一个确定的性能边界。有了上限,容量规划就能算清楚:单机 QPS 多少、缓存多大、批量接口分几批最合适。没有上限,你就只能按最坏情况做容量,而这个最坏情况可能是被少数用户撑爆的。
3.2 从业务与风控角度
第二个维度是业务。购物车做长了对平台其实是负资产,因为它会显著拉低转化率。
道理不难理解:用户加了 200 件商品,结算是要一件件确认的,决策成本高到一定程度,他干脆就不买了。相反,购物车里只有 10 件,用户更容易一次性清空。所以很多平台会做"购物车瘦身"的运营动作,比如提示"您有 15 件商品已降价"、"3 件商品库存紧张",本质上都是在帮用户缩小决策范围。
还有一层是风控。购物车虽然不锁库存,但它是很多链路的前置。恶意用户或者爬虫可以批量加购来做几件事:探测商品是否存在、探测价格和库存变化、以及在被限购规则拦住之前先囤一批。设置上限能明显提高这类行为的成本——想扫 10 万个 SKU,就得反复加删,请求量翻好几倍,很容易被风控策略识别出来。
另外,上限还和数据统计口径有关。运营看的"人均加购件数"、"购物车转化率"这些指标,如果存在少数用户加了几千件的情况,均值会被严重拉偏。设个上限,统计数据的分布会健康很多,做分析的时候不用天天做异常值清洗。
3.3 从用户体验角度
第三个维度才是大家第一反应想到的,但它其实是"结果"而不是"原因"。
用户视角看,购物车无限长带来的问题不是"装不下",而是"找不到"。列表一长,靠滑动去找商品基本不可能,只能依赖搜索和筛选。而购物车的搜索功能通常做得很弱,这就变成了一个死循环。所以上限在体验上的作用是逼着用户做一次整理,把不买的删掉,把要买的留下,这其实是在帮他做决策。
还有一个心理层面的东西:购物车里的商品数会形成一个"心理账户"。10 件以内,用户觉得"我这次买得挺克制";50 件以上,用户会有明显的压力感,反而更容易放弃。这个不是我瞎猜,是电商领域反复验证过的现象,也是为什么很多平台会主动提示"购物车已满,请先清理"来制造一种稀缺感。
3.4 上限值的合理区间与验证方法
综合上面三点,业内常见的上限值落在 100 到 200 之间,按 SKU 条目数计算居多。也有按"件数"算的(比如总数不超过 999 件),但按条目算更主流,因为条目数才是真正影响渲染和校验成本的量。
验证上限,我一般按这个顺序做:
第一,确认计数口径。是条目数还是件数?不同规格算不算两条?失效商品占不占名额?这三个问题必须先问清楚,否则后面全是无效用例。我遇到过最离谱的一次,产品文档写"上限 120 件",开发按条目实现,测试按件数验证,三方理解完全不一致。
第二,验证边界行为。加到 119 条能成功,加到 120 条能成功,加到 121 条要给出明确提示且不能静默失败。提示文案也很重要,"操作失败"和"购物车已满,最多可放 120 件商品"给用户的感受完全不同。
第三,验证上限后的恢复。满了之后删掉一条,能不能立刻再加一条?这个是高频缺陷点,因为很多实现里上限判断走的是缓存计数,删除后缓存没及时更新,用户就会遇到"我明明删了还是加不进去"。
第四,验证失效商品的处理。失效商品如果占名额,用户就必须手动清理才能继续加购,体验很差;如果不占名额,那上限判断就要排除失效记录,实现会复杂一些。两种方案都能接受,但必须和产品确认清楚并写进用例。
第五,验证并发下的上限。两个端同时加购最后一条名额,会不会突破上限?这个要用脚本并发打,验证后端是不是用了原子操作或者分布式锁。
4. 实操:把用例真正跑起来
前面讲的都是设计思路,这一章讲怎么落地。用例写在文档里没人跑等于零,我的经验是一定要有一部分能自动执行,尤其是边界和金额这两类。
4.1 用例表的结构与优先级划分
我的用例表一般是这几列:编号、模块、用例标题、前置条件、操作步骤、预期结果、优先级、是否自动化。优先级只分三档,P0 是主流程和金额相关,必须每次回归都跑;P1 是常见异常,版本回归跑;P2 是极端场景,发版前抽跑。
| 编号 | 模块 | 用例标题 | 预期结果 | 优先级 |
|---|---|---|---|---|
| CART-001 | 加购 | 详情页选择完整规格后加购 1 件 | 加购成功,购物车角标 +1 | P0 |
| CART-002 | 加购 | 未选规格直接加购 | 提示"请选择规格",不产生记录 | P0 |
| CART-003 | 加购 | 同一 SKU 连续加购两次 | 数量累加为 2,不新增记录 | P0 |
| CART-004 | 边界 | 加购数量输入 0 | 前端拦截或后端返回参数错误 | P1 |
| CART-005 | 边界 | 加购至上限值 | 成功,无异常提示 | P0 |
| CART-006 | 边界 | 加购至上限值 +1 | 明确提示已达上限,数量不变 | P0 |
| CART-007 | 失效 | 商品下架后查看购物车 | 记录置灰、取消勾选、不可结算 | P0 |
| CART-008 | 金额 | 店铺满减 + 平台券叠加 | 按约定顺序计算,结果与手算一致 | P0 |
| CART-009 | 同步 | A 端删除,B 端刷新 | B 端记录消失,无残留 | P1 |
| CART-010 | 并发 | 两端同时加购最后一条名额 | 只有一条成功,总数不超上限 | P1 |
这张表只是个骨架,实际项目里我一般会铺到 150 到 250 条。数量不是目的,覆盖状态机的每一条边才是。
4.2 数据构造与接口层校验脚本
接口层的边界测试,我习惯用 pytest 加 requests 写,因为上手快、报告清晰。下面是我常用的一个骨架,用来验证上限相关的行为:
import pytest import requests BASE_URL = "https://cart.example.com" AUTH_TOKEN = "test-token-placeholder" @pytest.fixture(scope="module") def client(): session = requests.Session() session.headers.update({ "Authorization": f"Bearer {AUTH_TOKEN}", "Content-Type": "application/json", }) return session def add_item(client, sku_id, quantity=1): return client.post( f"{BASE_URL}/v1/cart/items", json={"skuId": sku_id, "quantity": quantity}, timeout=5, ) def clear_cart(client): client.delete(f"{BASE_URL}/v1/cart/items", timeout=5) def test_add_at_limit(client): clear_cart(client) for i in range(120): resp = add_item(client, f"SKU_{i:04d}") assert resp.status_code == 200, f"第 {i+1} 条加购失败" assert len(client.get(f"{BASE_URL}/v1/cart/items").json()["items"]) == 120 def test_add_over_limit(client): resp = add_item(client, "SKU_OVER") assert resp.status_code == 409 body = resp.json() assert body["code"] == "CART_FULL" assert "上限" in body["message"] def test_add_after_delete(client): items = client.get(f"{BASE_URL}/v1/cart/items").json()["items"] target = items[0]["skuId"] client.delete(f"{BASE_URL}/v1/cart/items/{target}", timeout=5) resp = add_item(client, "SKU_NEW") assert resp.status_code == 200, "删除后仍无法加购,疑似计数缓存未刷新"这段脚本里最关键的是最后一个用例。删除后能否立刻加购,是上限功能里 bug 密度最高的地方,十有八九第一次跑都会挂。
余额和金额的校验脚本我会单独拆一个文件,把期望值写死在数据里,避免"用系统算的结果去验证系统"这种假验证。
4.3 自动化怎么铺才不失控
自动化最容易犯的错是想一口气全铺。我的建议是先把 P0 里的加购主流程、失效处理、金额计算、上限边界这四类做掉,大概 30 到 40 条,跑通之后再加。
数据隔离一定要做好。购物车是用户维度的强状态数据,如果多个用例共用同一个测试账号,跑起来互相污染,失败原因根本查不清。我的做法是每个用例用独立的测试账号,或者每个用例开头强制清空。清空这一步别省,省下来的时间会在排查上十倍还回去。
断言点要选稳定的。别去断言完整的响应体,字段一变就全挂。只断言你真正关心的那几个:状态码、业务 code、条目数量、以及金额字段。UI 层的自动化只保留"从加购走到结算页"这一条主链路,其他全部下沉。
5. 常见问题与排查技巧实录
5.1 几个反复出现的典型缺陷
删除后加不进去。这是上限功能的老朋友了。根源通常是计数走了缓存,删除只删了数据库。排查方法是删完之后立刻调一次购物车列表接口,看返回的条数是不是已经减了,如果列表对了但加购还是失败,那基本可以定位到计数缓存。
失效商品占了名额。用户加满了 120 件,其中 30 件已经下架,想加新的加不进去,又不知道该删哪些。这个问题的本质是"上限判断的范围"和"列表展示的范围"没对齐。修法有两种:一是失效不占名额,二是满的时候优先清理失效。两种都能接受,但必须在产品层面拍板。
金额差一分钱。九成是浮点运算或者四舍五入时机不对。排查时不要看最终结果,要把每一步的中间值都打出来,尤其是优惠券分摊到每个商品上的金额。分摊之后每条商品的小计之和必须精确等于总优惠,这个校验一定要加。
多端状态覆盖。用户在手机上改数量,网页上还显示旧值,提交后互相覆盖。这类问题基本都要靠版本号或者时间戳做乐观锁,测试的时候重点验证"提交旧版本"会不会被拒绝。
上限判断非原子。并发下突破上限。用 JMeter 或者 locust 起 20 个并发打同一个账号的加购,看最终条数会不会超过限制。如果超了,说明后端是"先查后写"的非原子操作。
5.2 问题速查表
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 加购成功但角标不变 | 前端未刷新或统计接口失败 | 刷新页面看是否恢复,抓包看统计接口返回 |
| 删除后列表还有 | 缓存未失效 | 直接查库确认,再查缓存 TTL 配置 |
| 金额与手算不符 | 计算顺序或分摊规则错误 | 打印中间值,逐条核对优惠分摊 |
| 上限后无法恢复 | 计数未随删除更新 | 调列表接口比对条数与加购判断逻辑 |
| 失效商品无法批量清理 | 批量接口频率限制 | 查接口限流配置,确认前端是否串行调用 |
| 结算页提示商品已失效 | 列表与结算校验口径不一致 | 对比两处对"可售"的定义 |
| 并发下超出上限 | 非原子判断 | 查是否用了分布式锁或数据库行锁 |
5.3 我在实际项目里攒下的几条经验
第一,先跟产品把口径对齐,再动手写用例。购物车里"上限按什么算""失效占不占名额""优惠按什么顺序叠"这三个问题,如果开工前没问清楚,后面返工的量会非常大。我现在的习惯是把这三个问题写成一页纸,让产品签字确认再开始。
第二,把金额用例的期望值手工算一遍并写进文档。不要指望后来的人能看懂你的计算逻辑,把每一步的中间值都列出来,包括优惠分摊到每个 SKU 的金额。这样出了问题,谁都能在五分钟内定位到是哪一步偏了。
第三,上限相关的用例一定要在真实数据量下跑一次。测试环境里加 120 条数据可能秒开,线上有几十万用户同时在刷的时候完全是另一回事。哪怕只是简单压一下列表接口,也能提前发现慢查询这类问题。
第四,别忽略提示文案的测试。用户被上限拦住的时候,看到的是"操作失败"还是"购物车已满,最多可放 120 件,请先清理",这直接决定了他会不会继续用。文案的用例看起来琐碎,但它是用户能感知到的唯一部分。
第五,也是我个人体会最深的一点:购物车这个模块的价值不在用例数量,而在状态覆盖。我见过写了 300 条用例但状态机还有边没覆盖到的方案,也见过只有 80 条但每条都卡在关键路径上的方案。后者上线后的缺陷率明显更低。写用例的时候多问一句"这条记录如果失败了,用户会看到什么",比多写十条重复的 UI 检查有用得多。