摘要
上一篇文章里,我从两个 Playwright 脚本讲起:
第一个脚本,用来验证不同设备环境下,首次游客登录是否会生成不同游客。
第二个脚本,用来验证点击 SDK 页面按钮之后,是否真的发出了正确的网络请求,并且请求参数和响应数据是否符合预期。
这两个脚本都能解决具体问题,但继续往下做时,我发现一个更大的问题:
设备参数写死在 Python 里 页面元素写死在 Python 里 接口地址写死在 Python 里 断言逻辑写死在 Python 里 用例依赖写死在 Python 里 变量提取也写死在 Python 里所以我开始把这套 H5 SDK 自动化测试从单脚本,重构成数据驱动框架。
这篇文章继续往下讲一个更具体的问题:
CSV + YAML 到底怎么描述一条自动化测试?
我的目标不是把所有字段逐个解释成说明书,而是讲清楚:
为什么第一版框架要把测试数据拆成devices.csv、cases.csv、case_steps.csv和elements.yaml,它们分别解决什么问题,又为什么不能全部塞进一个文件里。
一、问题不是“用什么文件”,而是“变化在哪里”
很多时候,一说数据驱动,大家马上会讨论:
用 CSV 还是 Excel? 用 YAML 还是 JSON? 要不要用数据库? 要不要做用例管理平台?但我这次设计数据模型时,最先考虑的不是文件格式。
我先问的是另一个问题:
这套 H5 SDK 自动化测试里,哪些东西会经常变化?
如果一套自动化脚本一直只有一条用例、一个浏览器、一个接口、一个页面按钮,那确实没必要做复杂设计。
但 H5 SDK 测试不是这样。
它天然会变化。
1.1 设备会变
今天我要模拟 Windows Chrome。
明天可能要模拟 iPhone Safari。
后面可能还要补:
不同浏览器 不同屏幕尺寸 不同语言 不同时区 不同移动端特征 不同触摸能力如果这些设备参数一直写在 Python 脚本里,那么每次新增设备,都要改代码。
1.2 用例会变
第一阶段我先做游客登录。
但 H5 SDK 后续还要覆盖:
初始化 游客登录 邮箱登录 Google 登录 Facebook 登录 Apple 登录 SDK 埋点 自定义埋点 支付 归因 用户信息 账号绑定和解绑如果每新增一个测试点都复制一个 Python 脚本,脚本数量会越来越多。
1.3 步骤会变
不同业务流程里,步骤不一样。
比如游客登录的步骤可能是:
打开页面 等待 Channel ID 点击初始化 等待初始化接口 点击游客登录 等待登录接口 提取 userName 等待用户信息接口 等待登录成功埋点但邮箱登录可能还要:
输入邮箱 获取验证码 输入验证码 点击邮箱登录 等待邮箱登录接口 校验登录态支付流程又完全不一样。
所以步骤本身也应该可以配置。
1.4 页面元素会变
测试页上的元素也可能变。
比如:
初始化按钮 ID 变了 游客登录按钮 ID 变了 状态文案区域变了 操作日志区域结构变了 第三方登录按钮位置变了如果选择器散落在 Python 代码和 CSV 步骤里,页面一改,就要到处搜索修改。
1.5 接口断言也会变
不同功能要断言的接口不同。
游客登录要断言:
/test-api/oauth2/login /test-api/oauth2/playergameuser/getUserById /test-api/client/adjusteventrecord/save初始化要断言:
/test-api/client/init埋点要断言:
eventName deviceId channelId sdkType browser os支付要断言的字段会更多。
这些断言不应该都写死在 Python 里。
1.6 所以我先拆变化点
最后,我把第一版配置拆成了四份:
data/devices.csv data/cases.csv data/case_steps.csv data/elements.yaml它们分别回答四个问题:
用什么设备跑? -> devices.csv 要跑哪些用例? -> cases.csv 每条用例怎么执行? -> case_steps.csv 页面元素怎么定位? -> elements.yaml这就是第一版数据模型的核心。
二、为什么不能只用一个大 CSV?
最直接的做法,是把所有东西都写进一个大 CSV。
比如一行里同时写:
用例 ID 用例名称 模块 设备参数 页面地址 按钮选择器 接口地址 断言表达式 变量提取规则 状态文件 是否继续执行看起来文件少了,管理起来好像也简单。
但真正写起来,很快会出现问题。
2.1 重复会非常多
同一个设备可能会被很多用例复用。
比如device_a可能用于:
游客登录 邮箱登录 初始化 埋点 支付如果每条用例、每个步骤都重复写:
user_agent viewport_width viewport_height locale timezone_id device_scale_factor is_mobile has_touchCSV 会迅速膨胀。
更麻烦的是,一旦设备参数要改,就要改很多行。
这很容易漏。
2.2 修改风险会变大
比如页面上的游客登录按钮选择器原来是:
#testGuestLogin如果这个选择器出现在几十行步骤里,页面一改,就要逐行修改。
我更希望的是:
步骤里只写 guest_login_button 真正的选择器统一放到 elements.yaml这样按钮选择器变化时,只改一个地方。
2.3 不同层级的信息会混在一起
用例信息、设备信息、步骤信息、页面元素信息,其实不是同一类东西。
比如:
case_id 属于用例层 device_id 属于设备层 action 属于步骤层 #initSDK 属于页面元素层如果强行放在一个表里,短期看起来文件少了,长期会导致配置越来越难读。
最后很容易变成:
一个巨大 CSV 什么都能写 什么都不好维护所以我没有追求“文件越少越好”。
我更关注的是:
每个文件的职责是否清楚。
三、devices.csv:把设备参数从脚本里拿出来
第一个文件是devices.csv。
它解决的问题是:
同一套用例,可以在不同浏览器 / 不同设备环境下运行。
3.1 原来设备参数写在 Python 里
在最开始的多游客登录脚本里,设备参数大概是这样写的:
BROWSER_PROFILES=[{"name":"windows_chrome","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...","viewport":{"width":1366,"height":768},"locale":"zh-CN","timezone_id":"Asia/Shanghai","device_scale_factor":1,"is_mobile":False,"has_touch":False,},{"name":"iphone_safari","user_agent":"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ...","viewport":{"width":390,"height":844},"locale":"zh-CN","timezone_id":"Asia/Tokyo","device_scale_factor":3,"is_mobile":True,"has_touch":True,},]这段代码能用。
但它的问题是:
设备参数和测试逻辑绑在一起了。如果我要新增一个设备,就要改 Python。
如果我要调整某个设备参数,也要改 Python。
但设备本身不是框架逻辑,它只是测试输入。
所以我把它抽到devices.csv。
3.2 devices.csv 示例
一行代表一个设备。
device_id,device_name,user_agent,viewport_width,viewport_height,locale,timezone_id,device_scale_factor,is_mobile,has_touch device_a,Windows Chrome,"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",1366,768,zh-CN,Asia/Shanghai,1,false,false device_b,iPhone Safari,"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",390,844,zh-CN,Asia/Tokyo,3,true,true这样用例里只需要引用:
device_a device_b不用再关心背后那一堆浏览器上下文参数。
3.3 devices.csv 字段说明
我第一版会先保留这些字段:
device_id device_name user_agent viewport_width viewport_height locale timezone_id device_scale_factor is_mobile has_touch它们和 Playwright 创建 context 时的参数基本对应。
比如框架读取到一行设备配置后,可以转换成:
context=browser.new_context(user_agent=device["user_agent"],viewport={"width":int(device["viewport_width"]),"height":int(device["viewport_height"]),},locale=device["locale"],timezone_id=device["timezone_id"],device_scale_factor=float(device["device_scale_factor"]),is_mobile=parse_bool(device["is_mobile"]),has_touch=parse_bool(device["has_touch"]),)这一步做完之后,设备配置就从 Python 代码里独立出来了。
3.4 这样拆的好处
第一,设备可以复用。
游客登录可以用 device_a 邮箱登录可以用 device_a 埋点测试也可以用 device_a 支付流程也可以用 device_a第二,新增设备不需要改框架代码。
只需要在devices.csv加一行。
第三,设备维度可以参与用例设计。
比如后续可以设计:
同一用例在多个设备上执行 同一设备复用 storage_state 不同设备断言生成不同游客 不同语言环境验证页面和请求参数这就是把设备从“脚本变量”变成“测试数据”带来的价值。
四、cases.csv:把“用例是什么”讲清楚
第二个文件是cases.csv。
它解决的问题是:
哪些是业务用例,以及这些用例之间有什么关系。
4.1 我不希望每个用例都是一个 Python 函数
如果不用数据驱动,最后很容易写成这样:
deftest_guest_001():...deftest_guest_002():...deftest_email_001():...deftest_payment_001():...这种写法当然可以。
但问题是:
业务用例仍然锁在 Python 代码里。我更希望业务用例进入配置文件。
Python 只保留一个统一入口:
pytest 启动 读取 cases.csv 选择要运行的 case_id 读取 case_steps.csv 交给框架执行4.2 cases.csv 示例
当前游客登录相关用例可以先设计成这样:
case_id,module,case_name,enabled,depends_on,device_id,state_mode,state_file GUEST_001,guest_login,新设备首次游客登录,true,,device_a,new,device_a_state.json GUEST_002,guest_login,老设备再次游客登录,true,GUEST_001,device_a,load,device_a_state.json GUEST_003,guest_login,不同设备首次游客登录,true,GUEST_001,device_b,new,device_b_state.json这三条用例分别验证:
GUEST_001:新设备可以创建游客 GUEST_002:老设备再次登录,应该还是同一个游客 GUEST_003:不同设备首次登录,应该生成不同游客4.3 case_id:用例唯一标识
case_id是用例的唯一标识。
它不只是一个名字。
它会被很多地方引用:
命令行运行指定用例 case_steps.csv 绑定步骤 depends_on 声明用例依赖 日志记录 测试报告 变量作用域所以case_id要稳定,不能随便改。
我一般会用模块前缀加编号,比如:
GUEST_001 GUEST_002 EMAIL_001 PAY_001 EVENT_001这样后续看日志和报告时会更清楚。
4.4 module:用例模块分组
module用来做模块分组。
比如:
guest_login email_login third_party_login payment event_tracking init这样后续就可以按模块执行:
python-mpytest tests/test_csv_runner.py--moduleguest_login对于日常回归来说,这个字段很有用。
因为有时候我不想跑全部用例,只想跑某个模块。
4.5 enabled:是否默认启用
enabled表示用例是否默认执行。
不是所有用例都适合默认跑。
比如:
第三方登录可能依赖真实浏览器账号状态 支付可能依赖测试商品和测试账号 某些归因测试可能依赖外部链接和特定环境 某些破坏性测试不适合默认执行这些用例可以先写进配置,但设置为:
enabled = false等需要时再通过命令行指定执行。
4.6 depends_on:用例依赖
depends_on表示用例之间的依赖关系。
比如:
GUEST_002,guest_login,老设备再次游客登录,true,GUEST_001,device_a,load,device_a_state.json这里表示:
GUEST_002 依赖 GUEST_001为什么?
因为GUEST_002要验证老设备再次登录。
它必须先有一个前置状态:
GUEST_001 先用 device_a 登录 保存 device_a_state.json 保存 guest_a然后GUEST_002才能加载这个状态,再次登录,并断言还是同一个游客。
如果我单独运行GUEST_002,框架应该知道:
它依赖 GUEST_001 需要先补跑 GUEST_001 或者提示依赖状态不存在这比单纯依赖脚本执行顺序更清楚。
4.7 device_id:引用设备配置
device_id表示这条用例使用哪个设备。
它引用的是devices.csv里的设备 ID。
比如:
device_a device_b这样cases.csv不需要重复写设备参数。
框架执行时会:
从 cases.csv 读取 device_id 再到 devices.csv 找对应设备参数 最后用这些参数创建 Playwright context4.8 state_mode 和 state_file:控制浏览器状态
游客登录里,“新设备”和“老设备”的差异,很大一部分来自浏览器状态。
所以我设计了两个字段:
state_mode state_filestate_mode可以先支持:
new:新状态启动 load:加载已有状态比如:
GUEST_001,guest_login,新设备首次游客登录,true,,device_a,new,device_a_state.json GUEST_002,guest_login,老设备再次游客登录,true,GUEST_001,device_a,load,device_a_state.json含义是:
GUEST_001 用新状态启动,执行完保存 device_a_state.json GUEST_002 加载 device_a_state.json,模拟老设备再次登录这样“新设备”和“老设备”就不再靠 Python 逻辑写死,而是进入了用例配置。
五、case_steps.csv:一行描述一个测试步骤
第三个文件是case_steps.csv。
它是第一版框架里最重要的配置文件。
因为它真正描述了:
一条用例到底怎么执行。
5.1 为什么要一行一个步骤
一条自动化用例,本质上就是一组步骤。
比如游客登录:
打开 SDK 测试页 等待 Channel ID 点击初始化 等待初始化成功 等待 init 接口 点击游客登录 等待 login 接口 提取 userName 等待 getUserById 接口 等待登录成功埋点接口 保存设备状态这些步骤原来都写在 Python 里。
现在我希望它们变成配置。
所以case_steps.csv的基本思路是:
一行 = 一个步骤5.2 case_steps.csv 字段设计
第一版可以先保留这些字段:
case_id step_id step_name action target value expect save_as depends_on_step continue_on_fail它们分别解决不同问题。
case_id:这一步属于哪条用例 step_id:步骤编号 step_name:步骤名称,方便日志和报告展示 action:动作类型 target:操作目标,通常是元素别名或变量名 value:输入值、URL、接口关键字等 expect:预期结果或断言表达式 save_as:把结果保存成变量 depends_on_step:当前步骤依赖哪些前置步骤 continue_on_fail:失败后是否允许继续执行5.3 action:框架要执行什么动作
action是最核心的字段。
它决定这一行到底做什么。
第一版可以先支持这些动作:
goto click fill wait_element wait_text wait_request extract assert_var save_state load_state比如:
case_id,step_id,step_name,action,target,value,expect,save_as,depends_on_step,continue_on_fail GUEST_001,1,打开SDK测试页,goto,,https://sdk-test.uggamer.com/h5/test-sdk.html,,,,false GUEST_001,2,等待Channel ID,wait_element,channel_id_input,,not_empty,,1,false GUEST_001,3,点击初始化,click,init_button,,,,2,false框架读到这些步骤后,会解释成真正的 Playwright 操作:
goto -> page.goto() wait_element -> locator 等待元素满足条件 click -> locator.click()5.4 wait_request:把网络断言写进步骤
前面我已经封装过NetworkRecorder。
现在要做的是,把网络请求断言变成配置。
比如等待登录接口:
GUEST_001,7,等待登录接口,wait_request,,/test-api/oauth2/login,status=200;response.code=200;response.data.access_token=not_empty,login_response,6,true这行虽然短,但表达了很多信息:
它属于 GUEST_001 它是第 7 步 动作是 wait_request 等待的接口 URL 包含 /test-api/oauth2/login 断言 HTTP 状态码是 200 断言响应 code 是 200 断言 access_token 非空 把这次网络记录保存为 login_response 它依赖第 6 步 失败后允许继续执行后续不依赖它的步骤以前这些逻辑要写在 Python 里:
login_record=recorder.wait_for_url_contains("/test-api/oauth2/login")assert_response_success(login_record)login_response=login_record["response_json"]access_token=login_response.get("data",{}).get("access_token")assertaccess_token现在它变成了 CSV 里的一行。
这就是数据驱动的核心价值。
5.5 一个游客登录用例的步骤示例
以GUEST_001为例,可以先写成这样:
case_id,step_id,step_name,action,target,value,expect,save_as,depends_on_step,continue_on_fail GUEST_001,1,打开SDK测试页,goto,,https://sdk-test.uggamer.com/h5/test-sdk.html,,,,false GUEST_001,2,等待Channel ID,wait_element,channel_id_input,,not_empty,,1,false GUEST_001,3,点击初始化,click,init_button,,,,2,false GUEST_001,4,等待初始化成功文案,wait_text,sdk_status,,SDK 初始化成功,,3,false GUEST_001,5,等待初始化接口,wait_request,,/test-api/client/init,status=200;response.code=200,init_response,3,true GUEST_001,6,点击游客登录,click,guest_login_button,,,,4,false GUEST_001,7,等待登录接口,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,login_response,response.data.userName,,guest_a,7,true GUEST_001,9,等待用户信息接口,wait_request,,/test-api/oauth2/playergameuser/getUserById,status=200;response.code=200,user_response,6,true GUEST_001,10,等待登录成功埋点,wait_request,,/test-api/client/adjusteventrecord/save,status=200;response.code=200;request.eventName=sdk_登录成功,event_response,6,true GUEST_001,11,保存浏览器状态,save_state,,device_a_state.json,,,6,true这条用例就已经表达了:
页面操作 页面断言 网络请求断言 请求参数断言 响应字段断言 变量提取 状态保存 失败策略而这些都不需要写成一个新的 Python 测试函数。
六、depends_on_step:步骤之间也要有依赖
一条用例内部,步骤并不总是简单从上到下执行就完了。
尤其是在 SDK 测试里,我不希望某一个断言失败后,整条用例立刻结束。
我更希望一次执行能暴露更多信息。
6.1 为什么不能简单 fail-fast
比如游客登录后,我要检查三个请求:
登录接口 查询用户接口 登录成功埋点接口如果登录接口成功,但埋点接口没发,我希望测试报告能告诉我:
登录接口成功 用户信息接口成功 埋点接口未捕获 最终用例失败而不是第一个失败就直接停止。
但另一方面,如果“点击游客登录”这一步都失败了,那么后面的登录接口、用户信息接口、埋点接口就不应该继续等。
所以这里需要步骤依赖。
6.2 depends_on_step 的作用
depends_on_step表示当前步骤依赖哪些前置步骤。
比如:
GUEST_001,7,等待登录接口,wait_request,,/test-api/oauth2/login,status=200;response.code=200;response.data.access_token=not_empty,login_response,6,true这里的depends_on_step = 6表示:
第 7 步依赖第 6 步也就是必须先成功点击游客登录,才有必要等待登录接口。
如果第 6 步失败,那么第 7 步应该跳过。
6.3 框架执行时的判断逻辑
框架执行每一步时,可以先判断依赖步骤状态:
如果依赖步骤成功: 当前步骤继续执行 如果依赖步骤失败或跳过: 当前步骤跳过 如果当前步骤失败: 记录失败原因 根据 continue_on_fail 判断是否继续后续步骤这样就能做到:
不是无脑失败就停 也不是失败后还盲目继续而是根据步骤依赖来决定后续动作。
6.4 多个步骤依赖同一个动作
比如点击游客登录之后,会触发多个请求:
GUEST_001,6,点击游客登录,click,guest_login_button,,,,4,false GUEST_001,7,等待登录接口,wait_request,,/test-api/oauth2/login,status=200;response.code=200;response.data.access_token=not_empty,login_response,6,true GUEST_001,9,等待用户信息接口,wait_request,,/test-api/oauth2/playergameuser/getUserById,status=200;response.code=200,user_response,6,true GUEST_001,10,等待登录成功埋点,wait_request,,/test-api/client/adjusteventrecord/save,status=200;response.code=200;request.eventName=sdk_登录成功,event_response,6,true第 7、9、10 步都依赖第 6 步。
如果点击游客登录成功,它们都可以执行。
如果点击游客登录失败,它们都应该跳过。
这比单纯按顺序执行更合理。
七、save_as:把中间结果放进变量池
自动化测试里,很多断言不是孤立的。
前一个步骤拿到的结果,后一个步骤可能还要继续用。
7.1 为什么需要变量池
比如游客登录里,我需要验证:
新设备首次登录生成 guest_a 老设备再次登录生成 guest_a_again 断言 guest_a_again == guest_a 不同设备首次登录生成 guest_b 断言 guest_b != guest_a这就需要把前面步骤拿到的数据保存下来。
如果都写在 Python 里,就会变成各种函数参数和全局变量。
我更希望框架有一个统一的变量池。
7.2 save_as 保存步骤结果
save_as字段用来保存中间结果。
比如:
GUEST_001,7,等待登录接口,wait_request,,/test-api/oauth2/login,status=200;response.code=200;response.data.access_token=not_empty,login_response,6,true这一步会把完整的登录接口记录保存为:
login_response里面可以包含:
请求 URL 请求方法 请求体 HTTP 状态码 响应 JSON 响应文本后续步骤就可以继续使用它。
7.3 extract 从结果中提取变量
比如从登录响应里提取userName:
GUEST_001,8,提取游客名称,extract,login_response,response.data.userName,,guest_a,7,true这行表达的是:
从 login_response 里取 response.data.userName 保存为 guest_a后续就可以通过:
${guest_a}来引用它。
7.4 跨用例变量比较
比如老设备再次登录:
GUEST_002,8,提取游客名称,extract,login_response,response.data.userName,,guest_a_again,7,true GUEST_002,9,断言老设备游客不变,assert_var,guest_a_again,,eq:${guest_a},,8,true这表示:
提取当前登录返回的 userName,保存为 guest_a_again 断言 guest_a_again 等于 GUEST_001 中保存的 guest_a不同设备登录则可以写成:
GUEST_003,8,提取游客名称,extract,login_response,response.data.userName,,guest_b,7,true GUEST_003,9,断言不同设备游客不同,assert_var,guest_b,,not_eq:${guest_a},,8,true这表示:
device_b 生成的 guest_b 不应该等于 device_a 生成的 guest_a7.5 变量池不能滥用
变量池很有用,但也不能滥用。
如果变量越来越多,依赖越来越深,说明用例之间耦合可能过重。
比如一条用例需要依赖前面五条用例产生的十几个变量,这种设计就不太健康。
变量池应该服务于明确的业务链路,比如:
新设备 -> 老设备 创建订单 -> 查询订单 登录 -> 查询用户信息 初始化 -> 埋点上报而不是让所有用例都互相依赖。
八、elements.yaml:把页面选择器和测试步骤解耦
第四个文件是elements.yaml。
它解决的问题是:
页面元素选择器不应该直接散落在测试步骤里。
8.1 原来选择器写在代码里
比如:
page.locator("#channelIdInput")page.locator("#initSDK")page.locator("#sdkStatus")page.locator("#testGuestLogin")或者写在 CSV 里:
GUEST_001,3,点击初始化,click,#initSDK,,,, GUEST_001,6,点击游客登录,click,#testGuestLogin,,,,这两种方式都能用。
但维护性不够好。
8.2 用元素别名代替选择器
我更希望步骤里写的是:
GUEST_001,3,点击初始化,click,init_button,,,, GUEST_001,6,点击游客登录,click,guest_login_button,,,,而真实选择器放在elements.yaml:
sdk_test_page:channel_id_input:"#channelIdInput"init_button:"#initSDK"sdk_status:"#sdkStatus"guest_login_button:"#testGuestLogin"google_login_button:"#testGGLogin"facebook_login_button:"#testFBLogin"apple_login_button:"#testAPPLELogin"operation_log:"#operationLog"这样测试步骤就更接近业务语义。
click init_button比:
click #initSDK更容易理解。
8.3 elements.yaml 的好处
第一,步骤更可读。
测试用例里看到guest_login_button,基本就知道这是游客登录按钮。
第二,选择器变更影响更小。
如果初始化按钮从:
#initSDK改成:
button[data-testid="init-sdk"]只需要改 YAML:
init_button:'button[data-testid="init-sdk"]'不用改所有测试步骤。
第三,后续可以按页面或弹窗分组。
比如:
sdk_test_page:channel_id_input:"#channelIdInput"init_button:"#initSDK"guest_login_button:"#testGuestLogin"google_login_popup:email_input:'input[type="email"]'next_button:"#identifierNext"password_input:'input[type="password"]'第三方登录后续会涉及 OAuth 弹窗,这种分组会很有用。
8.4 选择器策略也要治理
不过,elements.yaml只是把选择器集中管理,并不能解决所有页面不稳定问题。
如果页面 DOM 本身频繁变化,还是需要更稳定的定位策略。
比如优先使用:
稳定 ID><buttondata-testid="guest-login-button">游客登录</button>这样自动化会比依赖复杂 CSS 路径更稳定。
九、四个文件如何串起来
这四个文件不是孤立的。
它们通过 ID 和别名串起来。
整体执行链路可以理解成这样:
pytest 启动 -> 读取 cases.csv -> 找到要执行的 case_id -> 根据 depends_on 解析用例依赖 -> 根据 device_id 找到 devices.csv 中的设备 -> 根据 case_id 找到 case_steps.csv 中的步骤 -> 步骤执行时根据 target 查 elements.yaml -> 执行 Playwright 操作或网络断言 -> 保存变量、状态和结果 -> 最后交给 pytest 判定成功或失败9.1 以 GUEST_001 为例
cases.csv里有:
case_id,module,case_name,enabled,depends_on,device_id,state_mode,state_file GUEST_001,guest_login,新设备首次游客登录,true,,device_a,new,device_a_state.json框架读取后知道:
要执行 GUEST_001 它属于 guest_login 模块 它使用 device_a 它是新状态启动 执行后可以保存 device_a_state.json然后去devices.csv找device_a:
device_id,device_name,user_agent,viewport_width,viewport_height,locale,timezone_id,device_scale_factor,is_mobile,has_touch device_a,Windows Chrome,"Mozilla/5.0 ...",1366,768,zh-CN,Asia/Shanghai,1,false,false框架用这些参数创建浏览器 context。
接着去case_steps.csv找GUEST_001的所有步骤:
GUEST_001,1,打开SDK测试页,goto,,https://sdk-test.uggamer.com/h5/test-sdk.html,,,,false GUEST_001,2,等待Channel ID,wait_element,channel_id_input,,not_empty,,1,false GUEST_001,3,点击初始化,click,init_button,,,,2,false执行第 2 步时,target = channel_id_input。
框架就去elements.yaml找:
channel_id_input:"#channelIdInput"然后执行:
page.locator("#channelIdInput")这样四个文件就串起来了。
9.2 以 GUEST_002 为例
GUEST_002是老设备再次登录。
它在cases.csv里可以这样写:
GUEST_002,guest_login,老设备再次游客登录,true,GUEST_001,device_a,load,device_a_state.json这行的关键点是:
depends_on = GUEST_001 device_id = device_a state_mode = load state_file = device_a_state.json含义是:
先确保 GUEST_001 已经执行过 复用 device_a 加载 device_a_state.json 再次游客登录 提取 guest_a_again 断言 guest_a_again == guest_a这时框架不再靠脚本顺序猜测,而是根据cases.csv中的依赖关系执行。
9.3 这就是数据模型真正发挥作用的地方
单独看四个文件,好像只是拆配置。
但它们串起来之后,就能表达完整业务链路:
用什么设备 跑哪条用例 是否依赖前置用例 是否加载浏览器状态 每一步做什么动作 操作哪个元素 等待哪个接口 断言哪个字段 保存哪个变量 最终如何判定成功或失败这就是我设计 CSV + YAML 数据模型的目的。
十、第一版为什么不要设计得太复杂
第一版的数据模型,我刻意设计得比较克制。
它没有一开始就做:
复杂条件表达式 复杂 JSONPath 语法 多环境配置中心 完整用例管理平台 大量业务关键字 复杂循环和分支 分布式执行这是有意为之。
10.1 第一版先证明真实链路能跑通
第一版最重要的目标不是把所有未来能力一次性设计完。
而是先证明:
这套模型能表达一条真实的 H5 SDK 回归链路。游客登录这条链路已经覆盖了很多关键能力:
设备差异 新老状态 用例依赖 页面操作 网络请求断言 变量提取 跨用例变量比较 状态保存和加载 失败后继续执行如果这条链路能稳定跑通,后面扩展邮箱登录、第三方登录、埋点、支付,就有了基础。
10.2 过度设计会拖慢落地
如果第一版一开始就设计得太重,可能会出现几个问题:
配置变难写 框架变难调试 字段越来越多 还没跑通核心链路,就陷入设计细节自动化框架最怕一开始就做成“大平台”。
结果是:
目录很多 概念很多 配置很多 但真实业务链路还跑不稳所以第一版应该先小而稳。
先让游客登录这条链路跑通。
再根据真实扩展需求,逐步加能力。
十一、这套 CSV + YAML 模型的边界
这套设计不是万能的。
它只是第一版 H5 SDK 自动化框架的数据模型。
它有自己的适用边界。
11.1 CSV 不适合复杂分支
CSV 很适合表达线性步骤。
比如:
打开页面 点击按钮 等待接口 提取变量 断言变量但如果后续出现大量:
if / else 循环 动态重试 复杂条件判断 多路径分支就不应该硬塞进 CSV。
更合适的方式是:
把稳定业务流程封装成关键字 CSV 调用更高层动作 复杂逻辑留在 Python 框架里比如:
GUEST_001,5,执行游客登录流程,guest_login_flow,,,,guest_result,4,false这个guest_login_flow可以在 Python 里封装一组稳定动作。
11.2 YAML 不能解决所有页面不稳定问题
elements.yaml可以集中管理选择器。
但如果页面 DOM 本身很不稳定,YAML 也救不了。
这时需要从定位策略上治理:
尽量不用长 CSS 路径 尽量不用容易变化的层级结构 优先使用稳定 ID 优先使用>11.3 变量池不能无限扩张变量池适合跨步骤、跨用例传递运行时数据。
但如果变量越来越多,说明用例之间的耦合可能越来越严重。
比如:
A 用例依赖 B 的变量 B 用例依赖 C 的变量 C 用例又依赖 D 的状态
这种链路会越来越难维护。
所以变量池应该用于明确、必要的业务链路,而不是让所有用例都共享一堆全局变量。
11.4 storage_state 要注意安全
Playwright 的storage_state很适合模拟老设备或已登录状态。
但状态文件里可能包含:
cookies localStorage 登录态 token 用户标识 业务缓存
所以这类文件不要随便提交到远程仓库。
可以考虑:
加入 .gitignore 只在本地或 CI 临时生成 必要时定期清理 敏感环境不要复用真实账号状态
十二、小结
第一版框架的数据模型,本质上是在拆分变化。
我没有把所有配置塞进一个大 CSV,而是拆成四个文件:
devices.csv cases.csv case_steps.csv elements.yaml
它们分别解决不同问题:
devices.csv -> 用什么设备跑 cases.csv -> 有哪些用例,以及它们之间是什么关系 case_steps.csv -> 每条用例具体怎么执行 elements.yaml -> 页面元素怎么定位
这四个文件共同完成了一件事:
把原来写死在 Python 脚本里的测试行为,拆成可以被维护、review、复用和扩展的数据。
这也是数据驱动自动化测试最关键的价值。
到这里,框架已经有了“剧本”。
但只有剧本还不够。
下一篇我会继续讲:
这些 CSV / YAML 配置是怎么被真正执行起来的?
从一行 CSV 到一次浏览器操作,关键字驱动执行引擎到底应该怎么设计?