☰
Playwright + Cucumber:让BDD自动化测试真正落地
2026/9/26 14:11:18 网站建设 项目流程

1. 为什么是Playwright + Cucumber:BDD落地的组合逻辑

先说一个我在不少团队里看到的怪现象:很多人一提BDD(行为驱动开发),第一反应就是拉一堆人开会,用Gherkin语法写一堆"假如、当、那么"的验收标准,写完就往文档仓库一放,再也没有然后了。过一个月项目复盘,大家发现BDD变成了"写文档运动",自动化用例的影子都没见到。

我最早也是这个路子,做了两年半的测试架构,带过三四个转型BDD的团队,最终得出一条很扎心的经验:BDD能不能落地,取决于你选了什么执行引擎。Feature文件写得再漂亮,最后没有一个能稳定执行的自动化层兜底,那它就是PPT。

而Playwright + Cucumber这套组合,是我目前试下来最接近"让BDD真正跑起来"的方案。Cucumber负责把自然语言(Gherkin)翻译成可执行的步骤调度,Playwright负责在这些步骤里真正驱动浏览器、模拟用户操作、断言页面状态。两者边界清晰互不污染:业务人员读Feature文件,测试人员写步骤定义,代码里不掺业务废话,业务文档里也不碍代码维护。

另一个我特别吃这套组合的原因,是Playwright的技术底子比传统方案干净太多。它在2019年之后重写了WebDriver那套走HTTP wire protocol的思路,直接通过CDP底层协议和浏览器通信,启动速度、元素定位的稳定性、对单页应用的等待机制,都上了一个台阶。我在某个电商核心链路的回归测试里,用之前的Selenium方案跑完一个场景要3分半,切到Playwright之后同一场景稳定在50秒以内,这个差距不是优化能追平的,是执行机制本身带来的红利。

所以这篇的定位很明确:带着你从零把手搭一套Playwright + Cucumber的自动化测试工程,从Feature怎么写、步骤定义怎么拆、World对象怎么管,到并发执行怎么避开上下文陷阱、失败重试怎么配,整个过程里我会点出那些只有跑过才知道的坑。

1.1 Cucumber在这个组合里到底干了什么

有段时间我差点用纯Playwright + Page Object把整个测试全扛下来,后来发现不行。原因不是Playwright能力不够,而是团队沟通成本控制不住。

测试人员写用例时,如果每一步都是在代码里调用一个方法,比如loginPage.fillUsername('tester'),那业务产品经理基本没法参与评审。但改成Cucumber的Feature文件之后,同样的测试变成:

功能: 用户登录 场景: 使用有效凭证登录成功 假如 我在登录页面 当 我输入用户名 "tester" 和密码 "secret" 并且 我点击登录按钮 那么 我应该看到个人中心页面

这谁都看得懂。产品、开发、测试围在一张桌上可以逐句对齐业务规则,"当"和"那么"之间描述的就是一个行为契约。Cucumber在运行时把这些句子映射到对应的步骤定义代码,从而把自然语言和可执行自动化串起来。

必须承认,Cucumber的步骤定义机制也有副作用——它要求你把测试逻辑拆成一个个可以复用的步骤块。如果步骤写得太细、太碎,Feature文件会变成天书;如果写得太粗,又失去了BDD的意义。我的经验是:一个步骤尽量对应一个用户意图(或一个完整动作组),而不是对应一个底层API调用。这条经验后面展开细说。

1.2 Playwright在这一侧的价值边界

Playwright解决的问题非常聚焦:让浏览器自动化变得可预期且接近真实用户行为。

它有几个点是我在实际项目中明确感受到优势的:

  • 自动等待机制:你去click()一个按钮时,Playwright会自动等待这个元素可操作、稳定、可见,而不是像老框架那样需要手工sleep()或者轮询。这直接消掉了一整类"元素还没渲染完就找不到"的经典自动化测试噪音。
  • 多标签页、多上下文隔离:同一套代码可以很轻松模拟不同用户的会话隔离,这在测多角色权限场景时省了大力气。
  • 拦截网络请求与路由:配合page.route(),我能把第三方支付、短信验证码等服务在测试环境里mock掉,让用例不依赖外部不稳定因素。
  • Trace Viewer与视频录制:用例失败后,能够拿到包含完整DOM快照、网络请求时间线、控制台报错的trace文件。排错效率比起"截图+日志"高了一个量级。

用一句话总结:Cucumber把"测试业务"的组织问题解决了,Playwright把"真正操作浏览器"的执行问题解决了。二者天然各管一头、不打架,这正是我后来坚持用它们做集成的核心理由。

2. 工程初始化与脚手架搭建:先把地基打牢

这一节我开始讲实操。网上很多教程直接上来就是写代码,忽略了工程骨架对后续维护的决定性影响。我自己经历过一套烂结构带来的灾难:所有步骤定义塞在一个文件里,6千多行,任何人都能手滑改坏别人的步骤,最后不得不花两周做重构。

先给出一套我目前用着最顺手的目录结构,是基于TypeScript + Playwright + Cucumber的标准方案。它把feature、steps、pages、support四类职责拆开,边界清晰:

e2e/ ├── features/ # Gherkin 功能文件 │ ├── login.feature │ └── checkout.feature ├── step_definitions/ # 步骤定义(Cucumber 与代码的映射层) │ ├── login.steps.ts │ ├── checkout.steps.ts │ └── common.steps.ts ├── pages/ # Page Object 模式封装页面操作 │ ├── LoginPage.ts │ └── CartPage.ts ├── support/ │ ├── world.ts # 自定义 World,持有上下文与 fixture │ ├── hooks.ts # Before/After 钩子 │ └── report.ts # 报告生成逻辑 ├── outputs/ # 报告、trace、截图输出目录 │ ├── report.html │ └── traces/ ├── package.json ├── cucumber.js # cucumber 配置文件 └── playwright.config.ts # playwright 配置(供并发/报告等使用)

乍一看有点复杂,但每一层的职责都很单一,团队里新人上手时基本不需要太多解释。下面拆关键点。

2.1 依赖安装与版本选择

实际项目里,我是这么初始化的:

mkdir e2e && cd e2e npm init -y npm install -D @cucumber/cucumber @playwright/test typescript ts-node @types/node npx playwright install chromium

这里有几个版本上的提醒:

  • @cucumber/cucumber和@playwright/test是两个独立的包,它们之间没有绑定关系,不要用playwright-bdd这类封装库来替代原生Cucumber。虽然playwright-bdd确实省事,但如果你团队已有Cucumber的历史资产(比如已有的feature文件和步骤定义),直接用官方Cucumber集成反而迁移成本更低,而且可控性更高。
  • npx playwright install chromium会下载Chromium内核到本地缓存。如果你遇到下载速度奇慢的问题,多半是网络环境导致的——这一点没有绕开它的银弹,唯一的建议是把浏览器二进制缓存指到可靠的路径,并且把安装步骤固化到CI脚本里,避免每次重新拉取。
  • TypeScript版本建议选^5.x,长期稳定版即可,不要追新版本太紧,因为ts-node和@cucumber/cucumber的配合偶尔会在大版本更新上踩兼容性的坑。

2.2 cucumber.js 配置文件的正确写法

Cucumber本身解析配置时有两种方式:cucumber.js文件或package.json里的cucumber字段。我统一用cucumber.js,因为它的表达力更强,可以直接用glob pattern组织路径,命令行也干净。

// cucumber.js module.exports = { default: { require: [ 'support/hooks.ts', 'support/world.ts', 'step_definitions/**/*.ts', 'support/**/*.ts' ], requireModule: ['ts-node/register'], format: [ 'progress-bar', 'html:outputs/report.html', 'json:outputs/cucumber_report.json' ], formatOptions: { snippetInterface: 'async-await' }, publishQuiet: true, parallel: 4 } };

重点解释两个我吃过亏的地方:

  • require数组里如果漏了support/world.ts,或者顺序排错,Custom World就没有被注册,然后你在步骤定义里用this.page时会拿到undefined,这种错误非常隐蔽,报错信息还不明确。
  • format里的progress-bar适合本地调试,但在CI日志里最好切成@cucumber/pretty-formatter或usage,这样日志更清晰,也方便出问题时定位到具体步骤。

2.3 自定义World:把Playwright上下文塞进Cucumber

Cucumber的World可以类比Spring里的IoC容器,但它的作用域是"每个场景实例"。每个场景运行时,Cucumber会创建一个新的World实例,并通过this注入到所有步骤定义中。我们要做的,就是把Playwright的browser、context、page对象托管到这个World里,这样步骤定义之间才能共享浏览器状态。

support/world.ts大致长这样:

import { setWorldConstructor, World } from '@cucumber/cucumber'; import { Browser, BrowserContext, Page } from '@playwright/test'; export class CustomWorld extends World { browser!: Browser; context!: BrowserContext; page!: Page; // 还可以在场景初始阶段存放临时断言数据 data: Record<string, any> = {}; constructor(options: any) { super(options); } } setWorldConstructor(CustomWorld);

注意,我刻意把browser、context、page都定义为非空断言类型。这么做看起来有点危险,但配合hooks初始化逻辑后,可以保证步骤执行前这些对象一定存在。后面讲并发时我会再解释"为什么要每个场景独立context"。

配套的support/hooks.ts负责生命周期:

import { Before, After, BeforeAll, AfterAll } from '@cucumber/cucumber'; import { chromium, Browser, BrowserContext, Page } from '@playwright/test'; let browser: Browser; BeforeAll(async function () { browser = await chromium.launch({ headless: true, args: ['--disable-dev-shm-usage'] }); }); Before(async function (this: any) { this.browser = browser; this.context = await browser.newContext({ viewport: { width: 1280, height: 720 }, locale: 'zh-CN', ignoreHTTPSErrors: true }); this.page = await this.context.newPage(); }); After(async function (this: any, scenario: any) { if (scenario.result?.status === 'FAILED') { const screenshot = await this.page.screenshot({ path: `outputs/screenshots/${scenario.pickle.name}-${Date.now()}.png`, fullPage: true }); this.attach(screenshot, 'image/png'); } await this.context.close(); }); AfterAll(async function () { await browser.close(); });

这个设计有几个考量:

  • 每个场景都新建一个独立的BrowserContext,彼此之间通过不同context隔离。比如多个tab的cookie、localStorage、service worker是完全分离的。这能最大限度避免用例之间的状态污染。
  • path截图目录和outputs/保持一致,后续CI里收集测试产物时直接打包整个目录即可。
  • ignoreHTTPSErrors在本地测试环境经常需要打开,因为很多内网测试环境用的是自签名证书,不打开这选项会直接报certificate error。

2.4 步骤定义的骨架与常用正则技巧

有了World之后,步骤定义文件就简单了。以登录场景为例,login.steps.ts:

import { Given, When, Then } from '@cucumber/cucumber'; import { expect } from '@playwright/test'; Given('我在登录页面', async function (this: any) { await this.page.goto('https://test.example.com/login'); await expect(this.page).toHaveTitle(/登录/); }); When('我输入用户名 {string} 和密码 {string}', async function (this: any, username: string, password: string) { await this.page.getByLabel('用户名').fill(username); await this.page.getByLabel('密码').fill(password); }); When('我点击登录按钮', async function (this: any) { await this.page.getByRole('button', { name: '登录' }).click(); }); Then('我应该看到个人中心页面', async function (this: any) { await expect(this.page.getByRole('heading', { name: '个人中心' })).toBeVisible(); });

细心的读者会发现,我步骤定义里没有把页面元素操作封装到Page Object类中,而是直接写在步骤里。对于简单的login场景这没问题,文件短、直白。但项目一大,我强烈建议引入Page Object。比如上面的登录步骤,在页面类中统一管理定位器和操作,好处是当UI重做时,你只需要改一个Page类,而不是去翻每一处步骤定义里的字符串。

我自己的经验准则是:当同一个页面被超过3个场景引用时,立刻抽Page Object,越早越好,别攒。

Gherkin里一个容易让新手困惑的点是参数匹配。Cucumber处理{string}、{int}、{float}这些内置参数时,会按表达式从左到右自动绑定到步骤定义函数的参数。如果你需要更复杂一点的匹配,比如"我想任意匹配非中文一串文本",可以用正则表达式步骤:

When(/^我输入用户名为(.*)和密码为(.*)$/, async function (this: any, username: string, password: string) { // ... });

这里两种写法不要混用,混用会埋雷。比如某次我写了一个用{string}定义的步骤,然后又写了一个同样中文文本的步骤但用了正则,Cucumber在匹配时只按Feature里的真正文本去匹配,如果文本写法有细微差异,它会直接报"undefined step"(不了步骤未定义)而不是给出明确提示,排查起来非常痛苦。

3. 从自然语言到可执行测试:一份完整Feature的诞生过程

这一节我想完整地走一个真实的业务场景,把它从需求讨论一直写到能稳定执行。我选的是网上书店的"购物车结算"链路,因为这类场景在大多数电商/零售类系统里都有,而且覆盖了多步骤状态流转、金额计算、异常分支,挺有代表性。

3.1 Feature文件的写法:先当业务文档写

需求场景来自产品:用户把书加入购物车,进入结算页,选择配送方式,完成支付,订单生成成功。先别急着写代码,就按业务语言写Feature:

功能: 购物车结算 背景: 假如 我已登录 并且 我的购物车中有 2 本《Playwright实战》 并且 我的购物车中有 1 本《Cucumber宝典》 规则: 结算页金额计算 场景: 商品小计正确 当 我进入购物车页面 那么 我应该看到商品小计合计为 3 本 并且 商品总金额正确显示 规则: 配送方式影响总价 场景: 选择普通配送 当 我进入结算页 当 我选择配送方式为 "普通配送" 那么 我应该看到运费为 6 元 并且 我应该看到应付总额为商品总金额加 6 元 规则: 订单创建后的状态 场景: 支付成功后生成订单 当 我进入结算页 当 我选择配送方式为 "次日达" 当 我点击提交订单 当 我模拟支付成功 那么 我应该看到支付成功页 并且 我应该能在订单列表看到编号非空的订单

这里有个BDD实践里很重要的点:背景 + 规则的结构。背景用于收敛所有场景共通的前置条件,避免每个场景里重复"假如我已登录..."三行。规则是Cucumber 7之后引入的概念,它把同一业务规则下的多个场景聚拢,让Feature文件读起来像一份带分节的需求规格书,而不是一堆平铺的测试用例。

从业务文档的角度看,这份Feature是可以拿给产品经理评审签字的。但从自动化执行的角度,当前里面有几个步骤是缺失的,比如"我的购物车中有 2 本《Playwright实战》"、 "模拟支付成功",这些必须在步骤定义里给出合理实现,否则Cucumber只会报undefined step。下面我把每类步骤的落法逐个说明。

3.2 前置数据的准备:API直接造数优于UI操作

"我的购物车中有2本书"这类前置条件,最忌讳的做法是通过UI一步步加购物车。原因很简单:慢、脆、耦合页面细节。如果登录后第一次进详情页,加购按钮的文案或者弹窗变化了,你的前置步骤就挂了,但你要测的其实是后面的结算流程。

我更推荐的方式是在步骤定义里直接调用后端API造数,或者直接改数据库。实践中,我会把这类造数API封装成独立工具函数,然后在步骤定义里调用:

import { Given } from '@cucumber/cucumber'; // 假设项目里有这样一个apiClient封装 import { apiClient } from '../support/apiClient'; Given('我的购物车中有 {int} 本 {string}', async function (this: any, count: number, bookName: string) { const book = await apiClient.searchBook(bookName); await apiClient.addToCart(book.id, count); this.data.bookId = book.id; this.data.bookCount = count; });

这段代码有一个细节值得注意:我把bookId存到了this.data上。因为后面的结算、支付、订单断言可能会用到这些数据。跨步骤传递数据时,放在this.data上是最干净的做法。如果用全局变量或者闭包外提,会在并发执行时出现串数据的问题,后面细讲。

如果你们的测试环境没有提供造数API,退而求其次可以用Playwright直接拦截网络请求,在页面初始化时通过page.route()注入购物车数据。这种方式也能减少UI路径依赖,但可维护性不如真API,因为你要知道页面发什么请求、返回什么字段,一旦前端联调数据结构变化,mock也需要同步。

3.3 与支付等外部依赖解耦:路由拦截

"模拟支付成功"是Elecom类系统里最经典的外部依赖解耦点。真实支付网关没法在测试环境稳定调通,所以更务实的做法是拦截支付回调请求,直接返回成功响应。

import { When } from '@cucumber/cucumber'; When('我模拟支付成功', async function (this: any) { // 拦截实际支付网关的回调请求 await this.page.route('**/api/payment/callback', async route => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 'SUCCESS', orderId: this.data.orderId }) }); }); // 点击页面上的"支付成功"模拟按钮(测试环境专用入口) await this.page.getByRole('button', { name: '模拟支付成功' }).click(); // 等回调处理完成 await this.page.waitForResponse('**/api/payment/callback'); });

route.fulfill是一种"假响应"机制,它会截断真实网络请求,把预先设定好的响应体返回给页面。测试环境专用的"模拟支付成功"按钮通常由研发在测试环境开关后露出,没有这个入口时,可以改成直接等待回调接口触发并拦截后自行声明。

这个做法的核心思想是:自动化测试要稳定,就必须把测试环境与真实第三方网关之间的"不可控因素"隔离。因为真实异步回调的延迟、网络抖动、支付平台方策略变更,都不是我们能掌控的。

3.4 断言的粒度:状态可见优于状态存在

BDD步骤里的"那么"是验收点。我的原则是,断言尽量面向"用户的可见反馈",而不是"内部数据的值"。比如"我应该看到支付成功页",最合适的断言是页面上的成功标识元素可见,而不是去数据库查order.status == 'PAID'。

为什么?因为站在业务行为的角度,只要用户看到支付成功页,说明整个支付链路走到了终点;数据库层面即使有偏差,那是另一个问题,不应该由这个场景来承担。自动化用例的断言务求"最小必要",断言越多、越细,用例越脆。

当然,有些状态是页面上看不到的,比如订单号非空。这种可以读取页面上的订单号文本再断言:

Then('我应该能在订单列表看到编号非空的订单', async function (this: any) { const orderList = this.page.locator('.order-item'); const orderCount = await orderList.count(); expect(orderCount).toBeGreaterThan(0); const firstOrderId = await orderList.first().locator('.order-id').textContent(); expect(firstOrderId?.trim().length).toBeGreaterThan(0); });

这类断言要慎用,一旦页面结构调整,字符串匹配就容易踩坑。更好的做法是查询API返回的数据,断言服务端确实产生了这个订单。实践中我建议"数据库/API断言"和"UI断言"分工:UI断言只负责用户可见反馈,数据层断言走API。两者缺一不可,但不能彼此替代。

4. 运行机制、上下文隔离与并发执行的正确姿势

很多人在本地跑通第一个Cucumber场景后,会自然地觉得"这套东西也就这样了"。真正拉开维护难度的,是当用例量超过300条,开始考虑怎么让它们在合理时间内跑完时,遇到的各种并发与隔离问题。

4.1 并发执行的两种方式:CLI parallel 与 多进程

Cucumber原生支持--parallel <N>参数,可以在同一Node进程内并行执行多个场景。对Playwright而言,这个方案有个关键限制:同一进程里的并发场景之间虽然World是独立的,但如果Browser实例本身是共享的,某些资源仍可能产生竞争。为此我建议,所有场景在Before里创建自己的Context,在After里关闭Context。这样即便浏览器进程共享,页面、存储、cookie这些核心状态仍然隔离。

另一种并发方式是让多个Node进程各自跑一部分feature,比如在CI里按feature文件拆分成多个job。这个方案隔离性最好,但维护成本偏高。我们的项目中采用了一个折中的办法:

cucumber-js --parallel 4

四个并行worker共享创建Chromium实例,并发度对绝大多数回归测试足够了。当场景特别重、单个场景跑了超过2分钟时,再考虑拆进程的办法。

4.2 测试数据串扰的经典坑:共享账号

并行执行最容易被忽视的坑是"共享账号"。比如你有10个用例都登录同一个测试账号,并行执行时,A用例改了用户昵称,B用例断言个人资料时读到新昵称就挂了。

解决办法有三种:

  • 为每个worker分配不同的账号,比如在Before钩子中根据workerIndex动态选择账号。听起来简单,但每个账号要提前在测试环境创建好,且要保证数据互不影响。
  • 用API在测试场景开始前自动创建一次性账号,场景结束再清理。这种方式最干净,前提是你的系统注册账号接口允许自动化调用,且没有繁重的验证码门槛。
  • 如果账号系统是手机号+短信验证码,那就必须借助测试环境的后门——比如固定验证码开关。这也是为什么自动化测试强烈依赖"测试后门"的原因,很多团队在写代码时完全没考虑测试后门,导致后续自动化的成本暴涨。

我强烈推荐第二种方式,它带来的数据隔离性是最好的。代码里大致这样:

Before(async function (this: any) { const user = await apiClient.createTemporaryUser(); this.data.user = user; await this.page.goto('https://test.example.com/login'); await this.page.getByLabel('用户名').fill(user.username); await this.page.getByLabel('密码').fill(user.password); await this.page.getByRole('button', { name: '登录' }).click(); });

有人会问,"那样例里背景部分的'我已登录'不就是重复实现了吗?"对,这确实是背景语法的局限。如果前置登录逻辑比较复杂,我会把登录这段挪到CommonSteps里,用同一个Given('我已登录')去完成,背景里直接调用,不至于每个场景里都复制一份。

4.3 事件监听与页面监控:调试利器 field

Playwright的page.on()系列监听事件,是我调试测试用例时最常用的工具。在集成Cucumber后,我会在Before钩子里给每个页面挂上一组默认监听,这样当用例失败时,不只是截图,还能保留关键网络请求和console报错。

Before(async function (this: any) { this.page.on('console', msg => { if (msg.type() === 'error') { this.attach(`Console Error: ${msg.text()}`, 'text/plain'); } }); this.page.on('requestfailed', req => { this.attach(`Request Failed: ${req.url()} - ${req.failure()?.errorText}`, 'text/plain'); }); this.page.on('pageerror', err => { this.attach(`Page Error: ${err.message}`, 'text/plain'); }); });

这里把监控数据通过this.attach挂到Cucumber的当前场景报告里,后期看HTML报告时能直接看到这段上下文。以前我用Selenium时代,排查一个失败要等半天去看日志,现在很多问题看一眼报告里的console error就知道是前端哪个JS报错导致的。

如果你的用例涉及动态iframe——比如某些登录弹窗是iframe嵌的——Playwright的处理方式比老框架舒服很多:

const frame = this.page.frame({ url: /login/ }); if (frame) { await frame.getByLabel('用户名').fill(username); }

page.frame()函数可以按URL匹配iframe引用,不需要像以前那样手动switchTo().frame()。注意iframe一旦加载完毕,frame对象里的元素查找和普通page一样,同样有Playwright的自动等待能力。这点对没有多少iframe经验的新人来说,能少踩很多坑。

4.4 滚动加载与懒加载内容

处理无限滚动页面时,scrollIntoViewIfNeeded是主力方法。比如结算页底部有一个"查看配送说明"的链接,它可能不在首屏,直接click可能碰到遮挡或者元素处于视口外。用scrollIntoViewIfNeeded()先滚动再点击,比手动调window.scrollTo稳得多:

await this.page.getByText('配送说明').scrollIntoViewIfNeeded(); await this.page.getByText('配送说明').click();

另外,有些页面采用懒加载,滚动过程中会动态请求数据。此时我建议用waitForResponse等待某个接口响应,再继续后续断言。最怕的是一个盲目waitForTimeout(2000),这既不稳定还拖慢执行。

4.5 失败重试的配置思路

回归测试里,有些用例偶发失败,比如环境扩容时服务启动慢了、某个服务偶发超时。完全禁止重试会让团队被噪音搞得疲惫,但无脑全局重试又可能掩盖真实缺陷。我的策略是:只在CI上开重试,本地跑不重试。

Cucumber本身没有内建重试机制,我通常通过脚本控制。比如在package.json里:

{ "scripts": { "test:e2e:ci": "cucumber-js --parallel 4 --retry 1 --retry-tag-filter '@flaky'" } }

--retry指定重试次数,--retry-tag-filter '@flaky'限定只有打了@flaky标签的场景才重试。这个方案的精髓在于:重试是显式的,不是默认行为。任何需要重试才能过的用例,必须由人先判断下是否该打@flaky标签,而不是让所有用例默认躲在重试的荫蔽下。

5. 测试数据、报告与CI集成:从能跑到能交付

用例能在本地跑了,这只是开始。真正让它产生团队级价值,是要把报告链路、CI流水线、失败通知全部串起来。

5.1 HTML报告选择与集成

Cucumber自带的html:outputs/report.html是基础版,能看场景通过率、步骤耗时、每个步骤的附加信息(截图、console日志等)。但UI比较朴素。我还在用cucumber-html-reporter这个社区方案,生成样式更清晰、按Feature聚合统计的页面。

安装与使用:

npm install -D cucumber-html-reporter

然后在CI跑完Cucumber后,单独执行一个生成报告脚本support/report.ts:

const reporter = require('cucumber-html-reporter'); const options = { theme: 'bootstrap', jsonFile: 'outputs/cucumber_report.json', output: 'outputs/report.html', reportSuiteAsScenarios: true, scenarioTimestamp: true, launchReport: false, metadata: { 'App Version': '1.2.3', 'Test Environment': 'test' } }; reporter.generate(options);

这个报告生成器也有自己的坑:它要求jsonFile路径必须存在。CI流水线中如果Cucumber执行彻底失败导致根本没生成json文件,报告脚本会报错。我写的CI脚本会做一个文件存在性检查,缺失时直接用head命令截取cucumber输出日志来展示,而不是让整个pipeline因为"报告脚本失败"而挂掉。

5.2 失败告警与通知

报告有了,接下来是通知。团队用企业微信或飞书的话,可以在AfterHook里针对失败场景发送webhook消息。示例:

After(async function (this: any, scenario: any) { if (scenario.result?.status === 'FAILED') { await sendWebhook({ title: 'E2E测试失败', content: `场景: ${scenario.pickle.name}\n失败原因: ${scenario.result.exception?.message}`, reportUrl: process.env.REPORT_URL }); } });

这里要注意的是不要每个失败都立刻发消息,否则失败20条时群里会被刷屏。我会用简单的聚合策略:在AfterAll钩子中统计本批次失败场景列表,统一发一条带列表的消息。只有一条失败记录时,直接发送详情即可。

5.3 与Jenkins/Git的集成路径

热词里有"jenkins持续集成java项目",可见很多人正在搞CI流程。Playwright + Cucumber的自动化工程接Jenkins时,常用的做法是:

  • Jenkins job 从Git拉取代码后执行npm ci
  • 安装浏览器依赖:npx playwright install --with-deps chromium
  • 执行测试:npm run test:e2e:ci
  • 归档测试产物:outputs/**
  • 发送报告链接到团队群

我的经验里最容易出错的地方是npx playwright install --with-deps chromium在部分Linux环境需要sudo,因为系统会缺少一些共享库。可以在Jenkinsfile里用sudo npx playwright install-deps chromium。如果公司CI有严格的权限限制,需要提前把依赖装进基础镜像,这一步不要放在每次构建里裸跑。

如果是GitLab CI,大致写法:

e2e: stage: test image: mcr.microsoft.com/playwright:v1.50.0-jammy script: - npm ci - npm run test:e2e:ci artifacts: paths: - outputs/ when: always expire_in: 7 days

mcr.microsoft.com/playwright官方镜像自带浏览器及全部系统依赖,省去了动态装依赖的麻烦,这也是我强烈推荐的CI基础镜像方案。

5.4 在提交前用Tags做冒烟测试

Cucumber标签机制很适合用来做"提交前冒烟测试":只挑核心链路跑,而不是全量回归。比如在Feature文件里给高优场景加@smoke标签:

@smoke 功能: 用户登录 场景: 使用有效凭证登录成功 ...

跑测试时用:

npx cucumber-js --tags "@smoke"

这个功能成本极低,但能明显减少开发同学在提交代码前全量回归的等待时间。我们团队现在的约定是:开发自测跑@smoke,每日回归跑全部。如果标签越打越多导致某些场景被重复运行,可以配合--tags "not @slow"做过滤。

6. 踩坑实录:那些文档里不会写但一定会遇到的问题

这一节我打算把从零搭建和运维这套框架过程中遇到的真实问题罗列出来。如果你正在集成过程中卡住,对照着会少走很多弯路。

6.1 步骤未定义(undefined step)的排查链路

Cucumber报undefined step时,你可能会以为是自己没写代码。但实际上最常见的原因是中英文标点符号不一致。比如Feature里写"我输入用户名"和"密码"之间的空格是全角,而步骤定义里用的是半角空格,Cucumber做文本匹配时是严格按字符匹配的,差一个空格都不认。

遇到这种问题时,我的排查顺序很固定:

  1. 先看Cucumber控制台输出的"snippets"建议,它给出的模板是基于当前文本生成的。如果模板里出现了和步骤定义看似一样却匹配不上的情况,那基本就是不可见字符差异。
  2. 借助--format usage参数可以看到所有已定义步骤被使用的情况和频率,自然也能看出哪些文本始终匹配不上。
  3. 用编辑器全量搜索步骤文本,肉眼对比空格和标点。

6.2 等待策略冲突:Playwright自动等待 vs Cucumber步骤调度

Playwright内置了等待机制,元素找不到时会一直等待到超时。这给测试的稳定性带来很大提升,但也带来一个副作用:如果页面有动画或者数据加载很慢,Playwright默认的timeout: 30_000可能不够。我在某个报表页面遇到过,明明点击查询后页面是loading骨架屏,但Playwright已经认为"按钮已可操作",之后马上点击下一个按钮时元素被loading遮罩挡住,测试失败。

这里的核心矛盾是"自动等待只等待元素可操作,并不等待业务数据完全加载"。解决办法分两派:

  • 我会在步骤定义中对耗时操作后的断言,使用await expect(page.getByText('查询完成')).toBeVisible()这类业务标识去等待,而不是粗暴加sleep。这是最干净的思路。
  • 如果业务标识确实不明确,可以用page.waitForResponse等待具体接口响应。在步骤定义里用网络层的确定性兜底。

6.3this上下文丢失:箭头函数的坑

Cucumber步骤定义里使用this访问World对象时,如果你用ES6箭头函数定义步骤,则会丢失this上下文:

// 错误示例:箭头函数导致this不是World实例 Given('我在登录页面', async () => { await this.page.goto('...'); // this 不是 CustomWorld }); // 正确示例:普通函数 Given('我在登录页面', async function (this: any) { await this.page.goto('...'); });

这个坑非常隐蔽,特别是从别处复制代码时很容易踩到。TS编译不会报错,程序也只会静默地把this绑定到外层词法作用域。排查时如果你发现this.page一直是undefined,先看看是不是用了箭头函数。

6.4 动态iframe与懒加载数据流的处理

现在不少系统用了微前端架构,页面上嵌套的iframe可能在不同步骤间动态创建、销毁。我在某个运营后台遇到过,两个业务模块其实分属两个iframe,而页面URL不断变化,用frameLocator定位时iframe尚未加载完成,导致元素查找失败。

处理这类问题的通用套路是:把"等待iframe出现"和"在iframe内定位"拆成两步。

const frame = await this.page.waitForSelector('iframe[src*="module-a"]').then(handle => handle.contentFrame()); // 若 handle.contentFrame() 返回 null,说明 iframe 尚未就绪,需重试 const targetFrame = frame ?? await this.page.frame({ url: /module-a/ }); await targetFrame?.getByRole('button', { name: '查询' }).click();

这里waitForSelector保证iframe元素出现在DOM中,但contentFrame()在iframe的文档加载之前可能返回null。虽然这个场景比较偏,但对现代前端架构来说,我认为还是值得写成工具函数,供所有涉及iframe的用例复用。

6.5 浏览器二进制与Cucumber的版本兼容

有次我升级了Playwright版本后,所有测试启动时浏览器正常,但执行到某个page.goto()就卡住、直到超时。排查了许久发现是Cucumber的某个依赖与新版Playwright的CDP建连方式不兼容。当时解决方法是锁定版本:Playwright1.4x.x+@cucumber/cucumber10.x。所以说,如果你正在用这套组合且一切稳定,不要随意升级Playwright版本;如果必须升级,一定要先在一条核心链路上做回归。

6.6 失败视频录制的锦上添花

最后分享一个我屡试不爽的小技巧:为失败场景录屏。在After钩子中如果场景失败,调用context.close()之前先暂停浏览器录制并保存视频。

After(async function (this: any, scenario: any) { if (scenario.result?.status === 'FAILED') { const videoPath = await this.page.video()?.path(); if (videoPath) { this.attach(videoPath, 'video/webm'); } } });

在browser.newContext()创建上下文时,需要设置recordVideo: { dir: 'outputs/videos/' }。录屏文件能直观看到失败前的完整用户操作回放,比截图信息量丰富几倍。代价是会产生较大体积的文件,CI里要留意产物清理策略。

7. 关于选择与维护:一个过来人的心得

走到这里,相信你已经能从零搭出一套Playwright + Cucumber的可执行工程了。最后我想聊的不是具体技术,而是更偏向方法论的东西——为什么这套组合能让BDD真正在团队里转起来。

我见过太多团队在引入BDD时走两个极端。一个极端是"只写Feature不写实现",最后这些文档全部腐烂,因为需求变了没人同步更新。另一个极端是"放弃Gherkin直接用代码写测试",这样用例虽然能跑,但业务方完全无法参与评审,写出来的东西其实还是老一套自动化脚本。

Playwright + Cucumber整合后,关键变化在于Feature文件本身成了活文档:需求一改,测试代码一跑,场景挂掉,随之而来的就是更新步骤定义或更新Feature描述的讨论。这个过程倒逼着需求和测试真正对齐。

至于项目规模大了之后是否应该持续使用Cucumber,我的态度是:300条左右用例的回归测试,这套组合依然能驾驭得很轻松。再往上走,比如到1000条以上,你需要考虑的就不再是工具选型问题了,而是用例分层、测试数据服务、多环境矩阵管理这些工程化课题。

如果你正准备在团队里推行BDD,我的建议是不要从零开始写一堆Feature。先把现有最主干的业务链路挑两条出来,阶段性目标是跑通两个场景,让大家真实体验一把"自然语言描述的用例在浏览器里自动执行成功"的冲击感。有了这个第一次的正反馈,后面的大规模推广会顺滑很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询