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

摘要

前面三篇文章讲的是框架出现之前的过程。

我先用 Playwright 脚本验证了多设备游客登录,又用另一个脚本验证了网络请求捕获与断言。后来发现,如果继续复制脚本,设备参数、页面元素、接口断言、变量提取和用例依赖都会越来越难维护。

所以第一版框架的第一步,不是马上写复杂执行引擎,而是先设计测试数据应该怎么描述。

这篇文章讲的就是:为什么我把配置拆成 devices.csvcases.csvcase_steps.csvelements.yaml

问题不是用 CSV 还是 YAML,而是变化在哪里

做数据模型之前,我先问的是:

哪些东西经常变?
哪些东西应该让测试同学维护?
哪些东西不应该写死在 Python 框架里?

H5 SDK 自动化里,变化点很多。

设备会变。

今天测 Windows Chrome,明天可能要测 iPhone Safari,后面还可能测不同语言、时区、分辨率和浏览器启动方式。

用例会变。

游客登录只是开始,后面还有初始化、邮箱登录、Google 登录、Facebook 登录、Apple 登录、埋点、支付。

步骤会变。

不同流程的页面操作、等待条件、接口断言、变量提取都不同。

页面元素会变。

测试页上的按钮 ID、输入框选择器、状态区域,都可能随着页面调整而变化。

如果这些都写在 Python 里,每次变化都要改代码。

所以我把第一版配置拆成四份:

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
用例名称
设备参数
页面地址
按钮选择器
接口路径
断言字段
预期值

文件看起来少了,但维护会很快变差。

第一,重复太多。

同一个设备可能被很多用例复用。如果每个步骤都重复写 user agent、viewport、locale,CSV 会很快膨胀。

第二,修改风险大。

如果 #testGuestLogin 改成了 #guestLoginButton,我不希望在几十行步骤里逐个改。

第三,层级混乱。

设备参数、用例信息、步骤动作、页面选择器不是同一类信息。强行放在一个表里,短期省文件,长期难读。

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

我更关心每个文件的职责是否清楚。

devices.csv:描述浏览器和设备环境

第一个文件是 devices.csv

它解决的问题是:

同一套测试步骤,要在什么浏览器 / 设备环境下运行?

从脚本阶段看,设备参数原来可能是这样:

context = browser.new_context(
    user_agent="Mozilla/5.0 ... Chrome/147.0.0.0 Safari/537.36",
    viewport={"width": 1366, "height": 768},
    locale="zh-CN",
    timezone_id="Asia/Shanghai",
    device_scale_factor=1,
    is_mobile=False,
    has_touch=False,
)

这些参数不是框架逻辑,而是测试数据。

所以我把它们放到 CSV:

device_id,device_name,user_agent,viewport_width,viewport_height,locale,timezone_id,device_scale_factor,is_mobile,has_touch,launch_mode
device_a,Windows Chrome,"Mozilla/5.0 ... Chrome/147.0.0.0 Safari/537.36",1366,768,zh-CN,Asia/Shanghai,1,false,false,isolated
device_b,iPhone Safari,"Mozilla/5.0 ... Mobile/15E148 Safari/604.1",390,844,zh-CN,Asia/Tokyo,3,true,true,isolated

框架读取后,会变成 DeviceConfig

@dataclass(frozen=True)
class DeviceConfig:
    device_id: str
    device_name: str
    user_agent: str
    viewport_width: int
    viewport_height: int
    locale: str
    timezone_id: str
    device_scale_factor: float
    is_mobile: bool
    has_touch: bool
    launch_mode: str = "isolated"

后面创建浏览器 context 时,再转换成 Playwright 参数:

def context_options(self) -> dict:
    options = {}
    options["user_agent"] = self.user_agent
    options["viewport"] = {
        "width": self.viewport_width,
        "height": self.viewport_height,
    }
    options["locale"] = self.locale
    options["timezone_id"] = self.timezone_id
    options["device_scale_factor"] = self.device_scale_factor
    options["is_mobile"] = self.is_mobile
    options["has_touch"] = self.has_touch
    return options

这样新增设备时,优先改 CSV,而不是改 Python。

cases.csv:描述用例是什么

第二个文件是 cases.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=83b8...
GUEST_002,guest_login,same device keeps guest,false,GUEST_001,device_a,load,device_a_state.json,channel_id=83b8...
GUEST_003,guest_login,different device creates different guest,false,GUEST_001,device_b,new,device_b_state.json,channel_id=83b8...

这里已经能表达游客登录的业务关系:

GUEST_001:新设备创建游客。
GUEST_002:依赖 GUEST_001,加载同一状态,验证还是同一个游客。
GUEST_003:依赖 GUEST_001,换设备,验证不是同一个游客。

对应的数据对象是:

@dataclass(frozen=True)
class CaseConfig:
    case_id: str
    module: str
    case_name: str
    enabled: bool
    depends_on: tuple[str, ...]
    device_id: str
    state_mode: str
    state_file: Optional[str]
    case_vars: Optional[str] = None

这一步的关键不是字段多不多,而是把“用例级信息”从步骤里拆出来。

比如 device_id 属于整条用例,不应该每个步骤重复写。

depends_on 也是用例关系,不应该藏在 Python 脚本顺序里。

case_steps.csv:描述每条用例怎么执行

第三个文件是 case_steps.csv

它是整个数据模型里最像“剧本”的部分。

一行就是一个步骤:

case_id,step_id,step_name,action,target,value,expect,save_as,depends_on_step,continue_on_fail
GUEST_001,1,open test page,goto,,https://sdk-test.uggamer.com/h5/test-sdk.html,,,,true
GUEST_001,2,fill channel id,fill,channel_id_input,${channel_id},,,1,true
GUEST_001,3,click init,click,init_button,,,,2,true
GUEST_001,5,wait init api,wait_request,,/test-api/client/init,status=200;response.code=200,init_response,3,true

这些字段的含义是:

case_id           -> 属于哪条用例
step_id           -> 第几步
step_name         -> 步骤名称,方便日志阅读
action            -> 做什么动作
target            -> 操作目标,比如元素别名或变量名
value             -> 输入值,比如 URL、接口路径、填入文本
expect            -> 预期,比如文本、接口断言表达式
save_as           -> 把结果保存成哪个变量
depends_on_step   -> 当前步骤依赖哪些步骤
continue_on_fail  -> 失败后是否继续

对应的数据对象是:

@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

这个设计的核心是 action

它让 CSV 可以描述动作语义:

goto
fill
click
wait_text
wait_request
extract
assert_var
save_state

下一篇第 5 篇会专门讲:这些 action 是怎么被 StepRunner 翻译成真实浏览器操作的。

这一篇先记住一点:case_steps.csv 不是随便堆字段,它是在描述“用例执行剧本”。

elements.yaml:把页面选择器集中管理

第四个文件是 elements.yaml

它解决的问题是页面元素定位。

如果直接在 CSV 里写:

click,#initSDK

短期没问题。

但如果页面选择器改了,就要去所有 CSV 步骤里找。

所以我更希望 CSV 写业务别名:

click,init_button

真正的选择器放在 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"

这样有几个好处:

CSV 更接近业务流程。
页面选择器集中维护。
元素命名可以统一。
页面改版时影响范围更小。

后面执行 click,init_button 时,框架会先从 elements.yaml 找到:

init_button -> #initSDK

再调用 Playwright 点击。

为什么 CSV 和 YAML 混用

我没有把所有配置都做成 CSV,也没有全部做成 YAML。

原因是它们适合表达的东西不一样。

CSV 适合表格型数据:

设备列表
用例列表
步骤列表

这些数据天然是一行一条记录。

YAML 更适合层级映射:

页面分组
元素别名
选择器

比如:

sdk_test_page:
  init_button: "#initSDK"

比 CSV 更自然。

所以这里不是为了技术偏好混用,而是让数据格式匹配数据形状。

csv_loader 把文件变成对象

配置文件只是第一步。

Python 框架还要把它们读进来。

framework/config/csv_loader.py 里有三个加载函数:

def load_devices(path: Path = DATA_DIR / "devices.csv") -> dict[str, DeviceConfig]:
    ...

def load_cases(path: Path = DATA_DIR / "cases.csv") -> dict[str, CaseConfig]:
    ...

def load_steps(path: Path = DATA_DIR / "case_steps.csv") -> dict[str, list[StepConfig]]:
    ...

以步骤加载为例:

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 字符串转换成更明确的配置对象。

后面的执行器就不用直接处理原始 CSV,而是处理 CaseConfigDeviceConfigStepConfig

这也是数据模型的意义:不是为了多几个类,而是让框架里的每层逻辑都有清楚输入。

这套数据模型的边界

第一版数据模型不是万能的。

它适合描述线性的、步骤化的自动化流程。

比如:

打开页面
填写参数
点击按钮
等待接口
提取字段
断言变量
保存状态

但如果出现非常复杂的逻辑,比如大量循环、动态分支、复杂数据构造,就不应该硬塞进 CSV。

这时更好的做法是:

把复杂逻辑沉淀成新的关键字。
CSV 只负责调用这个关键字。

也就是说,CSV 不应该变成另一种编程语言。

它应该描述测试意图。

Python 框架负责实现执行细节。

小结

这一篇要记住四个文件:

devices.csv      -> 设备和浏览器环境
cases.csv        -> 用例元信息、依赖、状态、变量
case_steps.csv   -> 步骤剧本
elements.yaml    -> 页面元素别名和选择器

它们共同完成了一件事:

把经常变化的测试信息,从 Python 脚本里移到数据文件里。

这就是数据驱动的第一步。

但数据文件本身不会执行测试。

CSV 里写了一行 click,浏览器为什么真的会点按钮?

CSV 里写了一行 wait_request,框架为什么真的能等接口并断言?

下一篇就进入执行层,讲关键字驱动执行引擎是怎么把一行 CSV 变成一次真实浏览器操作的。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐