用例不是孤立执行的:依赖、变量池与 storage_state 设计
摘要
前一篇讲了关键字驱动:CSV 里的一行步骤,最后会被 StepRunner 翻译成浏览器操作、网络断言或变量处理。
这一篇继续讲一个更贴近业务的问题:游客登录怎么验证。
游客登录看起来只是点击一个按钮,但真正要验证的是设备状态和游客身份之间的关系:
text
新设备第一次登录,应该创建新游客。
同一个设备再次登录,应该还是同一个游客。
不同设备登录,应该创建不同游客。
为了验证这个规则,框架需要三个能力:用例依赖、变量池和 storage_state。
为什么游客登录不是一条孤立用例
如果只测"能不能游客登录成功",一条用例就够了。
但现在要测的是"同一个设备还是不是同一个游客"。这就意味着第二条用例必须依赖第一条用例。
当前 CSV 里有三条游客登录相关用例:
csv
case_id,module,case_name,enabled,depends_on,device_id,state_mode,state_file,case_vars
GUEST_001,guest_login,new device creates guest,false,,device_a,new,device_a_state.json,channel_id=...
GUEST_002,guest_login,same device keeps guest,false,GUEST_001,device_a,load,device_a_state.json,channel_id=...
GUEST_003,guest_login,different device creates different guest,false,GUEST_001,device_b,new,device_b_state.json,channel_id=...
这三条用例的关系是:
text
GUEST_001:device_a 新设备登录,拿到 guest_a,并保存状态。
GUEST_002:加载 device_a 的状态,再次登录,断言还是 guest_a。
GUEST_003:换成 device_b 新设备登录,断言不是 guest_a。
也就是说,这不是三条完全独立的用例,而是一组状态型业务链路。
depends_on 决定用例顺序
cases.csv 里的 depends_on 表示用例依赖。
例如:
csv
GUEST_002,...,depends_on=GUEST_001,...
表示运行 GUEST_002 之前,必须先运行 GUEST_001。
框架里负责处理这个关系的是 DependencyResolver:
python
class DependencyResolver:
def resolve(self, requested_case_ids: list[str]) -> list[str]:
ordered: list[str] = []
visiting: set[str] = set()
visited: set[str] = set()
def visit(case_id: str) -> None:
if case_id in visited:
return
if case_id in visiting:
raise ValueError(f"Circular case dependency detected at {case_id}")
visiting.add(case_id)
for dependency in self.cases[case_id].depends_on:
visit(dependency)
visiting.remove(case_id)
visited.add(case_id)
ordered.append(case_id)
for case_id in requested_case_ids:
visit(case_id)
return ordered
这段代码做的是依赖排序。
如果你只指定运行:
bash
python -m pytest tests/test_csv_runner.py --case-id GUEST_002
框架不会直接跑 GUEST_002,而是先算出:
text
GUEST_001 -> GUEST_002
这样 GUEST_002 需要的前置状态和变量才会存在。
依赖失败时,后续用例会跳过
排序只是第一步。
真正执行时,test_csv_runner.py 还会检查依赖用例是否通过:
python
failed_dependencies = [
dependency
for dependency in case.depends_on
if completed_statuses.get(dependency) != "passed"
]
if failed_dependencies:
result = CaseResult(
case_id=case.case_id,
case_name=case.case_name,
status=SKIPPED,
steps=[StepResult(...)]
)
else:
result = runner.run_case(case)
这意味着:
text
如果 GUEST_001 失败,GUEST_002 不会继续跑。
这个设计很重要。
因为 GUEST_002 的目标是验证"同一个设备再次登录还是同一个游客"。如果第一次登录都失败了,后面继续跑只会制造更多误导性的失败。
变量池保存跨步骤数据
用例依赖解决的是"先跑谁、后跑谁"。
但光有顺序还不够。
GUEST_002 要比较第二次登录的游客名和第一次登录的游客名,就必须能拿到 GUEST_001 里提取出来的值。
这个能力由 VariableStore 提供:
python
class VariableStore:
def __init__(self):
self._values: dict[str, Any] = {}
def set(self, name: str, value: Any) -> None:
self._values[name] = value
def get(self, name: str) -> Any:
if name not in self._values:
raise KeyError(f"Variable not found: {name}")
return self._values[name]
def resolve_text(self, value: str) -> str:
def replace(match):
return str(self.get(match.group(1)))
return re.sub(r"\$\{([^}]+)}", replace, value or "")
可以把它理解成一次测试运行中的公共变量池。
比如 GUEST_001 里有两步:
csv
GUEST_001,7,wait login api,wait_request,,/test-api/oauth2/login,status=200;response.code=200;response.data.access_token=not_empty,login_response,6,true
GUEST_001,8,extract guest name,extract,login_response,response.data.userName,,guest_a,7,true
第一步把登录接口响应保存为 login_response。
第二步从 login_response.response.data.userName 提取游客名,保存为 guest_a。
后面的用例就可以引用:
csv
GUEST_002,9,assert same guest,assert_var,guest_a_again,,eq:${guest_a},,8,true
${guest_a} 会在运行时被替换成变量池里的真实值。
extract 和 assert_var 如何配合
提取字段靠 extract:
python
value = self.variables.extract(step.target, step.value)
self.variables.set(step.save_as, value)
其中:
text
target = login_response
value = response.data.userName
save_as = guest_a
意思是:
text
从 login_response 这个变量里,按 JSON 路径 response.data.userName 取值,保存成 guest_a。
断言变量靠 assert_var:
python
actual = self.variables.get(step.target)
expression = self.variables.resolve_text(step.expect)
if expression.startswith("eq:"):
expected = expression.removeprefix("eq:")
assert str(actual) == expected
elif expression.startswith("ne:"):
expected = expression.removeprefix("ne:")
assert str(actual) != expected
所以:
csv
assert_var,guest_a_again,,eq:${guest_a}
表示:
text
guest_a_again 必须等于 guest_a。
而:
csv
assert_var,guest_b,,ne:${guest_a}
表示:
text
guest_b 必须不等于 guest_a。
这样就能用 CSV 表达跨用例的数据比较。
storage_state 保存浏览器状态
变量池保存的是接口返回里的业务数据,比如 userName。
但"老设备"还需要浏览器状态,比如 cookie、localStorage 等。
Playwright 提供了 storage_state,框架把它包装成 save_state 和 state_mode=load。
GUEST_001 结束前保存状态:
csv
GUEST_001,11,save device state,save_state,,device_a_state.json,,,10,true
对应代码:
python
def save_state(self, state_file: str) -> Path:
path = STATES_DIR / Path(state_file).name
self.session.context.storage_state(path=str(path))
return path
GUEST_002 再加载同一个状态文件:
csv
GUEST_002,...,device_a,load,device_a_state.json,...
BrowserManager.create_session() 里会处理:
python
if case.state_mode == "load":
if state_path is None or not state_path.exists():
raise FileNotFoundError(...)
options["storage_state"] = str(state_path)
context = browser.new_context(**options)
这样 GUEST_002 创建浏览器上下文时,就带上了 GUEST_001 保存的状态。
测试意义上,它就不再是一个全新设备,而是同一个设备的再次访问。
case_vars 让用例带入业务参数
cases.csv 里还有一个字段:
csv
case_vars
例如:
text
channel_id=83b8f361234e171cb7ea90d0228d7151
CaseRunner 在用例开始时会把它放进变量池:
python
def _apply_case_vars(self, case: CaseConfig) -> None:
raw = (case.case_vars or "").strip()
if not raw:
return
for part in raw.split(";"):
key, _, val = part.partition("=")
self.variables.set(key.strip(), val.strip())
所以步骤里可以写:
csv
fill,channel_id_input,${channel_id}
这样同一套步骤可以复用,不同用例只要在 case_vars 里换参数。
排错时应该看哪里
如果依赖或变量相关用例失败,可以按这个顺序排查:
text
1. cases.csv 里的 depends_on 是否写对。
2. 被依赖用例是否真的执行并通过。
3. extract 的 source 变量名是否和上一步 save_as 一致。
4. JSON 路径是否存在,比如 response.data.userName。
5. assert_var 里的 eq/ne 是否符合预期。
6. state_file 是否保存成功,state_mode=load 时文件是否存在。
最常见的问题是变量名写错。
比如上一步保存的是:
csv
save_as=login_response
下一步却写:
csv
target=login_resp
这时 VariableStore.get() 会直接报:
text
Variable not found
这种错误不需要从 Playwright 查起,先查 CSV 字段最有效。
小结
这一篇要记住三件事:
text
depends_on -> 解决用例执行顺序
VariableStore -> 解决跨步骤、跨用例的数据传递
storage_state -> 解决浏览器状态复用
游客登录这种状态型场景,不是一条孤立用例能讲清楚的。
框架把"先跑哪条用例""保存哪个变量""复用哪个设备状态"都放到了 CSV 和执行器里。测试同学维护用例时,重点不是改 Python,而是把业务关系描述清楚。