每次产品提一嘴需求UI 改几个样式测试同学就要陪着熬夜改脚本——干过 Web 自动化测试的人应该都懂这种痛。过去大半年我一直在折腾一套内部代号叫 Mirage Flow 的工具核心思路一句话把页面改写成一段可读的 UI 描述它自动生成对应的 Selenium 或 Playwright 自动化测试脚本。听起来像是“测试代码不用手写了”实操下来大部分常见页面确实可以直接出能跑通的代码。这篇我会把 Mirage Flow 从设计思路、描述格式、映射规则到双框架生成代码的实现细节完整拆开讲也会把我在内部分享时帮别人避开的坑一并列出来。适合正在搭自动化测试体系、被脚本维护逼到头大的测试开发也适合想了解 UI 描述驱动测试代码生成这个思路的前端和 QA 同学。1. 自动化测试脚本维护的痛和 UI 描述这个解法1.1 脚本为什么越写越脆弱刚接触自动化测试那会儿我也和大多数人一样拿到一个页面就按 F12 复制 XPath拼出一段“能跑就行”的脚本。等用例攒到几百条问题就全冒出来了。最头疼的是定位器太脆弱。开发把按钮的 CSS 类名从btn-login改成btn-primary脚本直接红灯某个输入框的 placeholder 文案从“请输入手机号”变成“请输入手机号码”整条用例就崩。类似的问题还有页面加了异步加载脚本还在等固定 sleep接口超时报错测试用例跟着误报开发重构了 DOM 层级一层层写死的 XPath 全部失效。我曾经统计过一个小版本迭代里脚本维护时间能占到总工作量的 60% 以上真正写新功能的时间反而没多少。更深一层的问题是协作成本。业务同事、新来的同学想看懂自动化脚本发现全是driver.find_element(By.XPATH, //div[3]/div[1]/div[2]/button)这种天书。他们提一句“登录按钮现在换个位置了”你都得在全量用例里搜一遍有多少地方写死了这个按钮的路径。时间一长自动化测试变成了少数人的私产别人既不敢动也不敢提。这就有点像装修时把所有开关位置都记在脑子里地图和图纸都不画——家里一旦调整家具布局你只能摸黑挨个试。UI 描述驱动生成脚本的思路本质上就是先画一张“平面图”让每个控件有自己稳定的身份再让施工队按图去干活。页面改了改图比扒墙皮快得多。1.2 为什么要把“页面描述”和“测试代码”拆开Mirage Flow 最早想解决的问题其实很简单能不能让测试代码不直接依赖某一条具体的 XPath而是依赖“这里有一个密码输入框”这类语义信息因为产品、UI、测试在评审时讨论的从来不是#id_023而是“登录页有个密码框”。于是我们把人要管的层次和机器要管的层次分开。测试人员维护一份 UI 描述描述里写清楚页面有什么元素、元素叫什么、有哪些动作和断言生成器拿到描述之后再根据目标框架的能力去选择定位策略、生成等待代码、拼装脚本骨架。也就是说描述只回答“这个页面长什么样、我要做什么”代码负责回答“具体怎么定位、怎么等待、怎么断言”。这个拆分带来的好处在很多真实场景里都被验证过页面改版时我们通常只要更新描述文件里的字段重新生成脚本不用把所有用例翻一遍新增一个同类页面时复制一份描述改几个字段整个用例就有了业务同学参与评审描述文件比让他们看WebDriverWait代码接地气得多同一个描述可以同时生成 Selenium 和 Playwright 两套代码切换技术栈时不必推倒重来。当然这套方案也不是万能的。页面结构极其复杂、控件完全没有稳定属性、交互逻辑非常特殊的时候纯靠描述生成反而比手写更绕。所以 Mirage Flow 的定位从来不是“取代手写测试代码”而是把那些重复的、模式化的 Web 操作从手写变成生成让人把精力留在真正需要判断的地方。2. Mirage Flow 的核心设计思路与方案选型2.1 整体架构到底分了哪几层Mirage Flow 整体拆成四层这也是我后来给团队做分享时最常用的分层方式。第一层是 UI 描述层。我们选用 YAML 作为描述文件的载体原因后面细说。这层只关心“页面有哪些元素、元素类型是什么、有哪些动作和断言”完全不涉及任何定位器和框架 API。第二层是解析与校验层。生成器会读取描述文件先做 Schema 校验字段缺失、类型错误、引用不存在的元素都在这一层提前暴露。校验通过后把 YAML 归一化成内部统一的 Python 字典结构后续模板只要消费这一份结构即可。第三层是代码生成层。这里用了 Jinja2 模板引擎加一组针对元素类型的映射策略。生成器根据目标框架Selenium 或 Playwright选择不同的模板集模板里只负责拼装代码结构具体的定位器计算和等待策略由策略模块提供。第四层是执行与反馈层。生成出来的脚本可以直接交给 pytest 执行失败时会截图、抓取页面源码、输出日志并和 Allure 报告做集成。执行反馈又反过来告诉我们哪些元素描述偏向不稳定可以在下次生成时主动提示。为什么要把这四层拆得这么干净因为在实际迭代中四层的变化频率完全不同。描述格式相对稳定映射策略会随项目演进不断补充模板要跟随两个框架的 API 版本更新执行层则要根据团队基础设施调整。如果把这四层揉在一个脚本里任何一层小改动都会引发连锁问题后来者根本不敢动。2.2 为什么同时支持 Selenium 和 Playwright而不是二选一立项讨论时团队内部也吵过一轮Selenium 生态成熟、团队多少都会一点Playwright 自动等待和调试体验好、是趋势。最后结论是都要支持但理由不是站队而是现实需求。我先用一张表把两个框架在我们项目里的对比列出来维度SeleniumPlaywright生态成熟度高历史资料和社区案例多相对较新团队学习成本稍高自动等待需要手动写显式等待或 Polling内置 Actionability 检查默认行为友好多 Tab / 多窗口需要切换窗口句柄代码略繁琐提供多页面上下文API 更直观iframe / Shadow DOMiframe 要显式 switchShadow DOM 要额外处理frame_locator 与 Shadow DOM 支持更顺滑网络拦截与 Mock需要额外工具原生支持 route 拦截遗留系统兼容对老浏览器和复杂环境适应性更好对现代 Chromium/Firefox/WebKit 更友好存量脚本迁移成本低很多团队已有大量 Selenium 用例高我们真实的业务里存量项目一大半是 Selenium 脚本这些脚本背后还有一堆自定义工具函数、测试数据准备逻辑、内部框架封装。如果要求全部迁移到 Playwright光迁移和验证成本就够呛。而新立项的项目我们希望直接用 Playwright享受自动等待和多浏览器支持。所以 Mirage Flow 的设计原则是描述层保持中立生成层通过适配器输出两种代码。对使用的人来说同一个描述文件既能生成旧项目的 Selenium 回归脚本也能生成新项目的 Playwright 用例。你不需要想清楚“未来用什么框架”先把页面描述沉淀下来框架迁移只是一个重新生成的动作。2.3 从 UI 描述到定位器的映射策略描述层里写的是语义信息生成代码时必须翻译成真正的定位器。这一块是 Mirage Flow 最核心的难点也是最需要反复打磨的部分。我们给元素定位设计了一个优先级策略id优先其次是>page: 登录页 url: /login elements: - name: 用户名输入框 type: input placeholder: 请输入用户名 - name: 密码输入框 type: input placeholder: 请输入密码 secure: true - name: 登录按钮 type: button text: 登录 actions: - name: 输入用户名 element: 用户名输入框 action: fill data: ${username} - name: 输入密码 element: 密码输入框 action: fill data: ${password} - name: 点击登录 element: 登录按钮 action: click assertions: - name: 校验登录成功 element: 欢迎信息 type: visible这个文件的优点在于没有一行代码但任何懂业务的人都能看懂我们在测什么。elements定义页面元素actions定义操作序列assertions定义结果校验。描述文件里最需要注意的是命名一致性。你在elements里给元素起的name是全局唯一标识后面的actions和assertions都通过name引用它。我见过不少同事把同一个输入框在元素定义里叫“手机号输入框”在动作里写成“输入手机号”结果生成器匹配不上报错信息还提示得不够直接。后来我们在 Schema 校验里强行要求引用任何元素前必须先保证该元素确实存在于elements列表否则解析直接失败。3.2 元素类型识别与定位器生成的核心实现比较傻的做法是在模板里写满if type button这种硬编码但一旦控件类型变多模板会变成一个巨大的分支地狱。Mirage Flow 里我们抽了一个locator_strategy.py每种元素类型对应一个策略类由策略类负责计算定位表达式。以生成 CSS 选择器为例核心逻辑大约是这样def build_css_locator(element: dict) - str: # 优先级id >from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC driver webdriver.Chrome() driver.maximize_window() try: driver.get(http://localhost:8080/login) username_input WebDriverWait(driver, 10).until( EC.presence_of_element_located( (By.CSS_SELECTOR, [placeholder*请输入用户名]) ) ) username_input.send_keys(test01) password_input WebDriverWait(driver, 10).until( EC.presence_of_element_located( (By.CSS_SELECTOR, [placeholder*请输入密码]) ) ) password_input.send_keys(abc123) login_button WebDriverWait(driver, 10).until( EC.element_to_be_clickable( (By.XPATH, //button[contains(text(),登录)]) ) ) login_button.click() WebDriverWait(driver, 10).until( EC.visibility_of_element_located( (By.XPATH, //*[text()欢迎回来]) ) ) finally: driver.quit()生成代码时我特别要求把WebDriverWait的显式等待写进去而不是让框架替我们偷懒用time.sleep。原因很简单Selenium 没有内建 Actionability 检查presence_of_element_located只代表元素出现了不代表能被点击。如果生成器偷懒生成的脚本八成在真实环境里跑不稳。所以对可点击元素我们一律生成element_to_be_clickable对需要输入的控件至少生成presence_of_element_located保证元素被渲染出来。生成器还处理了一些“看起来不起眼但很要命”的细节finally里必须driver.quit()防止并发跑用例时进程不释放关键操作后如果描述里开启了截图自动插入截图代码最后把异常信息带出方便 pytest 捕获。3.4 生成 Playwright 代码时差异点比想象中大刚开始我以为 Playwright 代码就是把find_element换成locator而已实际上手才发现两个框架的生成逻辑差异远比 API 名称差异大。Playwright 最大的不同是自带 Actionability 检查click()方法会自动等待元素可见、稳定、可点击。这带来一个直接后果生成代码里不需要堆WebDriverWait。但我们必须理解它的等待语义否则生成的代码可能用错等待方式。一个典型的 Playwright 生成结果from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch() page browser.new_page() page.goto(http://localhost:8080/login) page.fill([placeholder*请输入用户名], test01) page.fill([placeholder*请输入密码], abc123) page.click(button:has-text(登录)) page.wait_for_selector(text欢迎回来) browser.close()两套代码放在一起看差异就非常直观Selenium 版本需要手动等待、手动选择显式条件生成器要替用户做很多决策Playwright 版本自带等待生成器只需要保证定位器合理代码看起来简洁得多Playwright 的多 Tab 和 iframe 处理方式不同生成器生成的代码结构也不同。我们曾经给一个涉及内部系统 iframe 的页面生成 Playwright 代码生成器直接输出frame_locator(iframe[titlemain]).locator(...)这比 Selenium 里switch_to.frame再定位的代码可读性好太多。双框架生成让我学到一个教训不要试图在一套抽象里掩盖框架的差异因为这样生成的代码必然是你妥协后的“最小公分母”。Mirage Flow 的做法是让描述层完全相同但生成模板按框架能力各自发挥。Playwright 模板可以用它的自动等待优势Selenium 模板就老老实实生成显式等待两者生成的代码都符合各自社区的最佳实践。3.5 数据驱动和断言怎么生成到脚本里自动化测试不可能只有一组数据。登录页至少要测正确密码和错误密码搜索结果页要测不同关键词。所以描述文件里的${username}、${password}这类变量必须能透传给测试框架。我们最终选择让生成器输出 pytest 参数化入口import pytest from yourgen_module.login_page import run_login pytest.mark.parametrize(username,password,expect_success, [ (test01, abc123, True), (test02, wrong, False), ]) def test_login(username, password, expect_success): run_login(usernameusername, passwordpassword, expect_successexpect_success)这样设计的好处是数据和业务逻辑完全分离。测试数据可以放在 JSON/Excel 里再由一个很小的加载逻辑喂给参数化装饰器业务脚本则由生成器维护描述文件更新后重新生成即可。断言部分也是一样。描述文件里写了assertions生成器会翻译成对应框架的断言代码。我们支持了最常见的几类元素可见、元素文本等于、元素数量等于、元素属性包含、当前 URL 包含。每类断言在不同框架里写法不同所以这块也是双模板的重点。不要小看断言描述的重要性它其实是把测试意图沉淀下来比只记录操作路径更有价值。4. 常见问题与排查技巧实录4.1 定位器生成出来却还是不稳定问题出在哪Mirage Flow 上线第一个月被问得最多的就是“为什么我根据描述生成了选择器跑两次还是挂了”。我排查了一圈发现绝大多数情况不是生成器的问题而是页面里存在多个满足条件的节点。举例来说页面里有三个按钮文本都包含“提交”描述里写type: button, text: 提交生成器用:has-text(提交)或contains(text(),提交)定位时匹配到多个节点。Selenium 会直接抛ElementNotInteractableException或StaleElementReferenceExceptionPlaywright 的strict mode会直接报错提示“locator resolved to 3 elements”。这个我后来在描述规范里加了一条规则如果元素文本模糊请务必补充位置信息比如index: 0或者写一个更精确的父级描述。另一个常见坑是页面里存在不可见元素。比如开发用隐藏的input做状态存储placeholder 恰好又和真实输入框类似。这时候显式等待能查到元素但send_keys却报错。我们在生成 Selenium 代码时特意对fill动作增加visibility_of_element_located而不是presence_of_element_located能减少一部分误报。排查这类问题我推荐一个土办法把生成的 locator 直接粘到浏览器 DevTools 的 Console 里执行document.querySelectorAll(...)数数匹配了几个节点。凡是能立刻看出匹配数量不对基本都能在生成阶段发现问题不用反复跑用例试错。4.2 iframe 和 Shadow DOM描述层怎么表达现在的系统里嵌套 iframe、Shadow DOM 已经很常见。早期描述文件里没有这个概念生成的脚本遇到内部系统页面就必挂。后来我们在元素描述里增加了两个可选字段- name: 内部操作按钮 type: button text: 确定 frame: iframe[titlemain] shadow: true生成器遇到frame字段时Selenium 侧会生成切 frame 再定位的代码Playwright 侧则生成frame_locator(...).locator(...)。遇到shadow字段时处理方式又不一样Playwright 默认可以穿透 open shadow root而 Selenium 需要先取shadowRoot再往里查。这些差异统统被封装在模板层对描述作者来说只是多写两个字段。这里有个值得强调的点如果不确定元素到底在不在 iframe 里可以先跑一次带--debug的生成模式工具会输出候选定位器在页面上的命中情况。比起在脚本里加断点这个阶段排查成本低得多。4.3 等待策略固定 sleep 是最毒的用法我见过太多现有自动化项目里全是time.sleep(5)。刚开始脚本能跑等页面变慢或者接口变慢sleep 不够就挂为了解决挂有人把 sleep 加到 10 秒、15 秒于是整个用例集跑到要 40 分钟。这种代码不能算自动化测试只能叫定时器表演。Mirage Flow 生成的代码里我们默认不输出任何固定 sleep。Selenium 侧用WebDriverWait配合具体条件Playwright 侧直接依赖自动等待能力。唯一允许出现page.wait_for_timeout(500)的场景是那些页面里有动画过渡、且动画结束后元素才可交互的罕见情况而且我们会在描述里显式标注delay: 500让使用者意识到这里有一个不经意的等待。如果用例跑下来还是不稳定第一步永远不是加 sleep而是打开浏览器的 Network 面板看请求到底卡在哪。是后端 5 秒才返回还是前端 JS 渲染慢了定位到原因后或者调整接口 Mock或者改用更合理的等待条件。把“等待“这件事交给广播式轮询而不是固定假设页面一定在 N 秒内加载完稳定性会完全不一样。4.4 和 CI 集成时最容易踩的三个坑生成出来的脚本最终要跑在流水线里和本地跑完全是两种体验。最先踩的坑是测试环境地址写死在描述文件里。我们在描述文件里用${base_url}这种变量占位CI 里通过环境变量注入这样同一套描述可以随便切换 dev / staging / prod 环境。第二个坑是失败证据收集。CI 里脚本挂了人不在现场连截图都看不到。我们在 pytest 的conftest.py里加了 fixture每个用例失败时自动截图并附加到 Allure 报告同时抓取当前页面 URL 和页面源码。这一步对排查定位器失效特别有用因为很多时候光看报错信息根本不知道页面当时长什么样。第三个坑是浏览器并发和资源占用。早期我们在 CI 里同时起十个 Chrome 进程跑不同用例经常把节点内存打满出现一堆莫名其妙的白屏和超时。后来做了两件事一是控制并行度二是给每个任务固定临时目录避免多个浏览器抢同一份用户数据目录。看起来很基础但确实救了我们很多次。5. 一些实际体会和扩展方向Mirage Flow 做到现在最有价值的不是它生成了多少行代码而是逼着我们把“页面怎么测”这件事用结构化方式沉淀下来。以前讨论一条用例是否合理大家得先翻代码现在直接看描述文件一条条核对元素和断言效率高很多。我个人的经验是先覆盖高频控件不要妄想一步到位。按钮、输入框、下拉、单选、文本断言这几类能覆盖日常 80% 的页面交互。表格、弹窗、拖拽、复杂动画这类特殊场景宁可让它落地在描述里留手工扩展口也不要让模板无限变复杂。模板越花哨后期维护越痛苦。后续我打算做两件事。一是把描述文件做得更“宽松”允许自然语言描述加参数配置混合使用比如“点击搜索按钮”结合type: button一起解析让非测试同学也能写描述。二是把定位器稳定性统计接回来每次执行后记录哪些 locator 匹配多个节点、哪些页面元素经常变化让生成器根据历史执行数据调整策略优先级。这些都是基于现有描述层之上的增量不用推翻架构这也是当初分层设计最值得的一笔投资。如果让我重新来一遍我会在立项时就把描述文件的 Schema 定得更严一些字段穷举清楚而不是边用边加。现在虽然做了向后兼容处理但早期那些“摸着石头过河”留下的模糊字段依然会在自动生成时造成语义歧义。自动化工具最怕的不是功能少而是规则不明确。把规则写清楚把例外留给人判断这套思路放到任何自动化项目里都适用。