把测试用例变成“可执行文档”,这个概念我琢磨了小半年,起因特别朴素。那是迭代到一半,开发改了登录流程,把“点击登录按钮”的文案换成了“安全登录”,测试这边的用例文档还停在旧版本。测试同学照着文档跑了半小时,跑完发现全废,回头骂骂咧咧改Excel。更要命的是,开发压根不知道有这份文档,你拿着文档去找他,他反问“什么用例?你测了啥?”。
问题的根源不是大家不写文档,而是文档和代码是两套系统。文档动不起来,代码看不懂意图,两边一旦对不上,文档就成了没人信的废纸。后来我换了个思路:把测试用例本身做成“可执行文档”——用例就是代码,代码就是文档,跑起来是自动化测试,打开来是给开发看的业务描述。技术栈我选了Playwright,这条路线在团队里跑通之后,开发开始主动来问“这个场景的用例怎么跑”,这是以前写两年Excel用例都没发生过的事。
这篇文章聊聊我具体是怎么改造的。适合正在被测试用例编写、管理、复用和维护折腾的测试同学,也适合想引入UI自动化、但不想把用例写成一堆“只有自己能懂”的脚本的团队。
1. 传统测试用例文档的困境:不是写得不够,是“动不起来”
1.1 开发为什么不看测试用例文档
传统用例的形态大家都熟:编号、模块、前置条件、操作步骤、预期结果,再配一列优先级。这种文档在需求评审阶段挺管用,一旦进入持续迭代,就变成了静态资产。开发不爱看,我观察下来有三个核心原因。
第一,阅读成本高。一份两百条用例的Excel,开发真想看完,基本等于把整个业务逻辑在脑子里重放一遍,他手头还有一堆代码要看,真没这个精力。第二,描述容易失真。用例写的是“点击登录按钮”,页面重构后按钮文案变成了“登录”,文档没人同步,用例本身就在误导人。第三,也是最关键的——文档里的用例“动不起来”。开发看完一条用例,不能马上知道这个步骤在当前代码里能不能走通,文档不会告诉他,必须等测试真正执行完才知道。
这三点叠在一起,测试用例在开发眼里就不是资产,而是负担。你问他“登录校验失败的用例看了吗”,他潜意识里想的是“又来了,一堆Excel谁看得完”。
1.2 文档与代码两张皮的代价
我在几个不同项目组待过,复杂迭代里的典型场景是这样的:A组改了登录逻辑,B组的用例文档里还引用着旧行为;C组的自动化脚本跑挂了,debug半天发现是文档里的预期结果和产品经理最新确认的不一致。问题全出在“两张皮”——业务描述是一套系统,自动化脚本是另一套系统,维护的人不同,更新节奏也不同。
测试用例文档还有个隐性成本:它跟代码之间没有强制绑定关系,改代码的人不知道要去同步文档,写文档的人不知道代码已经变了。哪怕团队约定“每次需求变更都要更新用例”,在赶进度的时候,这种约定通常是第一个被牺牲的。
所以我把目标定成了“一张皮”:用代码承载用例,用命名、注释和报告让代码变成文档。每条用例既是一个能独立运行的测试,又是一段能被人读懂的业务故事。这就是“可执行文档”这个说法的由来——它不只是一个漂亮的比喻,而是我实际的工作方式。
这里要强调一下,我不是让团队抛弃所有文档。需求背景、验收标准、需求单号这类信息,依然保留,只是换了一种存放方式——放在用例的annotation和代码注释里,让“代码可以引用”,而不是躺在共享盘里没人看。
2. 重新设计用例的形态:从“步骤清单”到“三层可执行文档”
2.1 三层拆分:行为层、实现层、数据层
动手写代码之前,我先重新回答了“一条测试用例到底包含什么”这个问题。过去是一条条操作步骤,现在我把用例拆成三层:
- 行为层(面向人):用例标题、业务背景描述、需求编号、关联缺陷、验收断言。这一层解决的是“这个用例在验证什么业务”的问题。
- 实现层(面向机器):一步步可执行的Playwright操作,比如点击、输入、断言。这一层解决的是“怎么在页面上复现这个业务”的问题。
- 数据层(面向环境):测试账号、URL、业务参数、超时时间。这一层解决的是“换个环境、换个项目组还能不能用”的问题。
这个拆分可以拿菜谱来类比。菜名是行为层,告诉客人这道菜是什么;烹饪步骤是实现层,告诉厨师怎么把菜做出来;食材清单是数据层,决定了这道菜在不同季节、不同市场能不能凑齐原料。一道菜菜名取得再优雅,食材清单错了,厨师也没法动手。
过去写测试用例,大家习惯把这三层混在一起。比如“打开登录页面(https://xxx),输入账号(admin),输入密码,点击登录,断言出现xxx”,看着挺顺,细想全是问题:账号写死在步骤里,换个环境就要改;点击哪个按钮用了CSS selector,开发根本不想看;断言结果写得模棱两可,页面显示“用户名或密码错误”和“账号不存在”到底哪个算通过也不清楚。
分层之后,行为层保证可读性,实现层保证可执行性,数据层保证可维护性。三者各管一摊,缺一不可。
2.2 为什么选Playwright作为执行引擎
确定目标后,选型几乎是顺理成章的。我当时对比了三个主流方案,纠结了大概两天。
| 能力 | Selenium | Cypress | Playwright |
|---|---|---|---|
| 自动等待 | 需要手动处理 | 内置,但事件循环限制多 | 内置web-first断言,自动重试 |
| 失败现场还原 | 手动截图/日志 | 截图+视频 | Trace Viewer完整回放 |
| 多浏览器支持 | 需要自己配置driver | 主要面向Chromium系 | 原生支持Chromium/Firefox/WebKit |
| 多标签页/多用户 | 支持但不稳定 | 限制明显 | 原生支持context隔离 |
| 测试报告 | 需要额外集成 | 一般 | 内置HTML报告,自带annotation和步骤树 |
| 调试体验 | 一般 | 较好 | codegen+Trace,调试效率最高 |
对我来说,决定性因素有两个。
第一个是web-first断言。比如expect(locator).toBeVisible(),Playwright会自己等待元素可见再判断,不用我在代码里塞一堆sleep。以前用Selenium,最烦的就是元素加载慢导致的随机失败,现在这个基础问题直接从框架层面解决了,我的用例里几乎看不到“等待三秒”这种代码。
第二个是Trace Viewer。用例失败以后可以把整个操作过程回放出来,每一步的DOM快照、网络请求、控制台日志都在。开发不用脑补现场,直接看回放就知道问题在哪。我后面会专门讲这个,因为它是“让开发看得懂”的关键一环。
另外,Playwright的用例组织方式——test.describe、test.step、annotations——很自然地对应我上面说的行为层。写出来的代码可读性非常强,这一点我在下一章展开。
3. 落地第一个“可执行文档”:Playwright项目结构与书写规范
3.1 目录结构:按业务模块组织用例
我建议第一版项目结构保持简洁,按业务模块组织即可。别一上来就搞复杂的多包架构,那会让开发更不敢碰。
tests/ login/ login.spec.ts # 登录相关用例,就是登录模块的“文档” register.spec.ts order/ create-order.spec.ts # 下单相关用例 common/ fixtures.ts # 公共fixture、登录态等 pages/ login-page.ts # Page Object,页面元素与操作封装 order-page.ts data/ users.json # 测试账号等数据 env.json # 环境配置 playwright.config.ts # Playwright配置文件这里有个关键决策:spec文件本身要“自解释”。项目组其他人点开login.spec.ts,应该能在五分钟内看懂这条用例在测什么、数据从哪来、失败去哪看,而不是去猜文件里的page对象做了什么。为此我宁可让spec里多几行描述性代码,也不搞那种“所有步骤都封装进方法、spec里一行注释都没有”的极简风格。
3.2 用例怎么写才像“文档”
直接看一段我实际在用的登录用例,这是“可执行文档”最直观的样子:
import { test, expect } from '@playwright/test'; import { LoginPage } from '../pages/login-page'; import users from '../data/users.json'; // 行为层:这个用例要验证的业务目标 test.describe('登录模块:账号密码登录', () => { test('未注册手机号登录时,页面提示“账号不存在”', async ({ page }) => { // 业务背景:见需求单REQ-2024-001,登录共通化改造后新增的校验分支 test.info().annotations.push( { type: '需求单', description: 'REQ-2024-001' }, { type: '前置条件', description: '用户未注册,且当前处于登录页' } ); const loginPage = new LoginPage(page); await loginPage.goto(); // 实现层:一步步操作,每一行都对应业务动作 await test.step('输入未注册手机号', async () => { await loginPage.fillPhone(users.unregisteredPhone); }); await test.step('输入密码并点击登录', async () => { await loginPage.fillPassword('Test@123456'); await loginPage.clickLogin(); }); // 断言:业务目标对应的验收结果 await test.step('页面提示账号不存在', async () => { await expect(loginPage.errorTip).toBeVisible(); await expect(loginPage.errorTip).toHaveText('账号不存在'); }); }); });你发现没有,这份用例从头到尾没有出现“为什么要等三秒”“这个按钮的class是什么”这类噪音。test.step起的名字都是业务动作,开发在报告里看到的是“输入未注册手机号→输入密码并点击登录→页面提示账号不存在”这样一条完整的故事线。这就是“可执行文档”和“纯自动化脚本”的分水岭。
有一点要提醒:不是所有用例都值得做成这个粒度。登录、下单、支付这种核心流程,值得。偶尔冒烟的边界场景,可以合并成一条用例里的多个断言,别让用例库膨胀到没人维护。
3.3 annotation与附件:让报告自带上下文
再补充两个让用例真正“文档化”的细节。
第一个是annotation。我习惯在每条用例里带上需求单号、缺陷链接、前置条件。这样测试报告本身就是一个可追溯的文档,出了问题,开发在报告里点一下就能跳到需求单,不用再到处问“这条用例是谁写的、对应哪个需求”。
代码里的写法就是上面案例的test.info().annotations.push那一行。这是Playwright官方支持的能力,报告里会渲染成清晰的键值对。这个功能我以前用TestRail管理用例时也做过,但报告和用例代码分离,维护起来很割裂。现在直接写进用例里,顺手得多。
第二个是attachment。Playwright允许在失败时自动附加现场信息。我在fixture里配置了失败自动截图和网络请求快照,但真正让开发觉得“这东西很懂我”的是Trace。配上trace后,失败用例的报告里有一段完整的操作回放,跟录屏很像,但比录屏多了每一步的DOM快照和网络日志。开发可以自己点开看,前后拉一拉,问题定位就完成了一大半。
4. 让开发“愿意跑”:命令行、报告与调试体验
4.1 开发最需要的三个操作
“能看懂”是第一步,第二步是“能跑起来”。我观察开发同学的诉求,其实就三个:只跑一条用例、只看失败的用例、失败之后能快速定位。我把对应的命令整理好,写进项目README,开发头一回打开就能用:
# 只跑一条用例:用标题里的关键字过滤 npx playwright test --grep "账号不存在" # 只看失败的用例 npx playwright test --last-failed # 失败时自动记录trace,方便回放 npx playwright test --trace on这个--grep功能是我最推荐的。它允许直接用用例标题里的业务关键词来筛选,开发想验证某个bug修复没修复,敲一条命令就够了。我在package.json里还配了几个别名脚本,比如npm run test:login对应只跑登录模块,npm run test:smoke对应跑冒烟回归,把常用场景再简化一步。
操作步骤降低之后,开发使用门槛一下子就下来了。我见过一个开发,他第一次跑用例,把命令复制粘贴进去,跑完看到绿色的passed,跟我说“哦,这比我想象的简单多了”。
4.2 报告如何“说人话”
Playwright的HTML报告,默认就把每条用例的操作步骤展开成一棵树,树上的节点就是test.step的名字。开发打开报告,看到的不是一堆selector和断言代码,而是“打开登录页→输入账号→点击登录→校验提示”这种业务流程。更重要的是报告中还能展示annotation(需求单号、前置条件)、失败时的截图、网络请求列表和trace回放入口。
有一次开发私聊我说,他以前判断UI自动化报告靠猜,现在靠看。报告本身就是文档——这句话我印象很深。
报告还有一个细节:可以在playwright.config.ts里配置自定义报告。我们团队用HTML Report就够,但如果你有多项目组协同,可以考虑把报告导出成统一目录,接进内部展示页面。不过第一版别做太重,先让报告“能看”,再谈“好看”。
4.3 从“你测了什么”到“我能自己看”
这里要分享一个心态上的转变。以前我总想着“用例要写得让开发看懂”,其实还不彻底,因为开发始终是被动接受信息的一方。可执行文档真正起作用的时刻,是开发自己敲下第一条命令、自己打开报告、自己看完trace回放的那个瞬间。他不再是“听你汇报测试结果”,而是“自己去看业务行为是否正常”。
为了这个目标,我会刻意在用例评审时拉上开发一起过一遍spec代码。不需要逐行讲语法,只需把test.describe和test.step的名字读一遍,他们就能理解用例的意图。有时候开发还会主动指出“你这条用例的前置条件和最新需求不一致”,这就是协作效率提升的实锤。
这也带来一个隐含要求:用例描述必须准确。如果开发照着报告里的步骤去复现,发现和实际页面行为对不上,那这份“文档”就失信了。所以我会把用例的验证标准写得非常具体,不能用“页面正常展示”这种模糊断言,要多用toBeVisible、toHaveText这种明确的web-first断言——既保证稳定性,也保证“文档”的可信度。
5. 复杂迭代下的复用与维护:把用例当产品管理
5.1 复杂迭代中用例为什么容易烂
把用例做成“可执行文档”之后,新问题来了:项目组多、业务交织、需求变化频繁,用例库一段时间不整理就开始腐烂。典型症状是:某条用例依赖了另一条前置用例的结果,某条用例的数据写死在脚本里,某条用例的操作步骤被业务改得面目全非但没有人同步更新。
我后来想明白一件事:用例不是一次性写出来就完事的东西,它跟产品功能一样需要持续维护。所以我把“用例管理”变成了“用例产品管理”,定了三条规矩,每条都是踩坑踩出来的。
5.2 参数化与数据驱动:一份用例跑多套环境
第一条规矩是数据必须外部化。测试账号、URL、业务参数,一律从json或环境变量读取,不能写死在spec里。举例,我们有两个环境,测试环境地址是https://test.internal.example,预发布地址是https://staging.internal.example。我在data/env.json里维护:
{ "test": { "baseURL": "https://test.internal.example", "adminAccount": "admin_test@example.com" }, "staging": { "baseURL": "https://staging.internal.example", "adminAccount": "admin_staging@example.com" } }对应在playwright.config.ts里通过环境变量决定用哪份配置。这样项目组之间共享同一套用例,环境不一样就换一个配置,不需要把用例复制N份。
我做过一个很傻的事:曾经把预发布环境的账号写死在用例里,结果测试环境跑挂了。排查半天,发现是账号在测试环境没有权限。后来改成从env.json读取,类似问题再没出现过。这个例子也侧面说明,数据层抽离不是锦上添花,是避免事故的必要手段。
另外建议在config里加一个校验:如果环境变量没设,直接报错,提醒配置者检查,避免有人漏配置导致脚本在错误环境上乱跑。我就被这种“看起来好像跑了,实际跑错地方”的情况坑过一次。
5.3 标签体系与回归策略
第二条规矩是标签要规范。Playwright支持给test.describe或test挂tag,比如@login、@smoke、@p0。这条看起来很基础,但在多项目组协同的时候特别好用。我用一套固定的标签体系:
- @模块名:比如@login、@order、@payment,标识业务模块
- @优先级:@p0(核心流程)、@p1(重要)、@p2(一般)
- @回归级别:@smoke(冒烟,每次发版都跑)、@full(全量回归)
然后CI里按需要过滤,比如冒烟只跑@smoke,全量回归跑@full。开发本地也可以只跑自己改动模块的用例,比如只跑@login。这个标签体系说白了就是给用例做索引,让“可执行文档”在几十个spec文件里依然能被快速定位。
实际操作中,我会在用例库里专门建一个tag说明文档,写清楚每种tag的含义和使用场景。不然一个月之后,你自己也会忘了@p2和@p3的区别是什么。
5.4 公共逻辑抽取与用例独立性
第三条规矩是页面操作封装到Page Object,但用例逻辑不要过度抽象。我的做法是:页面元素和单步操作封装成方法,比如输入手机号、点击登录这种动作放Page里;但用例里的业务顺序、断言标准必须留在spec里,让每条用例自包含、可独立运行。
用一句话概括:封装动作,不封装业务。动作封装是为了让用例好读,业务不封装是为了让用例清晰、可追踪。如果一个操作被多个用例共用,我倾向于做一个fixture作为公共前置,而不是在用例之间互相调用。
这样每条用例都是独立的“文档章节”,互不依赖,跑到哪条失败就知道哪条出问题,排查成本最低。我踩过的坑是,曾经图省事在用例A里调用了用例B的登录步骤,结果B先挂了,A也跟着失败,排查的人花了一个小时才发现根因在B。后来所有共用步骤都放进fixture,再没出过这种连环爆炸。
6. 下一步:AI辅助生成“可执行文档”的探索
6.1 为什么想到用Agent来做这件事
细节维护多了以后,我开始想一个问题:可执行文档能不能由机器自动生成?现在我在试LLM Agent方向,比如“基于langchain开发一个能读取测试用例自动生成UI自动化测试脚本的agent”。这个思路其实和我做的“可执行文档”天然契合——可执行文档本身就是人和机器都能读的中间产物。
我不认为AI能一步到位地生成稳定可跑的测试脚本。一个没有上下文、没有页面结构信息的AI,生成的代码大概率是“看起来对、跑起来废”的。但AI绝对可以作为“翻译引擎”,把业务描述翻译成Playwright骨架代码,再由测试工程师补充数据和断言细节。
我随手写过一个小Demo,思路是让AI读取Markdown格式的用例描述,输出一个spec文件骨架。效果是:骨架的框架代码基本能用,但断言覆盖和业务注解需要人补充。这个结果符合预期,定向工具比通用对话更能减少返工。
6.2 我尝试的路线:让LLM读取结构化用例,产出Playwright脚本
我给LLM的准备材料很简单:一份结构化的用例描述(步骤+期望结果),加一些项目里已有的Page Object命名。Prompt里的关键约束是“不要实现Page对象内部细节,只生成spec层的用例骨架”,这样生成结果的可控性会好很多。
下面是一个简化的Prompt骨架:
你是一名资深UI自动化测试工程师,使用Playwright和TypeScript。 请根据下面的业务用例,生成一个test.describe块。 要求: 1. 用例标题用业务目标描述,不用操作细节 2. 每个操作步骤用test.step包裹,step名称用业务语言 3. 页面操作一律调用已有的Page Object方法,不要直接写selector 4. 断言使用web-first断言,例如toBeVisible、toHaveText 业务用例: 【模块】登录 【前置】用户已注册且密码正确 【步骤】打开登录页 → 输入已注册手机号 → 输入密码 → 点击登录 【期望】跳转到首页,右上角显示用户昵称生成的骨架我再手动补充数据文件和Page Object调用。实践下来,初稿能节省50%左右的框架代码时间,但“每条用例的断言是否覆盖到位”“业务背景注解是否准确”这类问题,依然需要人把关。所以我把这个能力定位成“AI辅助起草”,而不是“AI全自动维护”。可执行文档的价值不在于省掉人,而在于让人把精力放到真正的业务验证上。
6.3 边界与建议:先别急着全自动
如果你想尝试这条路线,我的建议是:先把现有测试用例库里最核心的几十条用例规范成“可执行文档”,跑通维护流程,再引入AI辅助。原因很简单,AI生成脚本很像让一个实习生照着手册干活,手册本身清晰,实习生才能干对;手册乱糟糟,AI只会把乱糟糟放大。
另外,AI生成的脚本有一条硬性门槛:必须走完整的review流程,至少要有人跑通一遍、确认断言有效,才能进入主用例库。这一步可以靠playwright test运行结果来把关,但最终的用户反馈和业务匹配度,还得靠人来判断。
说回最初的问题,把测试用例变成“可执行文档”,最值钱的不是自动化覆盖率上去了多少,而是协作模式变了。以前开发和测试之间靠“文档+截图”沟通,现在靠“可以直接跑的用例+自带现场的报告”沟通。我自己的体会是,改造的前两周最痛苦,因为要把旧用例按新规范一点点重写,但从第三周开始,收益就上来了——开发开始主动跑用例,产品也愿意在需求评审时翻测试报告,测试用例本身变成了团队共同维护的资产。
如果你想开始,别贪多,挑一条核心业务流程,按上面的样式写三个用例,跑通报告和命令行的体验,再逐步铺开。等开发第一次自己用--grep跑出一条用例、然后在报告里看到完整trace回放的时候,你就知道这事成了。