
我第一次用 Cucumber是在一个购物网站自动化改造项目里。当时团队已经维护了一套 Selenium 脚本用例数量不少但产品经理和项目负责人每次验收都要另开一场会逐条解释“这个脚本到底验证了什么”。直到我们把用例全部改成 Gherkin 格式写进 Feature 文件产品经理第一次自己读懂了回归报告。那一刻我才真正确认Cucumber 的价值不是让程序员少写代码而是把“需求规格”变成“可执行文档”。这篇文章就是一份我自己的 Cucumber 参考笔记。我会从 BDD 思路讲起把 Gherkin 语法、项目搭建、步骤定义写法、参数绑定、Hooks、数据表、并行执行、常见报错这些东西串成一条完整链路。内容适合刚接触 Cucumber 的测试开发也适合用了几个月但一直靠复制粘贴维护 Step Definition 的同学。看完你应该能独立搭一个 Cucumber 项目并且知道遇到问题去哪里排查。1. 先弄懂Cucumber 到底解决什么问题1.1 从一份所有角色都能看懂的需求单说起传统的自动化测试脚本写出来是这样的打开浏览器、定位元素、输入用户名、输入密码、点击登录、断言 URL 发生了变化。代码本身没问题但它天然是技术语言。研发能看测试能维护唯独业务方看不懂。更麻烦的是测试脚本里的每一步到底对应需求文档里的哪一条验收标准往往没人说得清楚。Cucumber 走的是另一条路。它要求你先用 Gherkin 语法写一个 Feature 文件比如功能用户登录 场景使用正确的用户名和密码登录 假如我在登录页面 当我输入正确的用户名和密码 并且我点击登录按钮 那么我应该看到用户中心页面这段文字产品经理能看懂研发能看懂测试也能看懂。然后 Cucumber 会把这些自然语言和 Step Definition步骤定义代码做绑定把“当我输入正确的用户名和密码”映射到一段真正的测试代码去执行。所以 Cucumber 不是测试框架的替代品它更像是“需求描述”和“自动化执行”之间的一座桥。1.2 BDD 的核心把沟通成本变成规范成本BDD行为驱动开发它的核心不是工具而是协作方式。传统流程里需求经过产品经理转述、研发理解、测试拆解每一步都有信息损耗。BDD 要求大家坐下来用一种共享语法把“行为”写下来形成一份所有角色都认可的规范。Cucumber 就是 BDD 最常见的落地工具。它在技术实现上其实很简单Gherkin 解析器把 Feature 文件转成抽象语法树场景里的每一步句子都会和 Step Definition 方法上声明的正则表达式或 Cucumber Expression 做匹配匹配成功就调用对应方法。底层再交给 JUnit 或 TestNG 去驱动执行。理解这个机制很重要后面遇到“步骤匹配不上”“参数解析失败”这类报错就不会手忙脚乱。1.3 什么项目适合用 Cucumber适合用的场景有这几个业务规则复杂比如支付、优惠券、审批流团队里有需要对齐口径的产品和测试回归频率高希望需求变更能在 Feature 文件里留下痕迹以及跨端场景多一套 Gherkin 描述可以在 Web、App、接口层复用。不太适合的场景也有。如果你的用例全是简单接口探测没有业务故事驱动或者团队没人愿意维护“第二份语言”Feature 文件写完后很快失真又或者流程很短产品经理和研发天天坐在一起沟通成本本来就低。这种情况下硬上 Cucumber往往只会多一层维护负担。2. 把 Gherkin 语法吃透Feature 文件才有参考价值2.1 基础关键字Feature、Scenario、Given、When、ThenGherkin 的关键字不多但每个都有固定含义。Feature 是文件级描述写的是被测功能的整体名称Scenario 是具体场景一条场景就是一条测试用例Given 描述前置条件When 描述触发动作Then 描述预期结果。我见过很多团队把 Given 当测试步骤用写成“当我打开浏览器”“当我点击按钮”严格说这不对。Given 是“系统处于某个状态”When 才是“用户做了某个动作”。区分它们不是因为语法洁癖而是因为 Cucumber 报告是按照 Given/When/Then 分块的混在一起看报告时很难快速定位是环境问题还是断言问题。还有 And 和 But它们用来连接同类步骤。功能订单结算 场景使用优惠券后金额正确 假如我已经登录 并且我的购物车里有 2 件商品 并且我有一张满 100 减 20 的优惠券 当我进入结算页面 并且我选择使用该优惠券 那么结算金额应该是 180 元And 减少重复的前缀让场景读起来更像自然语言。But 则表达“转折”比如但是优惠券不能叠加使用2.2 Background、Scenario Outline 和 Examples一个 Feature 文件里通常有多个场景它们可能共享同一组前置条件。与其在每个场景里重复写不如用 Background 提取。功能用户中心 背景 假如我已经登录系统 并且我访问用户中心首页 场景查看个人信息 那么我应该看到我的昵称和头像 场景修改手机号 当我点击修改手机号 并且输入新的手机号码 那么系统应该发送验证码Background 适合放那些“所有场景都不变”的公共前置。一旦它开始出现“有些场景需要、有些不需要”的情况记得不要硬塞进去改用 Tag 配合 Hooks 来处理。Scenario Outline 是参数化利器。同样的场景流程数据不同结果不同。场景大纲不同会员等级享受不同折扣 假如我的会员等级是 等级 当我要购买一件 价格 元的商品 那么应付金额应该是 折扣价 元 示例 | 等级 | 价格 | 折扣价 | | 普通 | 100 | 100 | | 金牌 | 100 | 90 | | 钻石 | 100 | 80 |用 变量名 做占位符由 Examples 表格里的列名填充。一条大纲可以派生出几十条用例维护成本却只有一份。2.3 写 Feature 时一定要守住的三个规矩第一场景名称别写测试动作。比如“验证登录功能”这是个主题描述不是行为描述。更好的是“使用正确密码登录成功”“使用错误密码登录失败”。到了报告里场景名称就是测试用例名写得含糊失败后根本看不出哪个业务片段出了问题。第二避免魔法值满天飞。比如“当我输入 123456”123456 到底是什么是手机号还是验证码用 占位符 或者语义化文案替代比如“当我输入验证码 123456”。这样报告和日志的可读性会高很多。第三保持场景独立性。一个场景不要依赖另一个场景运行后的状态更不要依赖执行顺序。Cucumber 虽然支持执行顺序但业务上不应当依赖前一个场景留在系统里的脏数据。这跟单元测试的独立性是一个道理。3. 从零搭一个 Cucumber-JVM 工程3.1 Maven 依赖和插件配置现在 Java 技术栈里最主流的是 Cucumber-JVM配合 JUnit 运行。我用 Maven 举例子Gradle 的依赖坐标也一样。properties cucumber.version7.15.0/cucumber.version junit.version5.10.0/junit.version /properties dependencies dependency groupIdio.cucumber/groupId artifactIdcucumber-java/artifactId version${cucumber.version}/version scopetest/scope /dependency dependency groupIdio.cucumber/groupId artifactIdcucumber-junit-platform-engine/artifactId version${cucumber.version}/version scopetest/scope /dependency dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter/artifactId version${junit.version}/version scopetest/scope /dependency /dependenciesCucumber 7 默认基于 JUnit Platform现在基本推荐这套配置。早期 Cucumber 6 用cucumber-junit配RunWith(Cucumber.class)也能跑但和 JUnit 5 的生态兼容不如 JUnit Platform 自然。如果你的工程已经很老保持老配置没问题新项目建议直接用 JUnit Platform。3.2 标准目录结构一般团队约定把 Feature 文件放在src/test/resources/featuresStep Definition 放在src/test/java下按包分层。我的习惯是这样src/test/ ├── java/ │ └── com/example/test/ │ ├── steps/ │ │ ├── LoginSteps.java │ │ └── OrderSteps.java │ └── RunCucumberTest.java └── resources/ └── features/ ├── login/ │ └── login.feature └── order/ └── order.featureFeature 文件按业务模块分目录Steps 按页面或领域对象分文件。不要一个文件装下所有场景也不要一个 Step Definition 类装下所有步骤。3.3 JUnit Runner 的写法如果用 JUnit Platform启动类可以很简单package com.example.test; import org.junit.platform.suite.api.ConfigurationParameter; import org.junit.platform.suite.api.IncludeEngines; import org.junit.platform.suite.api.SelectClasspathResource; import org.junit.platform.suite.api.Suite; import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME; import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME; Suite IncludeEngines(cucumber) SelectClasspathResource(features) ConfigurationParameter(key GLUE_PROPERTY_NAME, value com.example.test) ConfigurationParameter(key PLUGIN_PROPERTY_NAME, value pretty, html:target/cucumber-report.html) public class RunCucumberTest { }关键参数就两个SelectClasspathResource指定 Feature 所在的 classpath 路径GLUE_PROPERTY_NAME指定 Step Definition 的包名。这两个对不上就会报告上装不到步骤定义。3.4 第一个能跑的 Feature 和 Step Definition写一个最基础的例子。功能计算器 场景两数相加 假如我有数字 3 并且我有数字 5 当我将它们相加 那么结果应该是 8对应步骤定义package com.example.test; import io.cucumber.java.en.Given; import io.cucumber.java.en.When; import io.cucumber.java.en.Then; import static org.junit.jupiter.api.Assertions.assertEquals; public class CalculatorSteps { private int a; private int b; private int result; Given(我有数字 {int}) public void iHaveNumber(int number) { if (a 0) { a number; } else { b number; } } When(我将它们相加) public void iAddThem() { result a b; } Then(结果应该是 {int}) public void resultShouldBe(int expected) { assertEquals(expected, result); } }跑 RunCucumberTestCucumber 会自动扫描 features 目录解析 Gherkin匹配 Step Definition 并执行。{int}是 Cucumber Expression 的整数参数类型它不只是正则还包含类型转换。IDE 方面IntelliJ IDEA 安装 Cucumber for Java 插件可以直接右键 Feature 文件运行还支持从 Feature 步骤跳转到 Step Definition。这对调试帮助很大。4. 步骤定义里的几个进阶玩法4.1 参数绑定正则表达式和 Cucumber ExpressionCucumber 匹配步骤有两种方式一种是老式正则比如我有数字 (\\d)另一种是 Cucumber Expression比如我有数字 {int}。Cucumber 7 默认推荐 Expression它可读性更好类型转换也更直接。常用的内置参数类型包括参数类型匹配范围转换结果{int}整数Integer{float}浮点数Double{word}不带空格的单字String{string}被双引号包起来的内容String{bigdecimal}高精度数字BigDecimal用{word}时要注意它不能匹配带空格的内容。如果步骤里要传“张三 李四”这种带空格的字符串就用{string}并配合引号当我输入用户名 张三 李四 时。4.2 DataTable 和 DocString处理多行数据有些场景的前置条件或断言数据不止一行比如批量创建用户、比对订单清单。这时候用 DataTable 很合适。场景批量创建用户 当管理员批量创建以下用户 | 用户名 | 手机号 | 角色 | | zhangsan | 13800000001 | 普通用户 | | lisi | 13800000002 | 管理员 | 那么系统应该创建成功 2 个用户步骤定义里可以这样收import io.cucumber.datatable.DataTable; When(管理员批量创建以下用户) public void adminCreatesUsers(DataTable dataTable) { ListMapString, String rows dataTable.asMaps(String.class, String.class); for (MapString, String row : rows) { String username row.get(用户名); String phone row.get(手机号); String role row.get(角色); // 调用接口或操作页面 } }asMaps返回按表头组织的 Map 列表代码阅读直观。也可以直接转成ListListString适合不关心表头的场景。DocString 则适合传一段较长文本比如 JSON 请求体。用三引号写出来场景创建订单 当用户提交以下订单数据 {userId: 1, items: [{sku: A100, count: 2}]} 那么系统应该返回订单号步骤定义里DocString 形参会自动作为 String 传入。它的好处是避免在 Feature 文件里写一长串难看的转义字符串。4.3 Tags 和 Hooks控制执行范围与生命周期Tags 的作用是在一个维度的用例集合上打标记。比如冒烟测试、回归测试、依赖环境的用例。smoke login 功能用户登录 场景正确密码登录成功 ...配置运行时只跑smoke标签cucumber.filter.tagssmoke多个标签组合条件也可以比如smoke and not (slow)这种表达式是支持的。Hooks 是另一个高频能力。Before在每个场景之前执行After在每个场景之后执行。可以配合 Tag 做精细控制比如import io.cucumber.java.Before; import io.cucumber.java.After; public class Hooks { Before(smoke) public void setupSmokeData() { // 准备冒烟测试的干净数据 } After public void cleanup(Scenario scenario) { if (scenario.isFailed()) { // 截图写日志清理脏数据 } } }注意 Hooks 不属于某个步骤它是全局或按 Tag 生效的。不要在Before里放跟具体场景业务强相关的准备逻辑那样会让场景依赖隐形的外部配置降低可读性。5. 一个完整实战场景登录加下单接口链路5.1 场景描述跨模块的业务语序纸上谈兵不好讲我们用一个登录加下单的接口链路例子走一遍。假设系统有用户服务和订单服务测试要覆盖登录拿 Token、用 Token 下单、校验订单创建结果。api 功能用户下单链路 场景登录后成功创建订单 当用户通过手机号 13800000001 和密码 123456 进行登录 那么登录接口应该返回成功 并且返回的令牌应该不为空 当用户携带该令牌创建一个 A100 商品订单 那么订单接口应该返回创建成功 并且订单状态应该是待支付注意我在这里用了两个“当”这在语义上是允许的代表两个连续的用户动作。Cucumber 语法并不限制 When 只能出现一次。5.2 步骤定义实现状态传递是重点现在要写步骤定义。链路场景里有状态传递Token 从登录步骤拿到的要在下单步骤里使用。我不能用静态变量满天飞但简单的场景下可以用一个共享 Context 来存。package com.example.test.steps; import io.cucumber.java.en.Then; import io.cucumber.java.en.When; public class OrderSteps { private final TestContext context; public OrderSteps(TestContext context) { this.context context; } When(用户通过手机号 {string} 和密码 {string} 进行登录) public void login(String phone, String password) { // 实际调用登录接口 context.setToken(mock-token-value); } Then(登录接口应该返回成功) public void loginShouldSucceed() { // 断言登录返回状态 } Then(返回的令牌应该不为空) public void tokenShouldNotBeEmpty() { if (context.getToken() null || context.getToken().isEmpty()) { throw new AssertionError(token 为空); } } When(用户携带该令牌创建一个 A100 商品订单) public void createOrderWithToken() { String token context.getToken(); // 调用下单接口携带 Token } Then(订单接口应该返回创建成功) public void orderShouldSucceed() { } Then(订单状态应该是待支付) public void orderStatusShouldBePending() { } }这里必须用TestContext这个结构它其实是一个共享状态的容器。我习惯把每个线程需要的临时数据都放在里面这样在多线程并行执行时不会数据串味。共享静态变量是初学者经常踩的坑后面会专门讲。5.3 运行报告和失败定位执行完成后Cucumber 默认生成的 HTML 报告会列出每个场景的执行时间、状态、失败步骤、错误堆栈。查看时我一般先看场景名称再看失败步骤最后看错误堆栈。如果错误是Undefined Step说明 Gherkin 里的这句话没人认领先去 Step Definition 里搜文案。如果是AssertionError说明业务断言没通过再去查接口返回或页面状态。报告里加上时间统计也有用可以在After里记录Scenario对象的总执行耗时帮助发现哪些场景越来越慢。6. 长期维护时最重要的四件事6.1 Step Definition 不要堆成上帝类我见过一个项目里所有步骤全堆在CommonSteps.java里几千行。看起来省事实际上谁也不愿意改因为改一个方法可能影响几十个场景。我的做法是按业务域拆类LoginSteps、OrderSteps、PaymentSteps、UserSteps。就算暂时有些重复也值得。另外Step Definition 方法名要跟步骤文案互相呼应不要写method1()。当 IDE 搜索时一个清晰的命名瞬间就能定位。6.2 “正则脑”和“场景文档”都要防腐化Feature 文件一旦写成就要当成需求文档一样持续维护。业务变了Feature 文本先改Step Definition 跟着改。最怕的是研发直接改 Step Definition 去适配新代码Feature 文件却原封不动。这样报告虽然绿了Cucumber 作为沟通桥梁的价值就没了。我建议每次 MR 评审时如果涉及业务逻辑变更检查清单里一定要有一条Feature 文件是否描述了新行为。这个习惯在团队里能省很多后期解释成本。6.3 并行执行与线程安全用例多了以后串行执行会很痛苦。Cucumber 7 支持并行执行JUnit Platform 配置里可以设置cucumber.execution.parallel.enabledtrue cucumber.execution.parallel.config.strategyfixed cucumber.execution.parallel.config.fixed.parallelism4这样会在多个线程里同时跑场景。但代价是你的代码必须线程安全。我前面提到 TestContext 按线程隔离就是为了应对并行。如果你在 Step Definition 里用 static 变量存数据并行时一定互相污染。另外数据库里的共享数据也要注意。多个场景同时跑可能同时占用同一个测试用户、同一张优惠券导致断言失败。我在项目里会把测试数据随机化比如用户手机号带时间戳后缀。6.4 失败重试不要乱加测试不稳定是常态但一失败就重试很容易掩盖真实问题。Cucumber 本身没有内置失败重试需要额外实现或引入第三方插件。我的建议是只在偶发环境问题网络抖动、外部服务超时的用例上做重试并且重试次数限制在 1-2 次同时把重试结果单独标记。不要对整个回归套件无脑重试。7. 高频问题排查速查表问题出现原因解决办法Undefined StepFeatures 里某句话没有对应 Step Definition在 Step Definition 中补上Given/When/Then方法注意副本文案与功能文案格式一致Ambiguous Step多个 Step Definition 匹配同一句话检查两个方法的表达式缩小匹配范围或改用{word}而非(.*)参数解析失败{int}遇上非数字内容把参数类型改成{string}或修正文本里的参数值中文乱码文件编码不是 UTF-8把 Maven 的project.build.sourceEncoding设置为 UTF-8IDEA 的文件编码也要改场景执行顺序影响结果场景间共享了数据或者使用了静态状态清理测试数据携带 context避免依赖执行顺序在 IDEA 里跑不了 Feature没有安装 Cucumber for Java 插件或版本不匹配安装插件并确认项目依赖是同一大版本并行执行出现随机失败测试数据或状态跨线程共享使用线程隔离的 TestContext随机化测试数据7.1 遇到 Undefined Step 时怎么看才快打开报告或者控制台它会直接列出找不到的那句话。比如Undefined Step: 当我输入正确的用户名和密码最快的做法是把这句话原样复制到代码库里搜索看看有没有方法已经写了但正则没匹配上或者直接新建一个方法。注意中英文标点和空格When 我输入和When我输入是完全不同的两句话。7.2 正则匹配不上的经典坑比较常见的是“过度使用(.*)”。(.*)是贪婪匹配可能一下子吞掉后面所有字符导致后面的参数解析错位。如果你只是匹配一个单词用{word}匹配一个带引号的字符串用{string}只要整数用{int}。范围越小越不容易出歧义。还有一个坑是转义字符。正则里括号、点号都是特殊字符要匹配字面字符必须转义。Cucumber Expression 里很多场景不需要写正则宁愿放弃一点灵活性也别找麻烦。7.3 报告出现乱码时急救Cucumber HTML 报告出现乱码我遇到的大多数原因是源文件编码问题。IDEA 默认 UTF-8但 Windows 环境下个别文件可能被存成了 GBK。处理时统一转换编码并在 pom.xml 里强制源文件和资源文件的编码project.build.sourceEncodingUTF-8/project.build.sourceEncoding project.reporting.outputEncodingUTF-8/project.reporting.outputEncoding乱码不在代码里在文件存储层面转换之前最好先确认所有文件的 Bytes不要只改 IDEA 显示编码。7.4 并行下的共享状态排查思路如果你开了并行随机偶发失败并且一一执行全部通过基本就是共享状态问题。排查顺序是先查 Step Definition 里有多少 static 变量再检查 TestContext 是不是 ThreadLocal然后看测试数据表里的手机号、优惠券、订单号有没有重复最后看有没有对同一个文件或数据库记录做并发写。前两个是代码问题后两个是数据问题都要逐一排除。8. 一点真实的个人体会维护了几年 Cucumber 项目我最大的体会是Cucumber 的技术门槛真的不高高的是持续维护的纪律性。Feature 文件不是写一次就完了每次业务调整、每个边界条件补充都应该先改 Feature 再改代码。如果你能做到所有角色都愿意打开 Feature 文件对话Cucumber 的威力才真正发挥出来。如果你的团队只是把它当成一个脚本翻译器那它会比普通自动化脚本更让人头大。最后分享一个小技巧我习惯在每个 Feature 文件开头加一段简单的“业务背景”注释说明这条链路属于哪个业务模块、关联了哪些系统、数据依赖是什么。几个月后你自己回来维护就靠这段注释快速捡起上下文。这个习惯帮我避免了很多次“这场景到底在验什么”的尴尬。