ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

无框架数据驱动UI自动化:用CSV和动作表实现最简方案

无框架数据驱动UI自动化:用CSV和动作表实现最简方案 聊到 WebDriver 做数据驱动 UI 自动化很多人第一反应是上 TestNG、上 JUnit、搞 DataProvider或者干脆自研一套平台。我在多个项目里试过各种路子最后的结论恰恰相反——最稳定的方案往往是不用任何测试框架用最原始的循环去驱动数据。搜索数据驱动能看到不少同名不同义的东西比如 MCGS 组态软件的串口收发数据驱动安装步骤还有移动端 Maestro UI 自动化那一套其实跟我们要说的事不是一回事。搞 Web UI 自动化的人讲数据驱动核心就一句把数据从代码里彻底拿出来脚本只负责照着数据去执行。这篇文章就把我在实际项目中跑通的那套无框架数据驱动完整拆开用什么数据格式、怎么设计动作表、代码写多短、坑在哪里。它特别适合三种人刚接触 UI 自动化、被各种框架概念劝退的新人手里有一两个小项目想快速跑回归的测试开发以及想给团队搭一套维护成本足够低的数据驱动模板的同学。1. 先把数据驱动这个词掰扯清楚1.1 数据驱动在 UI 自动化里的准确定义数据驱动的原始含义其实特别朴素同一个测试脚本由不同的输入数据来驱动它执行一次跑一条数据跑完得出这条数据对应的结果。它不是让数据自己动而是让脚本变成一个没有灵魂的执行器——灵魂全部放在数据文件里。我用一个最典型的登录场景来说明。如果你把脚本写死通常长这样driver.find_element(By.ID, username).send_keys(admin) driver.find_element(By.ID, password).send_keys(123456) driver.find_element(By.ID, loginBtn).click()测完 admin 这组数据你想再测一组密码错误的情况怎么办复制粘贴改掉 value。想再测十组账号密码就得复制十遍。这种脚本最大的问题是改数据必须改代码而改代码就必须懂代码、走代码评审、重新部署。数据驱动要解决的就是把这个耦合彻底切开。数据驱动之后同样的登录用例变成这样TC001,open,,https://example.com/login,, TC001,input,idusername,admin,, TC001,input,idpassword,123456,, TC001,click,idloginBtn,, TC001,assert_text,iduserInfo,admin,代码里不再出现admin和123456这两个具体数据只有读取一行数据→执行对应动作→读下一行这个循环。新增用例只需要在 CSV 里加几行代码一个字符都不用改。这就是数据驱动的本质脚本与数据分离数据变化不触达代码。1.2 别把参数化、关键字驱动和数据驱动混成一锅我在面试和带人的时候发现很多人把概念混在一起。这个必须掰清楚否则后面设计容易变形。参数化只是把脚本里的硬编码值抽成变量通常还是代码控制流程参数只是填空。关键字驱动把点击输入断言这类操作也抽象成关键字数据文件里不仅写数据还写做什么动作。我们这篇文章讲的方法本质上就是关键字驱动和数据驱动的杂交体。数据驱动测试侧重一脚本多数据重点在数据组织方式。行为驱动开发偏向用自然语言描述业务场景比如 Cucumber 的 Given/When/Then那又是另一套体系。我为什么要区分这些因为最简单、无框架的方案里最核心的设计决定了它必须兼顾数据和动作两层。只有数据、没有动作描述的话脚本里还是得写 if-else 判断每一条数据怎么操作那耦合又回来了。所以这篇文章的做法是把每行数据设计成一个动作 动作参数让脚本完全变成机械执行。1.3 为什么无框架依然能做数据驱动很多人默认数据驱动是框架才有的能力这是最容易误导新人的地方。数据驱动需要的技术基础只有三个能读取外部数据、能循环遍历、能按动作分发执行。这三样东西Python 自带Selenium 自带不需要任何测试框架。测试框架真正提供的是用例管理、并发调度、报告生成、失败重试这些外围能力而不是数据驱动的内核。所以严格来说你用一个 for 循环加一个 if 字典就已经是数据驱动了。既然内核这么简单为什么非要用 TestNG 那套注解体系把它包起来这是我从实践里反复验证过的结论只要你的场景没有触及框架外围能力的刚需无框架就是最合理的选择。2. 为什么说无框架反而更适合多数项目2.1 测试框架给数据驱动带来了什么麻烦不是我故意唱反调TestNG 和 JUnit 的 DataProvider 确实能做数据驱动但它们解决问题的同时引入了一串连锁负担。你用 TestNG 做数据驱动至少要过这几关用 Maven 或 Gradle 初始化工程引入 selenium、testng、可能还要 poi 处理 Excel在 testng.xml 里配置 suite理解 DataProvider、Test(dataProvider ...) 的写法然后还得处理框架的生命周期——比如某些用例想共用浏览器会话某些用例想重置状态你得研究 beforeMethod、afterClass 这些注解的优先级。这些成本对一个小项目来说比数据驱动本身还大。我见过很多团队光是把 TestNG 的依赖和配置理顺就花了一周最后写出来的用例不到五十条。你想要的明明是快速回归结果陷入了框架的学习曲线。对比一下无框架的数据驱动只需要 pip install selenium再加两个 Python 文件当天就能跑起来。这不是否定测试框架而是说——如果你的主要诉求是数据驱动框架不是必需品反而是额外的维护负担。2.2 自研框架九成是自找麻烦比直接用测试框架更离谱的是自研一套 UI 自动化框架。常见配置是 BasePage、PageFactory、ConfigReader、ReportListener、DBUtils、Logger听起来很专业实际上大部分是给自己挖坑。自研框架的问题在于框架本身的 bug 比业务用例的 bug 还难查。页面元素定位失败你排查半天最后发现是框架的等待逻辑写错了新增一种页面组件你得先改框架的抽象层写框架的人一走剩下的人没人敢动整个自动化直接停摆。我在实践里最深的体会是UI 自动化的维护成本大头永远在页面元素和操作流程上不在数据怎么读取、报告怎么生成上。你把资源花在框架层恰恰是花错了地方。无框架不是一种偷懒而是一种资源聚焦把编程精力放在业务本身。2.3 无框架方案的适用边界和移动端参考当然无框架不是万能的。我在评估一个项目该不该用这套方案时会快速过一遍这五个问题用例量级是不是在一千条以内是否不需要复杂的分布式并发页面和需求的变更频率是不是比较高团队里负责维护用例的人是否以手工测试为主是否需要在一个月内见到可运行的自动化成果如果五个里面有三个以上是是那无框架数据驱动就是最优解。反过来如果系统极其复杂、用例上千且需要并行、用例之间有复杂依赖关系那再去引入测试框架甚至平台那是合理的不算打脸。移动端那边也出现了很有意思的参照Maestro UI 自动化工具它的用例就是 YAML 文件把启动应用、点击、输入、断言全部写进流程文件执行器统一解析。这不就是数据驱动思想的另一种实现吗它同样没有要求你背一套测试框架。说明数据与执行分离、用数据文件驱动执行器这个大方向在 Web 端和移动端都是被反复验证过的做法。3. 核心设计数据文件与动作表是关键3.1 数据载体选型CSV 凭什么排在第一位很多文章上来就推荐 Excel理由是业务同事熟悉 Excel。我的观点相反除非你们的用例维护者是纯业务人员且必须用 Excel 提交否则我首选 CSV。原因很简单——CSV 是纯文本Python 内置 csv 模块直接读不需要 POI 或 openpyxl也不需要处理单元格格式、合并单元格、隐藏 sheet 这些幺蛾子。我做了一张选型对照表你可以参考数据格式优势劣势我的结论CSV记事本和 Excel 都能编辑Python 零依赖读取diff 起来清晰多层嵌套结构表达困难含逗号时需转义单层扁平操作流首选Excel非技术同事友好可以加颜色、批注必须引第三方库格式错乱难排查git diff 难除非团队强依赖 Excel否则不选JSON层级清晰程序处理灵活手工编辑容易漏逗号非技术同事维护困难有嵌套结构时用YAML阅读性好Maestro 等新工具都用它缩进敏感新手容易翻车团队熟悉后可用我的最终建议是步骤本身就是扁平的动作序列完全不需要层级CSV 是最合适的。如果哪天真出现多级依赖再换成 YAML 也不迟。3.2 字段设计的每一个到底为什么这么定下面是我打磨过很多版才定下来的字段结构每个字段都有它存在的理由字段示例作用case_idTC_LOGIN_001用例唯一标识用于日志归类和失败定位step_no1步骤序号保证执行顺序不依赖文件行的物理顺序actionopen / input / click动作关键字脚本依赖它做分发targetidusername / xpath//...元素定位信息前缀声明定位方式valueadmin / https://...动作的输入参数open 是 URLinput 是文本expect密码错误期望结果仅断言类动作使用remark打开登录页备注说明方便非技术成员理解为什么 target 要用idxxx这种带前缀的写法因为一个系统里元素定位方式五花八门有 id、有 name、有 css selector、有 xpath。如果脚本里写死一种用例就写不活。带前缀之后定位方式成了数据的一部分遇到什么元素就写什么定位代码里只需要做一个映射解析。为什么 expect 单独一列而不是塞进 value因为断言是比较型动作value 是输入型参数两者语义完全不同。混在一起代码里还得猜这一行到底要做什么那就违背了数据自解释的原则。3.3 动作表把能做的事固定成字典级清单动作表是整个方案的心脏。我建议把动作固定在一个小而稳的集合里不要让它无限膨胀。我项目里最常用的动作就七个动作作用target 用法value 用法open打开 URL留空目标网址input输入文本元素定位输入内容click点击元素元素定位留空或延时select下拉框选择元素定位选项可见文本sleep强制等待留空秒数assert_text校验元素文本元素定位期望文本screenshot截图留空图片文件名你可能会问就这几个动作够用吗我的经验是绝大多数 Web 后台系统的日常回归八成以上操作就是打开、输入、点击、校验这四板斧。剩下的像上传文件、拖拽、iframe 切换我倾向于在代码里单独写专用函数而不是为了全面把动作表塞成几十个关键字。动作越多维护面越大每新增一个动作都要测试它对各种数据组合的兼容性。小而稳才是无框架方案的生存之道。还有一个设计原则要强调操作放代码里数据放表格里两者各司其职。操作流程本身需要编程逻辑的地方比如等待、异常处理留在代码里具体业务数据丢进文件里。过度设计的人喜欢把操作也全部搬进数据文件做成完全配置化我做过的项目告诉我那样做只会让表格膨胀到没人愿意打开。4. 从零开始完整代码实现4.1 环境准备代码就依赖一个东西Selenium。Python 3.8 以上执行pip install selenium浏览器直接用 Chrome。Selenium 4.6 以上自带 Selenium Manager会自动匹配并下载对应的 ChromeDriver不需要手动去搞浏览器驱动版本这一块算是框架替我兜底了。如果你想完全脱离网络下载的干扰也可以自己放一个 chromedriver 到 PATH 里两种方式都不影响下面代码。4.2 三个文件职责一次拆清我习惯把代码拆成三个文件避免所有逻辑堆在一个文件里打架actions.py动作分发表每个动作一个函数。runner.py主执行流程读数据、循环、跑动作、收集结果。cases.csv数据文件所有用例都在这。先说actions.py。这里最关键的是locate函数和ACTIONS字典。locate负责把idxxxxpath//xxx这样的 target 字符串解析成真正的 WebElementACTIONS字典负责把动作关键字映射到对应函数。import time from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait, Select from selenium.webdriver.support import expected_conditions as EC BY_MAP { id: By.ID, name: By.NAME, css: By.CSS_SELECTOR, xpath: By.XPATH, class: By.CLASS_NAME, link: By.LINK_TEXT, } def locate(driver, target, timeout10): method, _, expr target.partition() if method not in BY_MAP: raise ValueError(f不支持的定位方式: {method}) return WebDriverWait(driver, timeout).until( EC.presence_of_element_located((BY_MAP[method], expr)) ) def action_open(driver, row): driver.get(row[value]) driver.maximize_window() def action_input(driver, row): el locate(driver, row[target]) el.clear() el.send_keys(row[value]) def action_click(driver, row): locate(driver, row[target]).click() def action_select(driver, row): Select(locate(driver, row[target])).select_by_visible_text(row[value]) def action_sleep(driver, row): time.sleep(float(row[value])) def action_assert_text(driver, row): actual locate(driver, row[target]).text.strip() assert actual row[expect], f文本断言失败: 期望[{row[expect]}] 实际[{actual}] def action_screenshot(driver, row): driver.save_screenshot(row[value]) ACTIONS { open: action_open, input: action_input, click: action_click, select: action_select, sleep: action_sleep, assert_text: action_assert_text, screenshot: action_screenshot, } def execute(driver, row): action row[action] if action not in ACTIONS: raise ValueError(f未注册的动作: {action}) ACTIONS[action](driver, row)代码里有个容易被忽略的细节action_assert_text里我对实际文本做了.strip()。页面上的文本经常带前后空格、换行不清理的话CSV 里的期望文本和页面实际文本很容易因为一个看不见的换行判失败。这种问题不是逻辑错误纯属脏数据但会浪费你大量排查时间。再说runner.py。它的任务是把 CSV 按case_id分组逐条逐步骤执行单条用例失败不中断整体最后输出汇总。import csv import sys from collections import OrderedDict from selenium import webdriver from actions import execute def load_cases(file_path): with open(file_path, moder, encodingutf-8-sig) as f: reader csv.DictReader(f) return [dict(r) for r in reader if r.get(action, ).strip()] def group_cases(rows): grouped OrderedDict() for row in rows: grouped.setdefault(row[case_id], []).append(row) return grouped def run_case(driver, steps): driver.delete_all_cookies() logs [] for row in steps: try: execute(driver, row) logs.append((row[step_no], PASS, )) except Exception as e: logs.append((row[step_no], FAIL, str(e))) break return logs def main(): path sys.argv[1] if len(sys.argv) 1 else cases.csv driver webdriver.Chrome() summary [] try: for case_id, steps in group_cases(load_cases(path)).items(): logs run_case(driver, steps) case_ok all(status PASS for _, status, _ in logs) summary.append((case_id, case_ok)) print(f[ {PASS if case_ok else FAIL} ] {case_id}) for step_no, status, msg in logs: if status FAIL: print(f step {step_no} 失败: {msg}) finally: driver.quit() passed sum(1 for _, ok in summary if ok) print(f结果汇总: 共 {len(summary)} 条用例, 通过 {passed}, 失败 {len(summary) - passed}) if __name__ __main__: main()这里有几个刻意设计的地方。第一encodingutf-8-sig不是utf-8。CSV 文件只要用 Excel 编辑保存过大概率带 BOM 头用普通 utf-8 读会把第一列字段名读成\ufeffcase_id。这个坑我踩过不止一次后来干脆统一用 utf-8-sig一劳永逸。第二driver.delete_all_cookies()放在每条用例开头。登录用例跑完浏览器里留着上一个用户的登录态如果不清理下一条用例可能以一种完全错误的初始状态执行而且失败原因极难定位。这个习惯让每条用例尽可能独立。第三单条用例内部失败时break但外层 for 循环继续跑下一条用例。UI 自动化最忌讳因为一条用例挂了就中断整个回归那样什么都测不出来。失败信息记录了行号和异常文本足够定位问题。4.3 主执行流程 30 秒说清楚执行的时候命令行直接python runner.py cases.csv流程一句话概括runner.py读取 CSV → 按 case_id 分组 → 每条用例按 step_no 逐行执行 → 动作交给actions.py的字典分发表 → 执行结果记录 → 汇总打印。整个链路没有框架介入没有配置文件没有注解就是一个简单到不能再简单的解释器。我把这个三文件结构用了很久最大的感受是任何人接手都能在半天内看懂全部逻辑。相比那些动辄十个 package 的框架项目这种透明能省下无数沟通成本。5. 实战案例登录 新增商品一条龙5.1 数据文件长什么样空讲设计没意思我直接用一个典型的后台管理系统场景登录、校验登录成功、错误密码提示、新增商品后校验列表展示。这是后台回归里非常高频的组合。下面是cases.csv的内容示例case_id,step_no,action,target,value,expect,remark TC_LOGIN_001,1,open,,https://demo.example.com/login,,打开登录页 TC_LOGIN_001,2,input,idusername,admin,,输入账号 TC_LOGIN_001,3,input,idpassword,123456,,输入密码 TC_LOGIN_001,4,click,idloginBtn,,,点击登录按钮 TC_LOGIN_001,5,assert_text,iduserInfo,admin,校验已登录 TC_LOGIN_002,1,open,,https://demo.example.com/login,,打开登录页 TC_LOGIN_002,2,input,idusername,admin,,输入账号 TC_LOGIN_002,3,input,idpassword,wrong123,,输入错误密码 TC_LOGIN_002,4,click,idloginBtn,,,点击登录按钮 TC_LOGIN_002,5,assert_text,classerror-tip,用户名或密码错误,校验错误提示 TC_LOGIN_002,6,screenshot,,fail_login_002.png,,失败留痕 TC_ADD_001,1,open,,https://demo.example.com/login,,打开登录页 TC_ADD_001,2,input,idusername,admin,,输入账号 TC_ADD_001,3,input,idpassword,123456,,输入密码 TC_ADD_001,4,click,idloginBtn,,,点击登录按钮 TC_ADD_001,5,click,xpath//aside//span[text()商品管理],,进入商品模块 TC_ADD_001,6,click,xpath//button[contains(., 新增)],,点击新增按钮 TC_ADD_001,7,input,namegoodsName,自动化测试商品A,,输入商品名 TC_ADD_001,8,input,namegoodsPrice,99.90,,输入价格 TC_ADD_001,9,click,xpath//form//button[contains(., 保存)],,提交保存 TC_ADD_001,10,assert_text,xpath//table//td[contains(text(),自动化测试商品A)],自动化测试商品A,校验列表出现新商品注意看TC_LOGIN_002的错误提示校验和截图。我在设计表格时故意让失败用例也具备完整的留痕能力——断言挂掉后自动截图这就是后面排查的第一手证据。没有这一步失败后你得重新手动复现效率低到怀疑人生。5.2 执行效果与日志解读跑完python runner.py cases.csv控制台输出大概长这样[ PASS ] TC_LOGIN_001 [ PASS ] TC_LOGIN_002 [ FAIL ] TC_ADD_001 step 6 失败: Message: no such element: Unable to locate element: {method:xpath,selector://button[contains(., 新增)]} 结果汇总: 共 3 条用例, 通过 2, 失败 1看到这个日志第一件事就是查 TC_ADD_001 的第 6 步。//button[contains(., 新增)]找不到元素十有八九是页面结构里新增不是纯文本而是包在某个 span 里或者页面还没渲染完成按钮还不可见。我当时实际遇到的情况是商品管理页的新增按钮是图标按钮文案藏在i标签的 title 属性里纯文本匹配根本找不到。这种问题在框架体系里也得这么定位不是无框架方案的锅反而是因为日志足够干净一眼就能揪出是哪一步挂的。5.3 我把页面改崩了之后怎么复盘这次执行失败后我的排查步骤是先看第 6 步的定位表达式再到浏览器开发者工具里手工跑一遍这个 xpath确认能定位到接着临时把定位改成xpath//button[title新增商品]更新 CSV 后重新跑用例就过了。整个过程没有改一行代码纯粹是数据文件里换了个定位表达式。这就是数据驱动的红利——元素定位这种高频变化的东西被下沉到了数据层业务脚本不需要跟着动。后面我再遇到页面结构调整第一反应永远是改 target 列而不是打开 runner.py 改代码。6. 常见问题速查与避坑指南6.1 高频问题对照表这套方案跑久了踩过的坑基本集中在下面这些点我整理成了速查表问题现象常见原因解决办法CSV 读取后第一个字段名多出乱码Excel 保存后带 BOM 头读取用 utf-8-sigvalue 里含英文逗号导致列错位CSV 语法问题用双引号包裹字段或改用 YAML元素能找到但点不动元素被遮挡或未完全渲染locate 改用 visibility_of_element_located打开 URL 后页面加载慢导致定位失败异步渲染把 sleep 改为显式等待或调大 locate 的 timeout断言文本因换行空格失败页面文本带空白字符Ctrl点击/断言时 strip()一条用例失败后后面全不跑代码里没有异常隔离单条用例内部 break外层继续ChromeDriver 版本不兼容浏览器自动更新用 Selenium Manager 或固定浏览器版本新增用例后结果汇总不对CSV 空行/重复 case_idload_cases 过滤空 action检查分组这里我要特别说一下元素能找到但点不动。很多人在这一步开始怀疑无框架方案不行其实这是 WebDriver 的经典问题元素已经存在但被 loading 遮罩或者其他浮层挡住。我在locate里用的是presence_of_element_located它只保证元素出现在 DOM 里不保证可交互。如果你频繁遇到这种问题把这一行换成WebDriverWait(driver, timeout).until( EC.visibility_of_element_located((BY_MAP[method], expr)) )用可见性替换存在性大部分遮挡问题就消失了。这个改动只涉及actions.py里一个函数但能让整体稳定性上一个台阶。6.2 数据文件维护的独家技巧数据驱动跑得顺不顺很多时候不取决于代码而取决于表格维护得干不干净。我总结了几条自己的规矩每行数据只做一件事不要写打开页面并上传文件并断言这种复合句子。target 列的定位方式前缀永远写全id、xpath不要省略否则解析器只能猜。用例里涉及多个相似元素时优先用带语义的 id 或>
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进