前言
很多接口自动化项目写到后面,都会遇到一个问题:
接口都测了,但总觉得离真实业务还差一层。
这其实很正常,因为"接口成功"不等于"业务场景成立"。
例如,一个列表接口返回 200,只能说明请求成功,不代表:
-
查询条件生效了
-
返回数据真的符合预期
-
不同用户看到的数据一致性正确
-
前后接口联动没有问题
-
关键数据已经真实落到了系统里
所以,接口自动化做到一定阶段以后,重点就不该只是"单接口验证",而要开始向"真实业务场景"靠拢。
我结合项目里的 testcases/业务系统A/test_api_kc_goods.py、testcases/业务系统A/test_e2e_goods_flow.py、common/variable_store.py 和 conftest.py 来讲讲:怎么把接口自动化从"能跑"写到"像业务"。
一、单接口测试和业务场景测试的区别
单接口测试关注什么
-
请求参数是否正确
-
返回码是否正确
-
响应结构是否正确
-
关键字段是否存在
业务场景测试关注什么
-
多个条件组合后结果是否正确
-
数据是否真的和业务状态一致
-
不同角色看到的内容是否不同
-
先查再存、先存再查的链路是否闭合
这两者并不冲突,但优先级不同。
单接口测试更像"打底",业务场景测试才是真正验证系统可用性的地方。
二、为什么商品列表是很好的场景切入口
这个项目里,商品库存相关接口很适合做场景化展示,因为它天然有几个测试价值:
-
列表查询本身就有分页
-
查询条件很多,适合做组合验证
-
同一批数据可能有不同来源、类目、上下架状态
-
可以和数据库做一致性对比
-
可以做普通用户与操作员的差异化验证
所以在 testcases/业务系统A/test_api_kc_goods.py 里写的那些场景,其实很有代表性。
它不是为了"凑测试数量",而是很符合企业系统中库存列表类接口的典型验证方式。
三、为什么"默认查询"是场景入口
很多复杂场景的第一步,其实就是先确认默认数据是什么样。
比如:
response = 业务系统A_inventory_service.list_inventory()
这一步的意义不是"随便查一遍",而是先拿到一个稳定的基线:
-
当前环境中是否有数据
-
列表结构是否正常
-
响应外壳是否稳定
-
后续提取字段是否可用
一旦基线建立起来,后面的筛选、组合条件、角色对比才有意义。
四、模糊搜索和精确搜索为什么都要测
在真实业务里,列表查询通常至少有两种高频方式:
1)模糊搜索
按名称搜索,适合用户不记得完整编码时使用。
2)精确搜索
按商品编码、ID 等字段搜索,适合定位某条确定数据。
项目里的测试就体现了这一点:
python
resp_all = 业务系统A_inventory_service.list_inventory()
target_name = extract(resp_all, "data.list[0].goodsName")
target_code = extract(resp_all, "data.list[0].goodsCode")
先从默认列表里提取一个实际存在的数据,再用它回去做反向查询。
这个写法的好处是:
-
不依赖外部硬编码测试数据
-
用例更贴近真实业务
-
只要环境里有数据,就能跑
这类"先取数,再回查"的思路非常适合业务型接口测试。
五、为什么参数化特别适合筛选类接口
对于来源、上下架状态、分类等筛选条件,参数化是非常合适的。
因为同样是"列表筛选",测试逻辑是一样的,变化的只是输入数据和预期结果。
例如:
python
@pytest.mark.parametrize(
"case",
ConfigManager().get_test_data("kc_goods.page_query.source_cases"),
ids=lambda x: x["desc"]
)
def test_kc_goods_filter_by_source_ddt(self, 业务系统A_inventory_service, case):
...
这种写法有三个好处:
1)输入数据集中管理
测试数据放在 testdata/ 里,代码和数据分离。
2)新增场景更容易
要扩展一个来源值,只要往数据文件里加一条,不需要改测试逻辑。
3)报告更清晰
通过 ids=lambda x: x["desc"],测试报告里能直接看到场景描述。
这比把所有情况写成多个重复用例更适合长期维护。
六、动态数据为什么比写死数据更有价值
这个项目里一个很重要的特点,就是很多场景并不依赖写死的商品编码,而是先从系统返回值里提取,再用于后续查询。
例如:
python
tag_resp = 业务系统A_inventory_service.list_goods_tags()
tag_list = extract(tag_resp, "data")
first_group = tag_list[0]
children = first_group.get("children", [])
target_ids = [str(item["tagId"]) for item in children[:2]]
再用提取出来的动态类目 ID 去查询商品列表:
response = 业务系统A_inventory_service.list_inventory( overrides={"categoryIdList": target_ids} )
这类设计的核心价值在于:
-
不依赖手工维护的大量测试数据
-
更接近真实系统运行状态
-
能在不同环境中更稳定地复用
-
避免因为环境数据变化导致大量硬编码失效
对于企业内部项目来说,这种"动态取数 + 反向验证"的思路很重要。
七、多角色场景为什么很值得测
很多业务系统里,权限不是附属能力,而是核心业务逻辑的一部分。
所以在项目里设计了两套客户端:
-
业务系统A_client -
业务系统A_operator_client
然后在测试里对比两种角色的查询结果:
python
admin_resp = 业务系统A_inventory_service.list_inventory()
op_resp = 业务系统A_inventory_operator_service.list_inventory()
这类用例的意义不是单纯看两个响应码,而是验证:
-
不同角色是否能访问相同资源
-
数据可见性是否符合权限预期
-
当前登录身份是否真的生效
如果一个系统有角色权限模型,那这种测试是非常必要的。
八、并发查询类用例为什么值得保留
并发测试不一定非要追求性能指标,它也可以用来验证稳定性和一致性。
项目里写了一个轻量并发查询的用例:
-
使用
ThreadPoolExecutor -
同时发起多个查询
-
每个线程使用不同参数
-
最后汇总结果并做断言
python
@allure.story("轻量级并发-商品列表查询")
@pytest.mark.P2
def test_kc_goods_concurrent_query(self, 业务系统A_inventory_service):
"""
模拟 5 个用户同时查询商品列表。
注意:这里使用的是线程池,适合 I/O 密集型(接口请求)场景。
"""
num_threads = 3
results = []
# 定义一个内部函数,作为线程执行的任务
def task(thread_id):
try:
resp = 业务系统A_inventory_service.list_inventory(
page_num=1,
page_size=5,
overrides={"goodsName": f"测试商品_{thread_id}"} # 模拟不同参数
)
return {
"thread_id": thread_id,
"code": resp.get("code"),
"total": extract(resp, "data.total"),
"success": True
}
except Exception as e:
return {"thread_id": thread_id, "success": False, "error": str(e)}
# ✅ 启动线程池,最大并发数为 5
with ThreadPoolExecutor(max_workers=num_threads) as executor:
# 提交 5 个任务
futures = {executor.submit(task, i): i for i in range(num_threads)}
# 收集结果
for future in as_completed(futures):
result = future.result()
results.append(result)
# 可以在这里对每个并发的结果进行即时断言
assert result["success"], f"线程 {result['thread_id']} 执行失败: {result.get('error')}"
Assertions.assert_eq(result, "code", "200")
# ✅ 最终断言:确保 5 个请求都成功了
assert len(results) == num_threads, "并发任务数量不符"
# 可以在 Allure 报告里记录并发结果
allure.attach(
str(results),
name="Concurrent Results Summary",
attachment_type=allure.attachment_type.JSON
)
这个设计的价值有几个:
1)验证接口基本线程安全
至少能看出接口在并发访问时是否有明显异常。
2)验证测试框架本身是否能支持并发执行
有些问题不是业务接口的问题,而是测试代码共享变量或 session 使用不当。
3)帮助发现偶发性问题
某些接口在单线程下没事,但并发下会暴露边界问题。
对于业务自动化来说,少量轻量并发场景非常值得保留。
九、为什么要做查询结果为空的负向场景
好的接口自动化,不应该只测成功路径。
失败路径同样重要,比如:
-
查不到数据时怎么返回
-
参数不合法时怎么返回
-
空列表场景怎么处理
在 test_kc_goods_query_not_found 里就体现了这种思路:
python
response = 业务系统A_inventory_service.list_inventory(
overrides={"categoryIdList": ["随便乱写"]},
assert_response=False
)
Assertions.assert_eq(response, "code", "400")
Assertions.assert_eq(response, "successful", False)
Assertions.assert_str(response, "message", "数据参数不正确")
这个写法很实用,因为它把"异常响应应该长什么样"也纳入了测试范围。
对业务系统来说,错误信息是否稳定、是否可理解、是否能被前端正确处理,都是测试关注点。
十、为什么 E2E 场景一定要做上下文传递
端到端场景最关键的,不是接口多,而是数据怎么传。
比如在 test_e2e_goods_flow.py 里用了几种方式保存中间变量:
单个用例内:局部变量 > scene_context 多个用例/会话级:fixture > shared_context
1)scene_context/局部变量
适合同一个场景内临时传递。
python
scene_context 不是为了让单行代码更短,而是为了让"复杂业务流程"里每个步骤之间共享同一批中间数据。
即scene_context 只有在单个用例内部流程比较复杂时才考虑使用
你可以把它理解成:当前这个 E2E 用例专属的"临时数据袋"。
---------
假设我们要测一个完整流程:
1. 创建商品
2. 给商品创建库存
3. 基于这个库存商品创建营销活动
4. 校验营销活动和商品绑定正确
5. 清理测试数据
这个流程里,每一步会产生一些后面步骤要用的数据:
创建商品 -> 得到 goods_id、goods_code
创建库存 -> 需要 goods_id,同时产生 inventory_id
创建营销活动 -> 需要 goods_id、goods_code、inventory_id,同时产生 activity_id
校验营销活动 -> 需要 activity_id、goods_code
清理数据 -> 需要 activity_id、inventory_id、goods_id
如果不用 scene_context,你需要不断接收返回值、继续传参。
---------
不用 scene_context 的写法
大概会变成这样:
def test_goods_full_flow(self, 业务系统A_inventory_service):
goods_id, goods_code = self._create_goods(业务系统A_inventory_service)
inventory_id = self._create_inventory(
业务系统A_inventory_service,
goods_id,
goods_code,
)
activity_id = self._create_marketing(
业务系统A_inventory_service,
goods_id,
goods_code,
inventory_id,
)
self._assert_marketing(
业务系统A_inventory_service,
activity_id,
goods_code,
)
self._cleanup(
业务系统A_inventory_service,
activity_id,
inventory_id,
goods_id,
)
这个例子还不算复杂,但你已经能看到参数开始变多了。
每个步骤函数可能长这样:
def _create_goods(self, service):
resp = service.create_goods(...)
goods_id = extract(resp, "data.goodsId")
goods_code = extract(resp, "data.goodsCode")
assert goods_id, "goods_id 不应为空"
assert goods_code, "goods_code 不应为空"
return goods_id, goods_code
def _create_inventory(self, service, goods_id, goods_code):
resp = service.create_inventory(
goods_id=goods_id,
goods_code=goods_code,
)
inventory_id = extract(resp, "data.inventoryId")
assert inventory_id, "inventory_id 不应为空"
return inventory_id
def _create_marketing(self, service, goods_id, goods_code, inventory_id):
resp = service.create_marketing(
goods_id=goods_id,
goods_code=goods_code,
inventory_id=inventory_id,
)
activity_id = extract(resp, "data.activityId")
assert activity_id, "activity_id 不应为空"
return activity_id
def _assert_marketing(self, service, activity_id, goods_code):
resp = service.query_marketing(activity_id=activity_id)
Assertions.assert_eq(resp, "data.activityId", activity_id)
Assertions.assert_eq(resp, "data.goodsCode", goods_code)
def _cleanup(self, service, activity_id, inventory_id, goods_id):
service.delete_marketing(activity_id=activity_id)
service.delete_inventory(inventory_id=inventory_id)
service.delete_goods(goods_id=goods_id)
这种写法是可以的,而且在流程不复杂时非常推荐。
但问题是,如果流程继续变复杂:
goods_id
goods_code
sku_id
warehouse_id
inventory_id
activity_id
activity_code
tenant_id
operator_id
price_id
stock_batch_id
那么你的函数调用可能变成这样:
activity_id = self._create_marketing(
业务系统A_inventory_service,
goods_id,
goods_code,
sku_id,
warehouse_id,
inventory_id,
tenant_id,
operator_id,
price_id,
)
清理函数也可能变成:
self._cleanup(
业务系统A_inventory_service,
activity_id,
activity_code,
inventory_id,
stock_batch_id,
price_id,
goods_id,
sku_id,
warehouse_id,
)
这时候维护成本就会变高。
---------
使用 scene_context 的写法
有了 scene_context 后,主流程可以变成:
def test_goods_full_flow(self, 业务系统A_inventory_service, scene_context):
self._create_goods(业务系统A_inventory_service, scene_context)
self._create_inventory(业务系统A_inventory_service, scene_context)
self._create_marketing(业务系统A_inventory_service, scene_context)
self._assert_marketing(业务系统A_inventory_service, scene_context)
self._cleanup(业务系统A_inventory_service, scene_context)
这段代码像一个业务流程说明:
创建商品 -> 创建库存 -> 创建营销活动 -> 校验营销活动 -> 清理
它不关心每一步具体产生了哪些变量,只负责安排步骤顺序。
---------
第一步:创建商品
def _create_goods(self, service, ctx):
resp = service.create_goods(...)
goods_id = extract(resp, "data.goodsId")
goods_code = extract(resp, "data.goodsCode")
assert goods_id, "goods_id 不应为空"
assert goods_code, "goods_code 不应为空"
ctx["goods_id"] = goods_id
ctx["goods_code"] = goods_code
这一步做了三件事:
1. 调接口创建商品
2. 从响应里提取 goods_id、goods_code
3. 放进 scene_context
执行完后,scene_context 里面大概是:
{
"goods_id": "10001",
"goods_code": "G202609010001"
}
注意:这里确实还是定义了局部变量 goods_id、goods_code。
所以你前面说得对,scene_context 没有减少这一步的变量定义。
它真正的作用是:让后面的步骤不用通过返回值拿这些数据,也不用在主函数里继续传下去。
---------
第二步:创建库存
def _create_inventory(self, service, ctx):
resp = service.create_inventory(
goods_id=ctx["goods_id"],
goods_code=ctx["goods_code"],
)
inventory_id = extract(resp, "data.inventoryId")
assert inventory_id, "inventory_id 不应为空"
ctx["inventory_id"] = inventory_id
这里用到了第一步放进去的数据:
ctx["goods_id"]
ctx["goods_code"]
然后它又产生了新的数据:
ctx["inventory_id"] = inventory_id
执行完后,scene_context 变成:
{
"goods_id": "10001",
"goods_code": "G202609010001",
"inventory_id": "INV90001"
}
---------
第三步:创建营销活动
def _create_marketing(self, service, ctx):
resp = service.create_marketing(
goods_id=ctx["goods_id"],
goods_code=ctx["goods_code"],
inventory_id=ctx["inventory_id"],
)
activity_id = extract(resp, "data.activityId")
assert activity_id, "activity_id 不应为空"
ctx["activity_id"] = activity_id
这一步用到了前两步的数据:
ctx["goods_id"]
ctx["goods_code"]
ctx["inventory_id"]
然后产生了:
ctx["activity_id"]
执行完后,scene_context 变成:
{
"goods_id": "10001",
"goods_code": "G202609010001",
"inventory_id": "INV90001",
"activity_id": "ACT80001"
}
---------
第四步:校验营销活动
def _assert_marketing(self, service, ctx):
resp = service.query_marketing(
activity_id=ctx["activity_id"],
)
Assertions.assert_eq(resp, "data.activityId", ctx["activity_id"])
Assertions.assert_eq(resp, "data.goodsCode", ctx["goods_code"])
Assertions.assert_eq(resp, "data.inventoryId", ctx["inventory_id"])
这一步不产生新数据,主要是消费前面产生的数据:
ctx["activity_id"]
ctx["goods_code"]
ctx["inventory_id"]
它验证:
查询到的营销活动 ID 是否正确
营销活动绑定的商品编码是否正确
营销活动关联的库存 ID 是否正确
---------
第五步:清理数据
def _cleanup(self, service, ctx):
if ctx.get("activity_id"):
service.delete_marketing(activity_id=ctx["activity_id"])
if ctx.get("inventory_id"):
service.delete_inventory(inventory_id=ctx["inventory_id"])
if ctx.get("goods_id"):
service.delete_goods(goods_id=ctx["goods_id"])
清理函数的特点是:它可能需要前面很多步骤产生的数据。
如果不用 scene_context,你可能要传很多参数:
self._cleanup(
service,
activity_id,
inventory_id,
goods_id,
sku_id,
price_id,
stock_batch_id,
)
如果用 scene_context,清理函数只拿 ctx,自己判断里面有什么就清什么。
这就是 scene_context 比较适合的地方。
---------
完整版本
把上面的合起来就是:
def test_goods_full_flow(self, 业务系统A_inventory_service, scene_context):
self._create_goods(业务系统A_inventory_service, scene_context)
self._create_inventory(业务系统A_inventory_service, scene_context)
self._create_marketing(业务系统A_inventory_service, scene_context)
self._assert_marketing(业务系统A_inventory_service, scene_context)
self._cleanup(业务系统A_inventory_service, scene_context)
def _create_goods(self, service, ctx):
resp = service.create_goods(...)
goods_id = extract(resp, "data.goodsId")
goods_code = extract(resp, "data.goodsCode")
assert goods_id, "goods_id 不应为空"
assert goods_code, "goods_code 不应为空"
ctx["goods_id"] = goods_id
ctx["goods_code"] = goods_code
def _create_inventory(self, service, ctx):
resp = service.create_inventory(
goods_id=ctx["goods_id"],
goods_code=ctx["goods_code"],
)
inventory_id = extract(resp, "data.inventoryId")
assert inventory_id, "inventory_id 不应为空"
ctx["inventory_id"] = inventory_id
def _create_marketing(self, service, ctx):
resp = service.create_marketing(
goods_id=ctx["goods_id"],
goods_code=ctx["goods_code"],
inventory_id=ctx["inventory_id"],
)
activity_id = extract(resp, "data.activityId")
assert activity_id, "activity_id 不应为空"
ctx["activity_id"] = activity_id
def _assert_marketing(self, service, ctx):
resp = service.query_marketing(
activity_id=ctx["activity_id"],
)
Assertions.assert_eq(resp, "data.activityId", ctx["activity_id"])
Assertions.assert_eq(resp, "data.goodsCode", ctx["goods_code"])
Assertions.assert_eq(resp, "data.inventoryId", ctx["inventory_id"])
def _cleanup(self, service, ctx):
if ctx.get("activity_id"):
service.delete_marketing(activity_id=ctx["activity_id"])
if ctx.get("inventory_id"):
service.delete_inventory(inventory_id=ctx["inventory_id"])
if ctx.get("goods_id"):
service.delete_goods(goods_id=ctx["goods_id"])
---------
关键区别
不用 scene_context 时,数据流是显式返回和传参:
goods_id, goods_code = self._create_goods(service)
inventory_id = self._create_inventory(
service,
goods_id,
goods_code,
)
activity_id = self._create_marketing(
service,
goods_id,
goods_code,
inventory_id,
)
用 scene_context 时,数据流是统一放进上下文:
self._create_goods(service, ctx)
self._create_inventory(service, ctx)
self._create_marketing(service, ctx)
每个步骤自己从 ctx 里拿需要的数据,也把自己产生的数据放回 ctx。
---------
可以类比成一个表
执行到不同阶段时,scene_context 的内容不断增加:
刚开始:
{}
_create_goods 后:
{
"goods_id": "10001",
"goods_code": "G202609010001"
}
_create_inventory 后:
{
"goods_id": "10001",
"goods_code": "G202609010001",
"inventory_id": "INV90001"
}
_create_marketing 后:
{
"goods_id": "10001",
"goods_code": "G202609010001",
"inventory_id": "INV90001",
"activity_id": "ACT80001"
}
所以它是一个"场景状态容器"。
---------
什么时候它比局部变量更有价值
我觉得至少满足下面两个条件,才值得用:
1. 用例被拆成多个 helper 步骤函数
2. 多个步骤之间共享的数据比较多
例如:
def test_goods_full_flow(self, 业务系统A_inventory_service, scene_context):
self._create_goods(业务系统A_inventory_service, scene_context)
self._create_sku(业务系统A_inventory_service, scene_context)
self._create_inventory(业务系统A_inventory_service, scene_context)
self._create_price(业务系统A_inventory_service, scene_context)
self._create_marketing(业务系统A_inventory_service, scene_context)
self._approve_marketing(业务系统A_inventory_service, scene_context)
self._assert_marketing(业务系统A_inventory_service, scene_context)
self._cleanup(业务系统A_inventory_service, scene_context)
这时候如果不用 scene_context,参数传递会越来越长。
---------
什么时候它没有价值
像你现在看到的这条:
query_resp = 业务系统A_inventory_service.list_inventory()
new_goods_code = extract(query_resp, "data.list[0].goodsCode")
scene_context["temp_goods_code"] = new_goods_code
marketing_resp = 业务系统A_inventory_service.list_inventory(
overrides={
"goodsCode": scene_context.get("temp_goods_code"),
}
)
它没有拆 helper,也没有大量中间状态,只是"存一下再取一下"。
这种场景用局部变量就够了:
query_resp = 业务系统A_inventory_service.list_inventory()
goods_code = extract(query_resp, "data.list[0].goodsCode")
marketing_resp = 业务系统A_inventory_service.list_inventory(
overrides={"goodsCode": goods_code}
)
2)shared_context/ fixture
适合跨用例共享。
python
fixture 就是这个意思:只要你把它写进测试函数参数里,pytest 会自动先执行这个 fixture 函数,再把返回值注入给测试用例。
1. fixture 会不会自动调用?
会。
比如:
@pytest.fixture
def inventory_goods(业务系统A_inventory_service):
resp = 业务系统A_inventory_service.list_inventory()
goods_code = extract(resp, "data.list[0].goodsCode")
goods_id = extract(resp, "data.list[0].goodsId")
assert goods_code, "goods_code 不应为空"
assert goods_id, "goods_id 不应为空"
return {
"goods_code": goods_code,
"goods_id": goods_id,
}
测试里这样写:
def test_query_with_inventory_goods(self, 业务系统A_inventory_service, inventory_goods):
...
pytest 会自动做这几步:
1. 发现测试函数需要 inventory_goods
2. 先执行 inventory_goods() 这个 fixture
3. 把返回的字典传给测试函数
4. 测试函数里直接用这个数据
所以 fixture 的作用确实就是:
把"准备数据"从测试用例本身剥离出去,让每个用例自己拿自己的数据。
---------
2. 这样为什么能解除用例依赖?
因为数据不是"上一个 test 存给下一个 test",而是"每个 test 自己通过 fixture 拿到"。
而项目里的的 shared_context 是这种模式:
test_store_goods_code 负责写入
test_query_with_stored_goods 负责读取
这就有顺序依赖,会导致用例无法独立运行。
而 fixture 是这种模式:
test_a -> 自己调用 fixture 拿数据
test_b -> 自己调用 fixture 拿数据
两个用例互不依赖。
---------
3. fixture 和 shared_context 的区别
可以这样理解:
- fixture:每个用例需要什么,就自己声明什么
- shared_context:先往一个公共容器里塞数据,后面的用例再来拿
所以 fixture 更像"显式依赖",shared_context 更像"隐式共享"。
对于测试来说,显式通常更好。
---------
4. shared_context 什么时候比较适用?
先说结论:
适用场景比较少,且一般不如 fixture 自然。
它更适合这种情况:
- 你在同一个测试会话里,确实想让多个地方读写同一份可变数据
- 而且这份数据不是某一个 test 的专属输入
- 它更像"运行时共享状态"或"流程编排状态"
---------
5. 一个更实际的 shared_context 场景
假设你有一组"强流程依赖"的测试,不是独立用例,而是人为拆成多个阶段跑:
1. 创建临时租户
2. 创建临时商品
3. 给商品上架
4. 做营销配置
5. 最后统一清理
这组流程里,你不想每一步都把参数层层传递,也不想每个步骤都重复查询一次前面的结果。
这时可以用 shared_context 保存中间状态:
def test_bootstrap_tenant(shared_context, tenant_service):
tenant_id = tenant_service.create_tenant(...)
shared_context["tenant_id"] = tenant_id
def test_create_goods(shared_context, goods_service):
tenant_id = shared_context.get("tenant_id")
assert tenant_id, "tenant_id 不存在,请先执行 test_bootstrap_tenant"
goods_id = goods_service.create_goods(tenant_id=tenant_id, ...)
shared_context["goods_id"] = goods_id
def test_publish_goods(shared_context, goods_service):
tenant_id = shared_context.get("tenant_id")
goods_id = shared_context.get("goods_id")
assert tenant_id, "tenant_id 不存在"
assert goods_id, "goods_id 不存在"
goods_service.publish_goods(tenant_id=tenant_id, goods_id=goods_id)
这种写法下,shared_context 的作用是:
- 保存同一会话中的中间状态
- 让后续阶段能继续用前面生成的数据
- 不用把参数一直往下传
但这类场景有个前提:
你接受测试之间的顺序依赖。
这也是为什么我说它不太适合常规单元/接口测试。
---------
6. 但实际上,这种场景很多时候也更适合 fixture
你会发现上面那个"创建租户 -> 创建商品 -> 上架 -> 营销 -> 清理"的流程,
很多时候更适合写成:
- 一个 session 级 fixture 负责创建基础数据
- 各测试只消费 fixture 返回的数据
- 清理也放在 fixture teardown 里
也就是说,很多 shared_context 场景,最终都能被更干净地替换成 fixture。
所以可以这样理解:
- fixture:推荐的标准方式
- shared_context:少数情况下,为了跨步骤、跨函数、跨用例共享运行时状态,临时用一下
---------
7. 一句话总结
- fixture:pytest 会自动调用,用来给每个用例独立准备数据,适合解除用例依赖
- shared_context:适合少数"必须共享同一份可变状态"的编排型场景,但不适合作为常规测试用例之间的传值方式
fixture的4种scope:
python
function (默认作用域):每个测试函数/方法执行前都会调用一次 fixture,执行完后立即销毁。隔离性最好。
import pytest
@pytest.fixture # 默认就是 scope="function"
def cart():
print("\n[Setup] 创建新购物车")
return {"items": []}
def test_add_apple(cart):
cart["items"].append("apple")
assert "apple" in cart["items"]
def test_add_banana(cart):
cart["items"].append("banana")
assert "banana" in cart["items"]
class(类作用域):在同一个测试类中,fixture 只初始化一次,类里的所有测试方法共享同一个实例;换一个测试类则会重新初始化。
@pytest.fixture(scope="class")
def browser():
print("\n[Setup] 启动浏览器")
driver = {"status": "opened"}
yield driver
print("\n[Teardown] 关闭浏览器")
class TestLogin:
def test_open_login_page(self, browser):
print("测试打开登录页")
assert browser["status"] == "opened"
def test_input_credentials(self, browser):
print("测试输入账号密码")
assert browser["status"] == "opened"
class TestDashboard:
def test_view_profile(self, browser):
# 这个测试属于另一个类,会重新触发 browser fixture 的 setup
print("测试查看个人资料")
module(模块作用域):在同一个测试文件(.py 模块)中,无论有多少测试函数或测试类,fixture 只初始化一次。
# test_order.py
@pytest.fixture(scope="module")
def db_connection():
print("\n[Setup] 建立模块级数据库连接")
conn = {"connected": True}
yield conn
print("\n[Teardown] 断开模块级数据库连接")
def test_insert_order(db_connection):
assert db_connection["connected"]
class TestOrderQuery:
def test_query_by_id(self, db_connection):
assert db_connection["connected"]
def test_query_by_date(self, db_connection):
assert db_connection["connected"]
session(会话作用域):整个 pytest 运行期间(所有测试文件和所有用例)只初始化一次,所有测试共享。
# conftest.py 或任意被识别的测试文件
@pytest.fixture(scope="session")
def auth_token():
print("\n[Setup] 登录获取全局 token")
token = "fake_jwt_token_12345"
return token
def test_api_1(auth_token):
print(f"用例1 使用 token: {auth_token}")
def test_api_2(auth_token):
print(f"用例2 使用 token: {auth_token}")
3)VariableStore
适合跨会话、跨运行持久化。
python
- 写入、读取、清理分开,符合真实项目习惯
# testcases/业务系统A/test_e2e_goods_flow.py
import allure
import pytest
from common.assertion import Assertions, extract
from common.variable_store import VariableStore
@allure.epic("Inventory")
@allure.feature("Goods E2E Flow")
@pytest.mark.e2e
class Test业务系统AGoodsE2EFlow:
"""类变量定义 key = 给字符串起个名字,然后用名字引用它。存入json的格式是 {
"业务系统A_e2e_goods_code": "ABC001"} ,用例使用时是用self.STORE_KEY_CODE,本质上就是 Python 里"不要到处复制粘贴同一个字符串"这个基本原则的体现 """
STORE_KEY_CODE = "业务系统A_e2e_goods_code"
STORE_KEY_ID = "业务系统A_e2e_goods_id"
@allure.story("VariableStore-步骤1:保存接口返回的商品标识")
@pytest.mark.P2
@pytest.mark.goods_flow
def test_save_goods_to_variable_store(self, 业务系统A_inventory_service):
"""
场景说明:
- 首次执行时,从接口中取出 goodsCode / goodsId
- 写入 VariableStore,供后续独立 pytest 运行复用
- 适合保存创建结果、主键、token、临时环境标识等跨会话数据
"""
response = 业务系统A_inventory_service.list_inventory()
goods_code = extract(response, "data.list[0].goodsCode")
goods_id = extract(response, "data.list[0].goodsId")
assert goods_code, "goodsCode 不应为空"
assert goods_id, "goodsId 不应为空"
store = VariableStore()
store.update({
self.STORE_KEY_CODE: goods_code,
self.STORE_KEY_ID: goods_id,
})
#假设这句这么写的话,后面引用字段"业务系统A_e2e_goods_code"就很容易拼错,且拼错了也没有提示,而且如果哪天想要
#把这字段改成"bizA_e2e_goods_code",那就是改很多处地方
#store.update({
#"业务系统A_e2e_goods_code": "G001",
#"业务系统A_e2e_goods_id": "1001",
#})
assert store.get(self.STORE_KEY_CODE) == goods_code
assert store.get(self.STORE_KEY_ID) == goods_id
@allure.story("VariableStore-步骤2:读取上次保存的数据继续查询")
@pytest.mark.P2
@pytest.mark.goods_flow
def test_query_with_persisted_goods(self, 业务系统A_inventory_service):
"""
场景说明:
- 该用例可以单独执行
- 只要 runtime/variables.json 中存在上次保存的数据,就能继续使用
- 演示跨会话持久化,而不是同一次执行内的步骤串联
"""
store = VariableStore()
goods_code = store.get(self.STORE_KEY_CODE)
goods_id = store.get(self.STORE_KEY_ID)
assert goods_code, (
f"未找到 {self.STORE_KEY_CODE},请先执行 test_save_goods_to_variable_store"
)
assert goods_id, (
f"未找到 {self.STORE_KEY_ID},请先执行 test_save_goods_to_variable_store"
)
response = 业务系统A_inventory_service.list_inventory(
page_num=1,
overrides={
"goodsCode": goods_code,
"goodsId": goods_id,
},
)
Assertions.assert_eq(response, "data.list[0].goodsCode", goods_code)
Assertions.assert_eq(response, "data.list[0].goodsId", goods_id)
@allure.story("VariableStore-清理持久化数据")
@pytest.mark.P3
@pytest.mark.goods_flow
def test_clear_persisted_goods(self):
"""
场景说明:
- 可选清理步骤
- 在演示或联调结束后删除持久化变量,避免旧数据影响下一次执行
"""
store = VariableStore()
store.delete(self.STORE_KEY_CODE)
store.delete(self.STORE_KEY_ID)
适合 VariableStore 的真实场景一般是:
1. 创建资源后保留 ID,后续单独回归时继续用
2. 保存登录 token / session 标识,给后续独立用例复用
3. 保存临时环境里生成的业务主键,供下一轮测试读取
4. 演示"上次跑完,这次还能拿到数据"的跨会话能力
不太适合的场景:
- 同一个测试类里前后步骤串联
- 强依赖执行顺序的流程链路
- 并行跑且多个用例共用同一 key 的场景
这三种方式其实是按粒度分层的:
-
临时场景级:
scene_context/局部变量 -
会话级:
shared_context/fixture -
运行持久化:
VariableStore
这样设计的好处是层次非常清楚,不会把所有数据都塞进同一种容器里。
十一、为什么"查列表 -> 存变量 -> 再查询"是很好的 E2E 模式
例如:
python
response = 业务系统A_inventory_service.list_inventory()
new_goods_code = extract(response, "data.list[0].goodsCode")
scene_context["temp_goods_code"] = new_goods_code
再在后续步骤中使用这个值:
python
marketing_resp = 业务系统A_inventory_service.list_inventory(
overrides={"goodsCode": scene_context.get("temp_goods_code")}
)
这种方式的好处是:
-
数据来自系统真实返回,不是人工猜的
-
用例之间有自然的业务联系
-
更适合验证完整链路
这就是典型的 E2E 思路。
十二、为什么数据库断言要和场景测试结合
你在某些用例里已经预留了数据库校验思路。
这一步非常重要,因为很多业务系统的真实问题,不是在接口返回,而是在最终数据落库和状态流转上。
例如:
-
接口返回成功了,但数据库没更新
-
接口返回了列表,但字段值与数据库不一致
-
接口查到了结果,但业务状态其实不正确
所以如果接口自动化只停留在响应层,覆盖面还是不够。
数据库验证能把"看起来成功"变成"真正成功"。
十三、小结
这一篇讲的是"测试怎么更像业务"。
真正高质量的接口自动化,不应该只是一些静态断言,而应该具备这些能力:
-
基于真实数据反向验证
-
支持参数化和数据驱动
-
支持多角色查询对比
-
支持轻量并发稳定性验证
-
支持跨步骤、跨用例、跨会话的数据传递
-
必要时结合数据库做最终确认