你们有没有经历过这种尴尬:自动化测试代码写完了,用例也跑起来了,结果一到评审会,业务方盯着满屏的assertEquals和元素定位表达式,问了一句“这到底验证的是什么业务规则?”整个会议室瞬间安静。这不是我编的,而是很多团队做自动化测试时的真实场景。后来我在项目里引入Cucumber这个自动化测试框架,问题才算真正解决。它不是让测试更会写代码,而是让整个团队——包括业务、产品、开发、测试——都能用同一种语言描述“系统应该做什么”。这篇文章照我的实际使用经验来写,适合正在做功能自动化、接口自动化,想引入 BDD 或者想优化当前测试框架的团队参考。
1. 别急着写代码:Cucumber 解决的是“三拨人各说各话”
1.1 Cucumber 的定位:一个用自然语言驱动测试的框架
先泼一盆冷水:不少人第一次用 Cucumber 就犯迷糊——装了依赖、写了 Feature 文件,却发现 Cucumber 自己连浏览器都打不开。这里必须把定位说清楚:Cucumber 本身不驱动浏览器,也不发 HTTP 请求。它只负责三件事:解析用 Gherkin 写的 Feature 文件、根据关键字匹配对应的 Step Definitions、把匹配到的代码按场景顺序执行。真正做页面操作的是 Selenium WebDriver,真正发接口请求的是 RestAssured、HttpClient 这类库。
形象一点理解:把 Cucumber 比作“可以执行的 PRD”。产品经理写的需求文档如果足够结构化,理论上可以被机器直接执行——Given/When/Then 就是这种结构化的最小单元。所以当你决定引入 Cucumber,先别急着写代码,第一步是检查团队里有没有人能把这些规则用自然语言写清楚。写不清楚,用什么框架都白搭。
1.2 Given / When / Then 为什么这么设计
自动化测试界写了很多年的“三步曲”:准备数据、执行业务、校验结果。换成 Gherkin,就是Given、When、Then,额外加两个辅助角色And和But。这个拆分不是为了让语法好看,而是对应人类理解业务的方式。
Feature: 用户登录 Scenario: 使用正确凭证能够登录成功 Given 用户访问登录页面 When 用户输入正确的用户名和密码 When 用户点击登录按钮 Then 用户应该看到首页按单元测试的 AAA 理论套,Given是 Arrange(前置条件),When是 Act(用户行为),Then是 Assert(结果断言)。这样拆开之后,最大的收益有两个:第一,同一段Given可以复用在多个Scenario里;第二,任何一个人打开 Feature 文件,不看代码就知道这条用例在验证什么业务。这一点对国产软件团队尤其受用——需求文档更新不及时几乎是常态,Feature 文件到最后往往比文档还新、还准。
1.3 谁写 Feature:理想与现实的折中方案
很多人对 BDD 的想象是:业务人员、开发、测试围一桌,大家一起写 Gherkin。实话说,我在项目里没见过几个业务总监愿意亲手写Scenario的。现实做法通常是测试牵头草拟,产品和开发评审确认。
我在过的团队是这样运作的:测试拿到新需求后,用 Gherkin 写一版验收场景,当作“需求理解确认单”发给产品和开发。评审会上大家不聊代码,只聊规则:“没登录的用户点结算,是不是应该弹登录框?”“订单状态为已取消,还能不能申请退款?”这些对话如果能在写自动化代码之前发生,返工成本可以压到很低。Feature 文件一旦定稿,它既是需求验收标准,又是自动化用例的源码,一份投入两份产出。
2. 技术栈盘点:为什么 Java 项目里我会首选 Cucumber
2.1 Java、Python 两套生态到底怎么选
搜自动化测试框架相关的内容,绕不开 Java 和 Python 两个阵营。Python 生态里大家更熟悉 pytest,对应 Cucumber 习惯的是pytest-bdd、behave;Java 生态则基本是 Cucumber-JVM 一家独大,配合 JUnit/TestNG 使用。
这里给个不含糊的选型建议:项目技术栈是 Java 系,接口自动化、Web 自动化都有 Java 代码积累,直接上 Cucumber;团队主体是 Python 写测试脚本,就没必要强行转 Java,pytest-bdd 在 pytest 的 fixture 体系下融合得更好。选型不是比谁功能多,是比谁融进现有工程时阻力小。
表格对比一下两个生态的特征:
| 对比项 | Java + Cucumber | Python + pytest-bdd / behave |
|---|---|---|
| 语言亲和度 | 偏向后端开发团队 | 偏向脚本与数据团队 |
| 与 Selenium 配合 | 成熟方案多 | 同样成熟 |
| 与 pytest 生态整合 | 不沾边 | 天然整合 |
| 并行执行能力 | JUnit Platform 支持良好 | pytest-xdist 成熟 |
| 社区资料量 | 相对更多 | 少一些但在增长 |
2.2 接口自动化与 Selenium 怎么搭进 Cucumber
很多团队误以为 Cucumber 是“UI 自动化框架”,其实它根本不在乎你测的是页面还是接口。层抽象可以这样理解:
Cucumber 负责语义匹配 | +-- Selenium WebDriver 处理 Web UI 操作 +-- RestAssured 处理 HTTP API 请求 +-- JDBC 处理数据库准备与校验接口自动化尤其适合 Cucumber。比如支付回调、鉴权这类后端场景,用 Gherkin 描述“当第三方平台发送支付成功通知,系统应生成订单流水”,比直接甩一串 JSON 断言清晰得多。我见过一个团队把所有下单接口场景全用 Cucumber 管理,需求变更时先改 Feature 再改代码,回归周期从半天压缩到十分钟。UI 层面则用 Selenium 写 Step,真正驱动浏览器干活。
2.3 依赖引入与版本搭配建议
如果你走 Java + Maven 路线,最小依赖组合是cucumber-java+cucumber-junit-platform-engine+junit-platform-suite。这三个缺一不可:cucumber-java提供@Given注解,junit-platform-engine让 Cucumber 跑在 JUnit 5 平台上,junit-platform-suite是 Runner 的底座。
<properties> <cucumber.version>7.18.1</cucumber.version> <junit.platform.version>1.10.2</junit.platform.version> </properties> <dependencies> <dependency> <groupId>io.cucumber</groupId> <artifactId>cucumber-java</artifactId> <version>${cucumber.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>io.cucumber</groupId> <artifactId>cucumber-junit-platform-engine</artifactId> <version>${cucumber.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.junit.platform</groupId> <artifactId>junit-platform-suite</artifactId> <version>${junit.platform.version}</version> <scope>test</scope> </dependency> </dependencies>这里有个容易踩的版本坑:cucumber-junit-platform-engine对 JUnit Platform 版本有最低要求,你单独升junit-platform-suite到较高版本时,要确认与 Cucumber 内部依赖的junit-platform-engine没冲突。最省心的方法是让 Cucumber 父 POM 管住 JUnit 相关版本,只在 properties 里覆盖 Cucumber 版本。
3. 从零搭一个能跑的 Cucumber 工程:依赖、目录、第一个场景
3.1 Maven 标准目录:Feature 文件和步骤定义的分工
Cucumber 工程的目录结构有约定,但理解约定背后的逻辑比记住路径更重要。Feature 文件放在src/test/resources/features,因为它是资源文件,测试代码打 jar 包时要带上;Step Definitions 放在src/test/java下的包内,Runner 通过glue参数指定包路径来关联。
src/test ├── java │ └── com/example/demo │ ├── runner │ │ └── TestRunner.java │ └── stepdefs │ └── LoginStepDefs.java └── resources └── features └── login └── login.feature新手最容易犯的错是把 feature 文件放错目录或放错包结构,导致 Runner 跑起来提示 “No features found at ...”。遇到这个报错,先检查路径,八成是资源目录不对。
3.2 写第一个 Gherkin 场景:用业务语言描述需求
Feature 文件本质上是一份“可执行的验收标准”。写的时候有一条铁律:只写用户视角能懂的话,不要写任何技术细节。比如“用户输入正确的用户名和密码”可以,“用户调用 POST /api/login 接口传参 username=xxx 并断言 status 200”就不合格——后者是测试实现该关心的,不是业务语义该关心的。
Feature: 用户登录 Scenario: 使用正确凭证能够登录成功 Given 用户访问登录页面 When 用户输入用户名 "demo" 和密码 "123456" And 用户点击登录按钮 Then 用户应该看到文本 "欢迎回来"注意几点细节:字符串参数用双引号包裹,Cucumber 表达式会自动提取;And等价于上一步的类型延续,不用纠结它是 Given 还是 When;中文写 Feature 完全没问题,IDE 插件能识别,不过文件名建议英文,部分报告插件对中文文件名支持不友好。
3.3 Step Definitions 与 Runner:让自然语言落到代码上
写完 Feature 直接跑,默认结果是一堆 Undefined Step。这不是失败,而是你还没有给每个句子写实现。Step Definitions 是用@Given/@When/@Then注解标注的 Java 方法,Cucumber 靠正则或 Cucumber Expression 做全文匹配。
package com.example.demo.stepdefs; import io.cucumber.java.en.Given; import io.cucumber.java.en.When; import io.cucumber.java.en.Then; import org.openqa.selenium.By; import org.openqa.selenium.WebDriver; import org.openqa.selenium.chrome.ChromeDriver; public class LoginStepDefs { private WebDriver driver; @Given("用户访问登录页面") public void openLoginPage() { driver = new ChromeDriver(); driver.get("https://example.com/login"); } @When("用户输入用户名 {string} 和密码 {string}") public void inputCredentials(String username, String password) { driver.findElement(By.id("username")).sendKeys(username); driver.findElement(By.id("password")).sendKeys(password); } @When("用户点击登录按钮") public void clickLoginButton() { driver.findElement(By.id("loginBtn")).click(); } @Then("用户应该看到文本 {string}") public void verifyText(String expected) { String bodyText = driver.findElement(By.tagName("body")).getText(); org.junit.jupiter.api.Assertions.assertTrue( bodyText.contains(expected), "页面应包含文本: " + expected); driver.quit(); } }Runner 类是入口。JUnit 5 + Cucumber 7 的写法如下:
package com.example.demo.runner; import org.junit.platform.suite.api.IncludeEngines; import org.junit.platform.suite.api.SelectClasspathResource; import org.junit.platform.suite.api.Suite; @Suite @IncludeEngines("cucumber") @SelectClasspathResource("features/login") public class TestRunner { }跑完会得到一屏 JUnit 格式的通过/失败统计。到这一步,一个最基础的 Cucumber 工程就跑通了。你会发现整个流程跟“先写需求,再实现需求”几乎一一对应,写自动化变成了翻译自然语言的过程,而不是上来就堆代码。
4. 过了“能跑”这关之后:运行策略、并行与报告
4.1 三种启动方式:IDE、命令行、CI 流水线
工程搭好之后,日常使用会面临三种运行方式。IDE 里直接右键 RunTestRunner适合调试;命令行mvn clean test适合本地验证;CI 里mvn verify配套流水线最常用。Cucumber 对这三种方式天然支持,不需要额外配置。
我个人的习惯是:写单个 Feature 阶段用 IDE 跑,指定@SelectClasspathResource到具体文件或直接改 tags;提交代码前在命令行跑一次完整回归。CI 上则叠加 Maven Surefire 报告,把失败信息及时暴露在流水线里。曾有同事直接在 Jenkins 里点构建不看控制台输出,等发现红了一大片已经是三小时后——这个习惯要改,Cucumber 的失败信息在控制台里其实非常清晰,标明了哪个 step 失败、对应 feature 第几行。
4.2 并行执行:提速的关键,也是线程事故的重灾区
回归用例到几百条时,单线程跑不动了。Cucumber 7 支持通过junit-platform.properties开启并行:
cucumber.execution.parallel.enabled=true cucumber.execution.parallel.config.strategy=fixed cucumber.execution.parallel.config.fixed.parallelism=4配置本身不难,难在并行之后 WebDriver 的状态管理。如果 Step Definitions 里用的是全局单例WebDriver,并行一开就是串话:用例 A 打开了登录页,用例 B 却拿同一个 driver 去点了支付按钮。正确做法是用ThreadLocal<WebDriver>配合 WebDriverManager 按线程管理驱动生命周期。这是我见过 Cucumber 项目里最隐蔽也最致命的坑。
public class DriverFactory { private static final ThreadLocal<WebDriver> DRIVER = new ThreadLocal<>(); public static WebDriver getDriver() { if (DRIVER.get() == null) { DRIVER.set(new ChromeDriver()); } return DRIVER.get(); } public static void closeDriver() { if (DRIVER.get() != null) { DRIVER.get().quit(); DRIVER.remove(); } } }场景隔离也是必须考虑的。每个Scenario结束后确保清理数据并关闭 driver,才能避免上一个场景的状态泄漏到下一个。
4.3 报告:给团队看的不是代码,是业务执行记录
很多人以为 Cucumber 的报告就是控制台那几行统计数据,其实它最有价值的产物是 HTML 报告。用maven-cucumber-reporting插件可以生成图文并茂的报告页面,包含通过率、失败步骤截图、执行耗时等。
首先在 Runner 上配置生成 JSON 中间产物:
@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "json:target/cucumber-reports/cucumber.json")然后在 pom 里挂 Surefire 的 post-phase 执行报告生成:
<plugin> <groupId>net.masterthought</groupId> <artifactId>maven-cucumber-reporting</artifactId> <version>5.7.8</version> <executions> <execution> <id>generate-cucumber-reports</id> <phase>verify</phase> <goals> <goal>generate</goal> </goals> <configuration> <jsonFiles> <param>target/cucumber-reports/cucumber.json</param> </jsonFiles> <outputDirectory>target/cucumber-reports</outputDirectory> </configuration> </execution> </executions> </plugin>报告最终要共享给产品、开发和测试一起看。我的经验是:把报告地址贴进迭代周报,让业务同学自己去看哪些场景没通过,而不是等测试去口头解释。Feature 文件本身就是活文档,加一个 CI 报告入口,整个团队就拥有了可持续维护的验收记录。
5. 真实项目里踩过的坑:这些不解决,Feature 写得再漂亮也白搭
5.1 中文乱码:环境配置能坑掉整个报告
中文环境做 Cucumber 自动化,乱码问题几乎是人人都要打的怪。具体表现是 Feature 里的中文在控制台或报告中变成????,断言里的中文实际比对失败。根因通常是 Maven Surefire 没有显式指定 UTF-8。
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>3.2.5</version> <configuration> <argLine>-Dfile.encoding=UTF-8</argLine> </configuration> </plugin>Java 编译阶段同样建议显式指定编码:
<properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> </properties>很多团队卡在“用例在 IDE 里跑是好的,到 CI 上中文就乱”,十有八九是 Surefire 的argLine丢了。这个配置先加进 pom,能省掉大量无意义的排查时间。
5.2 等待策略:无脑 sleep 是隐形炸弹
Selenium 自动化里最常见的失败就是元素还没渲染就被定位。新手上路最喜欢Thread.sleep(2000),改一次跑一次,越跑越慢,越慢越改。Cucumber Step 本身有执行超时和重试机制,但不解决等待问题。
我建议三步走:能定位就定位,定位不到就用 WebDriverWait 显式等待,再不行才考虑隐式等待兜底。
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10)); wait.until(ExpectedConditions.elementToBeClickable(By.id("loginBtn")));一个实际的教训:线上环境偶发请求慢,睡眠 3 秒时大概率通过,但网络抖动时照样挂;换成显式等待后,用例不再依赖固定的停顿,单条用例耗时还平均降了 1.8 秒。等待策略不是优化项,是稳定性项目里的第一优先级。
5.3 Hook 和步骤隔离:@Before/@After 的“副作用”
Cucumber 的@Before、@After与 JUnit 的注解名字相同,但语义完全不同。Cucumber 的 Hook 是面向场景的:每个 Scenario 执行前跑@Before,执行后跑@After。这并不是坏事,但如果 Hook 里做了创建数据库连接、清空缓存表、启动浏览器等耗时操作,整个测试套件的性能会肉眼可见地下降。
我踩过的坑是:为了登录方便,在@Before里把所有公共数据都初始化了一遍;后来场景变多,每条用例启动都被拖到四五秒,一个 200 条场景的套件跑出近二十分钟。把公共离线数据放到@BeforeAll级别,只在针对性的场景里单独建数据,时间直接砍半。Hook 的使用原则应该是“越轻越好,能不用就不用”。
5.4 Step 匹配失败:ambiguous steps 与正则陷阱
Cucumber 里最折磨人的报错之一是AmbiguousStepException——多个 Step Definitions 匹配同一个句子,Cucumber 不知道该挑哪个执行。出现这种问题的原因通常是正则写得太宽泛:
@When("用户输入用户名 {string} 和密码 {string}")和
@When("用户输入用户名 {string} 和手机号 {string}")如果两个句子文本没有足够区分度,Cucumber 表达式推进会报二义性。解决办法是句子写得具体到业务动作,比如“用户使用账号密码登录”和“用户使用手机验证码登录”,字面上先分开。正则技巧再高,都不如业务语义清晰省事。
6. 进阶玩法:从“能用”升级到“好用”的实战习惯
6.1 Scenario Outline:一套逻辑验证多组数据
真实项目中往往要多组数据验证同一个规则,比如登录成功、密码错误、账号锁定。为每组数据写一个完整 Scenario 显得很啰嗦,Scenario Outline就是为这种场景设计的。
Feature: 登录规则校验 Scenario Outline: 登录结果校验 Given 用户访问登录页面 When 用户输入用户名 "<username>" 和密码 "<password>" And 用户点击登录按钮 Then 用户应该看到 "<expected>" Examples: | username | password | expected | | demo | 123456 | 欢迎回来 | | demo | wrong | 用户名或密码错误 | | locked | 123456 | 账号已被锁定 |这份文件读起来像一个需求表格,业务方完全能看懂。测试数据与步骤实现分离后,新增一组数据只需要在Examples表格加一行,代码零改动。这个能力是普通 JUnit 参数化测试替代不了的——参数化的是某段代码,而这里参数化的是整个业务场景。
6.2 DataTable:多实体数据传入,一步到位
某些场景需要批量准备数据,比如下单时要同时传商品、地址、优惠券信息。Cucumber 的 DataTable 可以像表格一样传参:
Given 用户准备购买以下商品 | 商品 | 单价 | 数量 | | 键盘 | 129 | 1 | | 鼠标 | 89 | 2 |Step 里用DataTable接收并做列表映射:
@Given("用户准备购买以下商品") public void prepareCart(DataTable dataTable) { List<Map<String, String>> rows = dataTable.asMaps(String.class, String.class); for (Map<String, String> row : rows) { System.out.println(row.get("商品") + " " + row.get("单价") + " " + row.get("数量")); } }DataTable 适合两件事:批量页面录入、批量接口参数组装。相比写死一组组参数,表格形式更直观,后续维护数据的人不需要懂代码。
6.3 Tags:让同一套用例按需拆成多套执行计划
Cucumber 的 Tag 机制用来划分执行子集非常顺手。我在项目里通常定义三类:@smoke冒烟集、@critical核心链路、@slow慢用例。Runner 里用 Tag 表达式过滤。
@ConfigurationParameter(key = FILTER_TAGS_PROPERTY_NAME, value = "@smoke and not @slow")这样开发本地跑@smoke,CI 回归跑@critical,定时任务跑全量。Tag 表达式的语法支持and/or/not,写起来像条件查询。一个明智的做法是:需求上线前只跑@smoke加@critical,快速给出通过结论;全量回归放到深夜定时任务,配合报告告警。把什么用例放在哪个 Tag 下,本身就是测试策略的体现。
6.4 把 Feature 文件当成需求的活文档来维护
讲到最后,说点跟“自动化测试框架”本身关系不大、但影响非常大的事情:Feature 文件的维护节奏。很多团队的 Feature 文件是上线前突击写完的,之后需求一改,用例就腐化,最后没人跑、没人更新,Cucumber 形同虚设。
我现在的习惯是需求迭代开始时新建 Feature 文件,和开发同步更新;需求评审时用 Feature 文件作为讨论底稿;提测阶段 Feature 里描述的验收场景就是测试用例。需求有变,先改 Feature,再改实现。这样能保证 Feature 文件永远是最新、最可读的验收文档,自动化测试框架才真正变成团队的通用语言。
最后分享一个我自己的习惯:现在每接一个新需求,我第一件事不是写代码,而是先打开 Cucumber,把 Given/When/Then 按业务描述出来,发给产品和开发确认。确认的过程比写代码的过程更能暴露需求理解偏差。这个习惯帮我减少了很多返工——有一次产品看了 Gherkin 才发现自己原始需求漏了一个“未登录状态也要允许加入购物车”的规则,当场补上了。后来我越发觉得,Cucumber 最大的价值不在测试本身,而在它逼着团队在写代码之前,先把“系统该干什么”这件事说清楚。