CSV + YAML 怎么描述测试:H5 SDK 自动化框架的数据模型设计

摘要

上一篇文章里,我从两个 Playwright 脚本讲起:

第一个脚本,用来验证不同设备环境下,首次游客登录是否会生成不同游客。

第二个脚本,用来验证点击 SDK 页面按钮之后,是否真的发出了正确的网络请求,并且请求参数和响应数据是否符合预期。

这两个脚本都能解决具体问题,但继续往下做时,我发现一个更大的问题:

text 复制代码
设备参数写死在 Python 里
页面元素写死在 Python 里
接口地址写死在 Python 里
断言逻辑写死在 Python 里
用例依赖写死在 Python 里
变量提取也写死在 Python 里

所以我开始把这套 H5 SDK 自动化测试从单脚本,重构成数据驱动框架。

这篇文章继续往下讲一个更具体的问题:

CSV + YAML 到底怎么描述一条自动化测试?

我的目标不是把所有字段逐个解释成说明书,而是讲清楚:

为什么第一版框架要把测试数据拆成 devices.csvcases.csvcase_steps.csvelements.yaml,它们分别解决什么问题,又为什么不能全部塞进一个文件里。


一、问题不是"用什么文件",而是"变化在哪里"

很多时候,一说数据驱动,大家马上会讨论:

text 复制代码
用 CSV 还是 Excel?
用 YAML 还是 JSON?
要不要用数据库?
要不要做用例管理平台?

但我这次设计数据模型时,最先考虑的不是文件格式。

我先问的是另一个问题:

这套 H5 SDK 自动化测试里,哪些东西会经常变化?

如果一套自动化脚本一直只有一条用例、一个浏览器、一个接口、一个页面按钮,那确实没必要做复杂设计。

但 H5 SDK 测试不是这样。

它天然会变化。


1.1 设备会变

今天我要模拟 Windows Chrome。

明天可能要模拟 iPhone Safari。

后面可能还要补:

text 复制代码
不同浏览器
不同屏幕尺寸
不同语言
不同时区
不同移动端特征
不同触摸能力

如果这些设备参数一直写在 Python 脚本里,那么每次新增设备,都要改代码。


1.2 用例会变

第一阶段我先做游客登录。

但 H5 SDK 后续还要覆盖:

text 复制代码
初始化
游客登录
邮箱登录
Google 登录
Facebook 登录
Apple 登录
SDK 埋点
自定义埋点
支付
归因
用户信息
账号绑定和解绑

如果每新增一个测试点都复制一个 Python 脚本,脚本数量会越来越多。


1.3 步骤会变

不同业务流程里,步骤不一样。

比如游客登录的步骤可能是:

text 复制代码
打开页面
等待 Channel ID
点击初始化
等待初始化接口
点击游客登录
等待登录接口
提取 userName
等待用户信息接口
等待登录成功埋点

但邮箱登录可能还要:

text 复制代码
输入邮箱
获取验证码
输入验证码
点击邮箱登录
等待邮箱登录接口
校验登录态

支付流程又完全不一样。

所以步骤本身也应该可以配置。


1.4 页面元素会变

测试页上的元素也可能变。

比如:

text 复制代码
初始化按钮 ID 变了
游客登录按钮 ID 变了
状态文案区域变了
操作日志区域结构变了
第三方登录按钮位置变了

如果选择器散落在 Python 代码和 CSV 步骤里,页面一改,就要到处搜索修改。


1.5 接口断言也会变

不同功能要断言的接口不同。

游客登录要断言:

text 复制代码
/test-api/oauth2/login
/test-api/oauth2/playergameuser/getUserById
/test-api/client/adjusteventrecord/save

初始化要断言:

text 复制代码
/test-api/client/init

埋点要断言:

text 复制代码
eventName
deviceId
channelId
sdkType
browser
os

支付要断言的字段会更多。

这些断言不应该都写死在 Python 里。


1.6 所以我先拆变化点

最后,我把第一版配置拆成了四份:

text 复制代码
data/devices.csv
data/cases.csv
data/case_steps.csv
data/elements.yaml

它们分别回答四个问题:

text 复制代码
用什么设备跑?       -> devices.csv
要跑哪些用例?       -> cases.csv
每条用例怎么执行?   -> case_steps.csv
页面元素怎么定位?   -> elements.yaml

这就是第一版数据模型的核心。


二、为什么不能只用一个大 CSV?

最直接的做法,是把所有东西都写进一个大 CSV。

比如一行里同时写:

text 复制代码
用例 ID
用例名称
模块
设备参数
页面地址
按钮选择器
接口地址
断言表达式
变量提取规则
状态文件
是否继续执行

看起来文件少了,管理起来好像也简单。

但真正写起来,很快会出现问题。


2.1 重复会非常多

同一个设备可能会被很多用例复用。

比如 device_a 可能用于:

text 复制代码
游客登录
邮箱登录
初始化
埋点
支付

如果每条用例、每个步骤都重复写:

text 复制代码
user_agent
viewport_width
viewport_height
locale
timezone_id
device_scale_factor
is_mobile
has_touch

CSV 会迅速膨胀。

更麻烦的是,一旦设备参数要改,就要改很多行。

这很容易漏。


2.2 修改风险会变大

比如页面上的游客登录按钮选择器原来是:

text 复制代码
#testGuestLogin

如果这个选择器出现在几十行步骤里,页面一改,就要逐行修改。

我更希望的是:

text 复制代码
步骤里只写 guest_login_button
真正的选择器统一放到 elements.yaml

这样按钮选择器变化时,只改一个地方。


2.3 不同层级的信息会混在一起

用例信息、设备信息、步骤信息、页面元素信息,其实不是同一类东西。

比如:

text 复制代码
case_id 属于用例层
device_id 属于设备层
action 属于步骤层
#initSDK 属于页面元素层

如果强行放在一个表里,短期看起来文件少了,长期会导致配置越来越难读。

最后很容易变成:

text 复制代码
一个巨大 CSV
什么都能写
什么都不好维护

所以我没有追求"文件越少越好"。

我更关注的是:

每个文件的职责是否清楚。


三、devices.csv:把设备参数从脚本里拿出来

第一个文件是 devices.csv

它解决的问题是:

同一套用例,可以在不同浏览器 / 不同设备环境下运行。


3.1 原来设备参数写在 Python 里

在最开始的多游客登录脚本里,设备参数大概是这样写的:

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,
    },
]

这段代码能用。

但它的问题是:

text 复制代码
设备参数和测试逻辑绑在一起了。

如果我要新增一个设备,就要改 Python。

如果我要调整某个设备参数,也要改 Python。

但设备本身不是框架逻辑,它只是测试输入。

所以我把它抽到 devices.csv


3.2 devices.csv 示例

一行代表一个设备。

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

这样用例里只需要引用:

text 复制代码
device_a
device_b

不用再关心背后那一堆浏览器上下文参数。


3.3 devices.csv 字段说明

我第一版会先保留这些字段:

text 复制代码
device_id
device_name
user_agent
viewport_width
viewport_height
locale
timezone_id
device_scale_factor
is_mobile
has_touch

它们和 Playwright 创建 context 时的参数基本对应。

比如框架读取到一行设备配置后,可以转换成:

python 复制代码
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 这样拆的好处

第一,设备可以复用。

text 复制代码
游客登录可以用 device_a
邮箱登录可以用 device_a
埋点测试也可以用 device_a
支付流程也可以用 device_a

第二,新增设备不需要改框架代码。

只需要在 devices.csv 加一行。

第三,设备维度可以参与用例设计。

比如后续可以设计:

text 复制代码
同一用例在多个设备上执行
同一设备复用 storage_state
不同设备断言生成不同游客
不同语言环境验证页面和请求参数

这就是把设备从"脚本变量"变成"测试数据"带来的价值。


四、cases.csv:把"用例是什么"讲清楚

第二个文件是 cases.csv

它解决的问题是:

哪些是业务用例,以及这些用例之间有什么关系。


4.1 我不希望每个用例都是一个 Python 函数

如果不用数据驱动,最后很容易写成这样:

python 复制代码
def test_guest_001():
    ...

def test_guest_002():
    ...

def test_email_001():
    ...

def test_payment_001():
    ...

这种写法当然可以。

但问题是:

text 复制代码
业务用例仍然锁在 Python 代码里。

我更希望业务用例进入配置文件。

Python 只保留一个统一入口:

text 复制代码
pytest 启动
读取 cases.csv
选择要运行的 case_id
读取 case_steps.csv
交给框架执行

4.2 cases.csv 示例

当前游客登录相关用例可以先设计成这样:

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

这三条用例分别验证:

text 复制代码
GUEST_001:新设备可以创建游客
GUEST_002:老设备再次登录,应该还是同一个游客
GUEST_003:不同设备首次登录,应该生成不同游客

4.3 case_id:用例唯一标识

case_id 是用例的唯一标识。

它不只是一个名字。

它会被很多地方引用:

text 复制代码
命令行运行指定用例
case_steps.csv 绑定步骤
depends_on 声明用例依赖
日志记录
测试报告
变量作用域

所以 case_id 要稳定,不能随便改。

我一般会用模块前缀加编号,比如:

text 复制代码
GUEST_001
GUEST_002
EMAIL_001
PAY_001
EVENT_001

这样后续看日志和报告时会更清楚。


4.4 module:用例模块分组

module 用来做模块分组。

比如:

text 复制代码
guest_login
email_login
third_party_login
payment
event_tracking
init

这样后续就可以按模块执行:

bash 复制代码
python -m pytest tests/test_csv_runner.py --module guest_login

对于日常回归来说,这个字段很有用。

因为有时候我不想跑全部用例,只想跑某个模块。


4.5 enabled:是否默认启用

enabled 表示用例是否默认执行。

不是所有用例都适合默认跑。

比如:

text 复制代码
第三方登录可能依赖真实浏览器账号状态
支付可能依赖测试商品和测试账号
某些归因测试可能依赖外部链接和特定环境
某些破坏性测试不适合默认执行

这些用例可以先写进配置,但设置为:

text 复制代码
enabled = false

等需要时再通过命令行指定执行。


4.6 depends_on:用例依赖

depends_on 表示用例之间的依赖关系。

比如:

csv 复制代码
GUEST_002,guest_login,老设备再次游客登录,true,GUEST_001,device_a,load,device_a_state.json

这里表示:

text 复制代码
GUEST_002 依赖 GUEST_001

为什么?

因为 GUEST_002 要验证老设备再次登录。

它必须先有一个前置状态:

text 复制代码
GUEST_001 先用 device_a 登录
保存 device_a_state.json
保存 guest_a

然后 GUEST_002 才能加载这个状态,再次登录,并断言还是同一个游客。

如果我单独运行 GUEST_002,框架应该知道:

text 复制代码
它依赖 GUEST_001
需要先补跑 GUEST_001
或者提示依赖状态不存在

这比单纯依赖脚本执行顺序更清楚。


4.7 device_id:引用设备配置

device_id 表示这条用例使用哪个设备。

它引用的是 devices.csv 里的设备 ID。

比如:

text 复制代码
device_a
device_b

这样 cases.csv 不需要重复写设备参数。

框架执行时会:

text 复制代码
从 cases.csv 读取 device_id
再到 devices.csv 找对应设备参数
最后用这些参数创建 Playwright context

4.8 state_mode 和 state_file:控制浏览器状态

游客登录里,"新设备"和"老设备"的差异,很大一部分来自浏览器状态。

所以我设计了两个字段:

text 复制代码
state_mode
state_file

state_mode 可以先支持:

text 复制代码
new:新状态启动
load:加载已有状态

比如:

csv 复制代码
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

含义是:

text 复制代码
GUEST_001 用新状态启动,执行完保存 device_a_state.json
GUEST_002 加载 device_a_state.json,模拟老设备再次登录

这样"新设备"和"老设备"就不再靠 Python 逻辑写死,而是进入了用例配置。


五、case_steps.csv:一行描述一个测试步骤

第三个文件是 case_steps.csv

它是第一版框架里最重要的配置文件。

因为它真正描述了:

一条用例到底怎么执行。


5.1 为什么要一行一个步骤

一条自动化用例,本质上就是一组步骤。

比如游客登录:

text 复制代码
打开 SDK 测试页
等待 Channel ID
点击初始化
等待初始化成功
等待 init 接口
点击游客登录
等待 login 接口
提取 userName
等待 getUserById 接口
等待登录成功埋点接口
保存设备状态

这些步骤原来都写在 Python 里。

现在我希望它们变成配置。

所以 case_steps.csv 的基本思路是:

text 复制代码
一行 = 一个步骤

5.2 case_steps.csv 字段设计

第一版可以先保留这些字段:

text 复制代码
case_id
step_id
step_name
action
target
value
expect
save_as
depends_on_step
continue_on_fail

它们分别解决不同问题。

text 复制代码
case_id:这一步属于哪条用例
step_id:步骤编号
step_name:步骤名称,方便日志和报告展示
action:动作类型
target:操作目标,通常是元素别名或变量名
value:输入值、URL、接口关键字等
expect:预期结果或断言表达式
save_as:把结果保存成变量
depends_on_step:当前步骤依赖哪些前置步骤
continue_on_fail:失败后是否允许继续执行

5.3 action:框架要执行什么动作

action 是最核心的字段。

它决定这一行到底做什么。

第一版可以先支持这些动作:

text 复制代码
goto
click
fill
wait_element
wait_text
wait_request
extract
assert_var
save_state
load_state

比如:

csv 复制代码
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 操作:

text 复制代码
goto -> page.goto()
wait_element -> locator 等待元素满足条件
click -> locator.click()

5.4 wait_request:把网络断言写进步骤

前面我已经封装过 NetworkRecorder

现在要做的是,把网络请求断言变成配置。

比如等待登录接口:

csv 复制代码
GUEST_001,7,等待登录接口,wait_request,,/test-api/oauth2/login,status=200;response.code=200;response.data.access_token=not_empty,login_response,6,true

这行虽然短,但表达了很多信息:

text 复制代码
它属于 GUEST_001
它是第 7 步
动作是 wait_request
等待的接口 URL 包含 /test-api/oauth2/login
断言 HTTP 状态码是 200
断言响应 code 是 200
断言 access_token 非空
把这次网络记录保存为 login_response
它依赖第 6 步
失败后允许继续执行后续不依赖它的步骤

以前这些逻辑要写在 Python 里:

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")
assert access_token

现在它变成了 CSV 里的一行。

这就是数据驱动的核心价值。


5.5 一个游客登录用例的步骤示例

GUEST_001 为例,可以先写成这样:

csv 复制代码
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

这条用例就已经表达了:

text 复制代码
页面操作
页面断言
网络请求断言
请求参数断言
响应字段断言
变量提取
状态保存
失败策略

而这些都不需要写成一个新的 Python 测试函数。


六、depends_on_step:步骤之间也要有依赖

一条用例内部,步骤并不总是简单从上到下执行就完了。

尤其是在 SDK 测试里,我不希望某一个断言失败后,整条用例立刻结束。

我更希望一次执行能暴露更多信息。


6.1 为什么不能简单 fail-fast

比如游客登录后,我要检查三个请求:

text 复制代码
登录接口
查询用户接口
登录成功埋点接口

如果登录接口成功,但埋点接口没发,我希望测试报告能告诉我:

text 复制代码
登录接口成功
用户信息接口成功
埋点接口未捕获
最终用例失败

而不是第一个失败就直接停止。

但另一方面,如果"点击游客登录"这一步都失败了,那么后面的登录接口、用户信息接口、埋点接口就不应该继续等。

所以这里需要步骤依赖。


6.2 depends_on_step 的作用

depends_on_step 表示当前步骤依赖哪些前置步骤。

比如:

csv 复制代码
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 表示:

text 复制代码
第 7 步依赖第 6 步

也就是必须先成功点击游客登录,才有必要等待登录接口。

如果第 6 步失败,那么第 7 步应该跳过。


6.3 框架执行时的判断逻辑

框架执行每一步时,可以先判断依赖步骤状态:

text 复制代码
如果依赖步骤成功:
    当前步骤继续执行

如果依赖步骤失败或跳过:
    当前步骤跳过

如果当前步骤失败:
    记录失败原因
    根据 continue_on_fail 判断是否继续后续步骤

这样就能做到:

text 复制代码
不是无脑失败就停
也不是失败后还盲目继续

而是根据步骤依赖来决定后续动作。


6.4 多个步骤依赖同一个动作

比如点击游客登录之后,会触发多个请求:

csv 复制代码
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 为什么需要变量池

比如游客登录里,我需要验证:

text 复制代码
新设备首次登录生成 guest_a
老设备再次登录生成 guest_a_again
断言 guest_a_again == guest_a
不同设备首次登录生成 guest_b
断言 guest_b != guest_a

这就需要把前面步骤拿到的数据保存下来。

如果都写在 Python 里,就会变成各种函数参数和全局变量。

我更希望框架有一个统一的变量池。


7.2 save_as 保存步骤结果

save_as 字段用来保存中间结果。

比如:

csv 复制代码
GUEST_001,7,等待登录接口,wait_request,,/test-api/oauth2/login,status=200;response.code=200;response.data.access_token=not_empty,login_response,6,true

这一步会把完整的登录接口记录保存为:

text 复制代码
login_response

里面可以包含:

text 复制代码
请求 URL
请求方法
请求体
HTTP 状态码
响应 JSON
响应文本

后续步骤就可以继续使用它。


7.3 extract 从结果中提取变量

比如从登录响应里提取 userName

csv 复制代码
GUEST_001,8,提取游客名称,extract,login_response,response.data.userName,,guest_a,7,true

这行表达的是:

text 复制代码
从 login_response 里取 response.data.userName
保存为 guest_a

后续就可以通过:

text 复制代码
${guest_a}

来引用它。


7.4 跨用例变量比较

比如老设备再次登录:

csv 复制代码
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

这表示:

text 复制代码
提取当前登录返回的 userName,保存为 guest_a_again
断言 guest_a_again 等于 GUEST_001 中保存的 guest_a

不同设备登录则可以写成:

csv 复制代码
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

这表示:

text 复制代码
device_b 生成的 guest_b 不应该等于 device_a 生成的 guest_a

7.5 变量池不能滥用

变量池很有用,但也不能滥用。

如果变量越来越多,依赖越来越深,说明用例之间耦合可能过重。

比如一条用例需要依赖前面五条用例产生的十几个变量,这种设计就不太健康。

变量池应该服务于明确的业务链路,比如:

text 复制代码
新设备 -> 老设备
创建订单 -> 查询订单
登录 -> 查询用户信息
初始化 -> 埋点上报

而不是让所有用例都互相依赖。


八、elements.yaml:把页面选择器和测试步骤解耦

第四个文件是 elements.yaml

它解决的问题是:

页面元素选择器不应该直接散落在测试步骤里。


8.1 原来选择器写在代码里

比如:

python 复制代码
page.locator("#channelIdInput")
page.locator("#initSDK")
page.locator("#sdkStatus")
page.locator("#testGuestLogin")

或者写在 CSV 里:

csv 复制代码
GUEST_001,3,点击初始化,click,#initSDK,,,,
GUEST_001,6,点击游客登录,click,#testGuestLogin,,,,

这两种方式都能用。

但维护性不够好。


8.2 用元素别名代替选择器

我更希望步骤里写的是:

csv 复制代码
GUEST_001,3,点击初始化,click,init_button,,,,
GUEST_001,6,点击游客登录,click,guest_login_button,,,,

而真实选择器放在 elements.yaml

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"

这样测试步骤就更接近业务语义。

text 复制代码
click init_button

比:

text 复制代码
click #initSDK

更容易理解。


8.3 elements.yaml 的好处

第一,步骤更可读。

测试用例里看到 guest_login_button,基本就知道这是游客登录按钮。

第二,选择器变更影响更小。

如果初始化按钮从:

text 复制代码
#initSDK

改成:

text 复制代码
button[data-testid="init-sdk"]

只需要改 YAML:

yaml 复制代码
init_button: 'button[data-testid="init-sdk"]'

不用改所有测试步骤。

第三,后续可以按页面或弹窗分组。

比如:

yaml 复制代码
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 本身频繁变化,还是需要更稳定的定位策略。

比如优先使用:

text 复制代码
稳定 ID
data-testid
data-test
aria role
明确文本
业务唯一属性

如果条件允许,最好和前端约定测试专用属性。

比如:

html 复制代码
<button data-testid="guest-login-button">游客登录</button>

这样自动化会比依赖复杂 CSS 路径更稳定。


九、四个文件如何串起来

这四个文件不是孤立的。

它们通过 ID 和别名串起来。

整体执行链路可以理解成这样:

text 复制代码
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 里有:

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

框架读取后知道:

text 复制代码
要执行 GUEST_001
它属于 guest_login 模块
它使用 device_a
它是新状态启动
执行后可以保存 device_a_state.json

然后去 devices.csvdevice_a

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 ...",1366,768,zh-CN,Asia/Shanghai,1,false,false

框架用这些参数创建浏览器 context。

接着去 case_steps.csvGUEST_001 的所有步骤:

csv 复制代码
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 找:

yaml 复制代码
channel_id_input: "#channelIdInput"

然后执行:

python 复制代码
page.locator("#channelIdInput")

这样四个文件就串起来了。


9.2 以 GUEST_002 为例

GUEST_002 是老设备再次登录。

它在 cases.csv 里可以这样写:

csv 复制代码
GUEST_002,guest_login,老设备再次游客登录,true,GUEST_001,device_a,load,device_a_state.json

这行的关键点是:

text 复制代码
depends_on = GUEST_001
device_id = device_a
state_mode = load
state_file = device_a_state.json

含义是:

text 复制代码
先确保 GUEST_001 已经执行过
复用 device_a
加载 device_a_state.json
再次游客登录
提取 guest_a_again
断言 guest_a_again == guest_a

这时框架不再靠脚本顺序猜测,而是根据 cases.csv 中的依赖关系执行。


9.3 这就是数据模型真正发挥作用的地方

单独看四个文件,好像只是拆配置。

但它们串起来之后,就能表达完整业务链路:

text 复制代码
用什么设备
跑哪条用例
是否依赖前置用例
是否加载浏览器状态
每一步做什么动作
操作哪个元素
等待哪个接口
断言哪个字段
保存哪个变量
最终如何判定成功或失败

这就是我设计 CSV + YAML 数据模型的目的。


十、第一版为什么不要设计得太复杂

第一版的数据模型,我刻意设计得比较克制。

它没有一开始就做:

text 复制代码
复杂条件表达式
复杂 JSONPath 语法
多环境配置中心
完整用例管理平台
大量业务关键字
复杂循环和分支
分布式执行

这是有意为之。


10.1 第一版先证明真实链路能跑通

第一版最重要的目标不是把所有未来能力一次性设计完。

而是先证明:

text 复制代码
这套模型能表达一条真实的 H5 SDK 回归链路。

游客登录这条链路已经覆盖了很多关键能力:

text 复制代码
设备差异
新老状态
用例依赖
页面操作
网络请求断言
变量提取
跨用例变量比较
状态保存和加载
失败后继续执行

如果这条链路能稳定跑通,后面扩展邮箱登录、第三方登录、埋点、支付,就有了基础。


10.2 过度设计会拖慢落地

如果第一版一开始就设计得太重,可能会出现几个问题:

text 复制代码
配置变难写
框架变难调试
字段越来越多
还没跑通核心链路,就陷入设计细节

自动化框架最怕一开始就做成"大平台"。

结果是:

text 复制代码
目录很多
概念很多
配置很多
但真实业务链路还跑不稳

所以第一版应该先小而稳。

先让游客登录这条链路跑通。

再根据真实扩展需求,逐步加能力。


十一、这套 CSV + YAML 模型的边界

这套设计不是万能的。

它只是第一版 H5 SDK 自动化框架的数据模型。

它有自己的适用边界。


11.1 CSV 不适合复杂分支

CSV 很适合表达线性步骤。

比如:

text 复制代码
打开页面
点击按钮
等待接口
提取变量
断言变量

但如果后续出现大量:

text 复制代码
if / else
循环
动态重试
复杂条件判断
多路径分支

就不应该硬塞进 CSV。

更合适的方式是:

text 复制代码
把稳定业务流程封装成关键字
CSV 调用更高层动作
复杂逻辑留在 Python 框架里

比如:

csv 复制代码
GUEST_001,5,执行游客登录流程,guest_login_flow,,,,guest_result,4,false

这个 guest_login_flow 可以在 Python 里封装一组稳定动作。


11.2 YAML 不能解决所有页面不稳定问题

elements.yaml 可以集中管理选择器。

但如果页面 DOM 本身很不稳定,YAML 也救不了。

这时需要从定位策略上治理:

text 复制代码
尽量不用长 CSS 路径
尽量不用容易变化的层级结构
优先使用稳定 ID
优先使用 data-testid
必要时和前端约定测试属性

自动化稳定性不只是测试代码问题,也和页面可测试性有关。


11.3 变量池不能无限扩张

变量池适合跨步骤、跨用例传递运行时数据。

但如果变量越来越多,说明用例之间的耦合可能越来越严重。

比如:

text 复制代码
A 用例依赖 B 的变量
B 用例依赖 C 的变量
C 用例又依赖 D 的状态

这种链路会越来越难维护。

所以变量池应该用于明确、必要的业务链路,而不是让所有用例都共享一堆全局变量。


11.4 storage_state 要注意安全

Playwright 的 storage_state 很适合模拟老设备或已登录状态。

但状态文件里可能包含:

text 复制代码
cookies
localStorage
登录态 token
用户标识
业务缓存

所以这类文件不要随便提交到远程仓库。

可以考虑:

text 复制代码
加入 .gitignore
只在本地或 CI 临时生成
必要时定期清理
敏感环境不要复用真实账号状态

十二、小结

第一版框架的数据模型,本质上是在拆分变化。

我没有把所有配置塞进一个大 CSV,而是拆成四个文件:

text 复制代码
devices.csv
cases.csv
case_steps.csv
elements.yaml

它们分别解决不同问题:

text 复制代码
devices.csv      -> 用什么设备跑
cases.csv        -> 有哪些用例,以及它们之间是什么关系
case_steps.csv   -> 每条用例具体怎么执行
elements.yaml    -> 页面元素怎么定位

这四个文件共同完成了一件事:

把原来写死在 Python 脚本里的测试行为,拆成可以被维护、review、复用和扩展的数据。

这也是数据驱动自动化测试最关键的价值。

到这里,框架已经有了"剧本"。

但只有剧本还不够。

下一篇我会继续讲:

这些 CSV / YAML 配置是怎么被真正执行起来的?

从一行 CSV 到一次浏览器操作,关键字驱动执行引擎到底应该怎么设计?

相关推荐
xhbh6661 小时前
Linux端口转发到外网完全教程:iptables DNAT+SNAT实现内网服务暴露
linux·运维·服务器·网络·ip·流量转发·端口流量转发
Q_4582838681 小时前
基于 JTT1078MediaServer 的集群方案实践(Nginx + 溯源模式)轻量级车联网音视频集群
运维·服务器·nginx·架构·音视频·交通物流
承渊政道1 小时前
数据删了不等于销毁:KingbaseES敏感数据物理擦除实战指南
运维·服务器·数据库·数据仓库·安全·oracle·业界资讯
精益数智小屋1 小时前
什么是进销存库存表?进销存库存表包含哪些内容?
大数据·运维·数据库·人工智能·安全
米高梅狮子1 小时前
14.K8s 中部署 LNMP 架构 ECShop 电商
云原生·容器·架构·kubernetes·自动化
sbjdhjd1 小时前
Docker 安全优化实战手册(企业级硬核版)
linux·运维·docker·云原生·容器·eureka·kubernetes
爱吃苹果的梨叔1 小时前
2026年清虹分布式坐席系统如何破局技术内卷与运维成本困局
运维·分布式
终端行者1 小时前
Jenkins Pipeline 构建后推送到Nexus制品库 jenkins 如何连接Nexus?企业级实战 --中 Jenkins 连接Nexus 实战
运维·ci/cd·docker·jenkins·nexus
张小姐的猫1 小时前
【Linux】多线程(中)—— 线程控制接口 | 线程库 | 线程局部存储
linux·运维·服务器