从一行 CSV 到一次浏览器操作:关键字驱动执行引擎设计
摘要
上一篇文章讲了第一版框架的数据模型:devices.csv、cases.csv、case_steps.csv 和 elements.yaml 分别负责什么。
但配置文件本身不会执行测试。
CSV 里写了一行 click,浏览器为什么真的会点击按钮?CSV 里写了一行 wait_request,框架为什么能等到接口并做断言?CSV 里写了一行 extract,变量又是怎么被保存起来的?
这篇文章就顺着一行 CSV 往下追,看看它是怎么一步步变成真实浏览器操作的。
CSV 只是剧本,框架负责执行
先看一行真实步骤。
csv
GUEST_001,3,click init,click,init_button,,,,2,true
这行数据的意思是:
text
用例:GUEST_001
步骤:第 3 步
名称:click init
动作:click
目标:init_button
依赖:第 2 步
失败后继续:true
测试同学维护用例时,不需要写:
python
page.locator("#initSDK").click()
而是写:
csv
click,init_button
init_button 再到 elements.yaml 里找真正的选择器:
yaml
sdk_test_page:
init_button: "#initSDK"
这就是关键字驱动的核心:CSV 不直接写 Python 代码,只写稳定的动作语义。Python 框架负责把这些语义翻译成 Playwright 操作。
pytest 是统一入口
当前框架没有为每条业务用例都写一个 Python 测试函数。
它只有一个统一入口:tests/test_csv_runner.py。
核心流程可以简化成这样:
python
devices = load_devices()
cases = load_cases()
steps_by_case = load_steps()
elements = load_elements()
selected_case_ids = _select_cases(pytestconfig, cases)
run_order = DependencyResolver(cases).resolve(selected_case_ids)
with sync_playwright() as playwright:
browser_manager = BrowserManager(playwright, headless=...)
runner = CaseRunner(browser_manager, devices, steps_by_case, elements, variables, logger)
for case_id in run_order:
result = runner.run_case(cases[case_id])
collector.add_case_result(result)
这段代码解决了几个问题:
- 从 CSV / YAML 读取配置。
- 根据命令行参数选择要跑哪些用例。
- 根据用例依赖算出实际执行顺序。
- 创建 Playwright。
- 把具体用例交给
CaseRunner执行。 - 最后统一收集结果。
所以当你运行:
bash
python -m pytest tests/test_csv_runner.py --case-id GUEST_001
pytest 实际跑的不是一段写死的游客登录脚本,而是"CSV 驱动的通用执行器"。
CSV 先被读成配置对象
框架不会直接拿字符串到处传。
framework/config/csv_loader.py 会把 CSV 行转换成更明确的数据对象。
比如步骤会被读成 StepConfig:
python
@dataclass(frozen=True)
class StepConfig:
case_id: str
step_id: int
step_name: str
action: str
target: str
value: str
expect: str
save_as: str
depends_on_step: tuple[int, ...]
continue_on_fail: bool
case_steps.csv 里的每一列,基本都能在这里找到对应字段。
这一步很重要。
如果框架一直传原始 CSV 字符串,后面的执行逻辑会很乱。现在每个步骤都有明确字段:动作看 action,目标看 target,输入看 value,预期看 expect,要保存变量就看 save_as。
加载步骤的代码大致是:
python
step = StepConfig(
case_id=row["case_id"].strip(),
step_id=int(row["step_id"]),
step_name=row["step_name"].strip(),
action=row["action"].strip(),
target=row["target"].strip(),
value=row["value"].strip(),
expect=row["expect"].strip(),
save_as=row["save_as"].strip(),
depends_on_step=_split_ints(row["depends_on_step"]),
continue_on_fail=_as_bool(row["continue_on_fail"]),
)
测试同学看这段代码时,可以把它理解成 CSV 和 Python 框架之间的"翻译层"。
CaseRunner 负责用例级执行
读完配置后,真正执行用例的是 CaseRunner。
它做的事情不是某一个具体动作,而是组织整条用例:
python
steps_all = self.steps_by_case.get(case.case_id, [])
pre_steps, mid_steps = split_pre_session_steps(steps_all)
main_steps, post_steps = split_post_session_steps(mid_steps)
session = self.browser_manager.create_session(case, self.devices[case.device_id])
recorder = NetworkRecorder(session.page)
recorder.start()
runner = StepRunner(session, self.elements, recorder, self.variables)
for step in main_steps:
detail = runner.run(step)
这段流程里有几个角色:
BrowserManager创建浏览器会话。NetworkRecorder开始监听网络请求。StepRunner执行具体步骤。CaseRunner负责步骤顺序、失败处理和结果收集。
也就是说,CaseRunner 不关心 click 到底怎么点,它只关心"这一条用例有哪些步骤、按什么顺序执行、失败后怎么办"。
StepRunner 负责把 action 翻译成动作
真正把 action=click 翻译成 Playwright 操作的是 StepRunner.run()。
看几个核心分支:
python
def run(self, step: StepConfig) -> str:
action = step.action
if action == "goto":
url = self.variables.resolve_text(step.value)
self.page.goto(url, wait_until="domcontentloaded")
return f"opened url: {url}"
elif action == "click":
loc = self.locator(step.target)
loc.click(timeout=DEFAULT_TIMEOUT)
return f"clicked element: {step.target}"
elif action == "fill":
loc = self.locator(step.target)
text = self.variables.resolve_text(step.value)
loc.fill(text)
return f"filled element: {step.target} len={len(text)}"
这里的逻辑很直接:
goto打开页面。click点击元素。fill输入文本。
但它们都不是直接使用 CSV 里的原始字符串。比如 fill 会先调用:
python
self.variables.resolve_text(step.value)
所以 CSV 里可以写:
csv
fill,channel_id_input,${channel_id}
运行时再把 ${channel_id} 替换成变量池里的真实值。
target 如何变成页面选择器
click 分支里有一行:
python
loc = self.locator(step.target)
对应方法是:
python
def locator(self, target: str):
selector = self.elements.get(target, target)
if not selector:
raise AssertionError("Element target is empty")
return self.page.locator(selector)
这段代码有一个小设计:如果 target 能在 elements.yaml 里找到,就使用配置里的选择器;如果找不到,就把 target 本身当成选择器。
所以两种写法都可以:
csv
click,init_button
或者:
csv
click,#initSDK
日常更推荐第一种。因为 init_button 是业务含义,#initSDK 是页面实现细节。页面选择器以后变了,只需要改 elements.yaml,不用到所有 CSV 里搜索替换。
wait_request 和 extract 也是关键字
关键字不只负责页面操作,也负责网络断言和变量提取。
比如登录接口:
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
StepRunner 里对应的是:
python
record = self.recorder.wait_for_url_contains(
self.variables.resolve_text(step.value),
timeout=DEFAULT_TIMEOUT,
matcher=lambda item: assert_expectations(
item,
self.variables.resolve_text(step.expect),
),
)
if step.save_as:
self.variables.set(step.save_as, record.to_dict())
这一步会等待 URL 包含 /test-api/oauth2/login 的请求,并检查:
text
status=200
response.code=200
response.data.access_token=not_empty
如果匹配成功,还会把整条网络记录保存到变量 login_response。
下一步就可以提取字段:
csv
GUEST_001,8,extract guest name,extract,login_response,response.data.userName,,guest_a,7,true
对应代码:
python
value = self.variables.extract(step.target, step.value)
self.variables.set(step.save_as, value)
这就是为什么 CSV 里可以写"等待接口 -> 保存响应 -> 提取 userName -> 后续断言"。
这套设计的边界
关键字驱动不是为了让 CSV 变成另一种编程语言。
它适合表达稳定、重复、可抽象的测试动作,比如:
- 打开页面。
- 点击按钮。
- 输入文本。
- 等待文案。
- 等待接口。
- 提取字段。
- 断言变量。
- 保存浏览器状态。
但如果某个流程包含大量复杂判断、循环、动态分支,就要考虑是不是应该沉淀成新的 Python 关键字,而不是在 CSV 里硬凑。
一个简单判断标准是:
text
如果测试同学能用一句业务动作说清楚它,就适合做关键字。
如果必须解释很多代码细节,可能就不适合直接塞进 CSV。
小结
这一篇要记住一条主线:
text
case_steps.csv
-> csv_loader.py
-> StepConfig
-> test_csv_runner.py
-> CaseRunner
-> StepRunner.run()
-> Playwright / NetworkRecorder / VariableStore
以后看到一行 CSV,不要只把它当成表格数据。
它最终会变成一个 StepConfig,交给 StepRunner 根据 action 执行。
理解了这条链路,后面再看用例依赖、变量池、网络断言和失败现场保存,就不会觉得它们是散的功能,而是同一套执行引擎上的不同能力。