【Java劝退师】Cucumber 黄瓜测试 BDD 从入门到精通

3,040 阅读12分钟

一、简介

官方文档:cucumber.io/docs/guides… 官方视频教学(全英文):school.cucumber.io/courses/bdd…

1. Cucumber

Cucumber 是 BDD(Behavior-Driven Development,行为驱动开发)的一个自动化测试工具,使用自然语言来描述测试用例,使得 非研发(QA、PM)也可以理解甚至编写 测试用例。

官方表示:应该将 Cucumber 视为一个【文档编写工具】,而非一个单纯的自动化测试工具

  • 撰写时,应该要以 PM 也能理解 测试用例 为目标去编写 Cucumber

2. Gherkin

Gherkin 是 Cucumber 用来描述 测试用例 的语言,以下为关键字的用意与关联关系。

以 分享概览 为例

  • Given:新增 帐号 abc@qq.com 、新增 概览1
  • When:将 概览1 分享给 帐号 abc@qq.com
  • Then:校验 帐号 abc@qq.com 是否能查看到 概览1

2.1 Scenario 范例

Feature: 授权功能  
  
  Scenario: 帐号 通过绑定 角色 进行授权  
  
    Given 新增角色 '分析师',拥有权限 '1001, 1002, 1003'  
    And 新增帐号 'abc@qq.com'  
  
    When 帐号 'abc@qq.com' 绑定角色 '分析师'  
  
    Then 鉴权 帐号 'abc@qq.com',有权限 '1001'  
    And  鉴权 帐号 'abc@qq.com',有权限 '1002'  
    But  鉴权 帐号 'abc@qq.com',没有权限 '1005'

2.2 Background 范例

Feature: 授权功能  

  Background:   
    Given 新增帐号 'abc@qq.com'  


  Scenario: 帐号 通过绑定 角色 进行授权  
  
    Given 新增角色 '分析师',拥有权限 '1001, 1002, 1003'  
  
    When 帐号 'abc@qq.com' 绑定角色 '分析师'  
  
    Then 鉴权 帐号 'abc@qq.com',有权限 '1001'  
    And  鉴权 帐号 'abc@qq.com',有权限 '1002'  
    But  鉴权 帐号 'abc@qq.com',没有权限 '1005'  
  
  
  Scenario: 帐号 通过绑定 机构 进行授权  
  
    Given 新增机构 '北京部门',拥有权限 '2001, 2002, 2003'  
  
    When 帐号 'abc@qq.com' 绑定机构 '北京部门'  
  
    Then 鉴权 帐号 'abc@qq.com',有权限 '2001'  
    And  鉴权 帐号 'abc@qq.com',有权限 '2002'  
    But  鉴权 帐号 'abc@qq.com',没有权限 '2005'

2.3 Scenario Outline 范例

可以在多个 Step 上共用同一个 "简单" 参数,且每一个 Example 都视为一个 Scenario

Feature: 授权功能  
  
  Scenario Outline: 帐号 通过绑定 角色 进行授权  
  
    Given 新增角色 <role>,拥有权限 <permissions>  
    
    When 新增帐号 <account>  
    
    Then 帐号 <account> 绑定角色 <role>  
    And  鉴权 帐号 <account>,有权限 <has_permission>  
  
    Examples:  
	    | role   | permissions      | account    | has_permission |  
	    | 分析师  | 1001, 1002, 1003 | abc@qq.com | 1001           |  
	    | 开发者  | 2001, 2002, 2003 | cde@qq.com | 2001           |  
	    | 管理员  | 3001, 3002, 3003 | fgh@qq.com | 3001           |

3. 基本概念

3.1 文件结构

  • Gherkin 写在 .feature 文件中
  • Step 对应的逻辑 写在 .java 文件中

3.2 Step 映射

通过 Gherkin 语法上的描述,找到与 注解 value 值匹配的 Java 方法,将 Gherkin 与 Java 代码关联起来。

3.3 Scenario 独立

  • 当同时执行多个 Scenario 时,执行每个 Scenario 对应的 Java 文件都会被重新创建。
  • 不同的 Scenario 之间,不应该存在数据依赖(MySQL),如果存在依赖,将会使 Scenario 变得脆弱
    • 可以在 Backgroud,进行数据清理,来保证测试结果的正确性

二、最佳实践

1. 撰写 Scenairo 原则 - BRIEF

school.cucumber.io/courses/tak…

B:Business Language。

  • Scenairo 中使用的词语应该使用【业务团队成员】能够理解的词语,否则将无法与业务团队成员互动。

R:Real Data。

  • Scenairo 中应该使用 具体、真实 的数据(不要用 1、2、3、A、B、C),有助于让场景变得生动,并及早揭示边界条件与基本假设。

I:Intention Revealing。

  • Scenairo 应该描述试图实现的意图,而不是描述程式将如何实现它的机制。
  • 确保每一行 Step 描述的是 意图 而非 机制。 (比如:创建帐号,就不要写成 "将帐号数据写入 user 表,并在 account_project 表绑定帐号与项目的关联")

E:Essential。

  • Scenairo 应该只保留必要的 Step,不直接促成结果的场景都应该被删除。
  • 任何不能增加读者对预期行为理解的场景,都不應該出现在文档中。

F:Focus。

  • 多数的 Scenairo 应该只专注于单一职责。

BRIEF

  • 建议将大多数的 Scenairo 限制在五行或更少,这将使它们更易于阅读与推理,并有助于避免 同时测试多个规则 或 增加额外细节。

2. 保证 Scenairo 可读性好处

school.cucumber.io/courses/tak…

  • 随时获得 你做的事情是否正确 的反馈
  • 你的 Feature 可以变成描述你 系统功能 的 线上文档
  • Scenairo 将会引导你的技术设计

3. 开发流程推荐

school.cucumber.io/courses/tak…

  1. 在 Cucumber 中描述你想要实现的 Scenairo,把所有的 Step 串连起来,并运行 Cucumber 使其出现 失败 结果
  2. 持续实现 Step 与 API 的具体逻辑,并观察 API 是如何 失败 的,最终使 Scenairo 的结果变为 成功。
  3. 当测试通过后,对 API 实现进行 清理 与 优化(重构),使其更具可读性,并再次运行 Cucumber 保证重构后的结果正确。

以上为 测试驱动开发(TDD) 的 生命周期: Red、Green、Clean

  • 如果改坏了系统逻辑,你的测试用例会告诉你。

推荐:在研发进行技术设计前,再多加一个 测试用例评审 的环节,让 PM、QA、RD 一起参与,方便及早发现问题,也能增加技术设计时考量的全面性

三、Cucumber 常用功能

1. 参数化

参数化 可以与 表格化、列表化、对象化 混用

1.1 关键字

类型正则
biginteger"-?\d+" 或者 "\d+"
string"([^"\](\.[^"\])*)"
bigdecimal"-?\d*[.,]\d+"
byte"-?\d+" 或者 "\d+"
double"-?\d*[.,]\d+"
short"-?\d+" 或者 "\d+"
float"-?\d*[.,]\d+"
word"\w+"
int"-?\d+" 或者 "\d+"
long"-?\d+" 或者 "\d+"

1.2 说明

Cucumber 支持在 Java 注解 中使用 {关键字} 作为占位符。 在 Step 中直接写上参数,将在 Java 代码中,会把占位符对应的参数作为方法参数传递进去。

  • 字串 类型的 关键字,需要加上 单引号 或 双引号 作为声明
  • 注解中声明占位符的顺序 为 注入方法参数的顺序

1.3 范例

Feature: 授权功能  
  
  Scenario: 鉴权场景  
    
    Given 创建帐号 'waiting001@qq.com',角色 'admin',项目id 1

@Given("创建帐号 {string},角色 {string},项目id {int}")  
public void test(String username, String role, Integer projectId) {  
    log.info("start to execute test(), params:[ username = {}, role = {}, projectId = {} ]",  
            username, role, projectId);  
}

2. 表格化(DataTable)

2.1 设置 Gherkin 数据

下方的 List - Map、List - List、Map - List 都是共用同一套 Gherkin 代码,也就是说,同一个 Gherkin 代码,Cucumber 可以根据不同的方法参数类型,自动进行转换

image.png

Feature: 授权功能  
  
  Scenario: 鉴权场景  
  
    Given 创建帐号  
      | username          | password | role      | project_id |  
      | waiting001@qq.com | a123456  | admin     | 1          |  
      | waiting002@qq.com | b123456  | analyst   | 2          |  
      | waiting003@qq.com | c123456  | developer | 3          |

2.2 List - Map(常用)

image.png

@Given("创建帐号")  
public void test(List<Map<String, String>> dataTable) {  
  
    for (Map<String, String> data : dataTable) {  
        String username = data.get("username");  
        String password = data.get("password");  
        String role = data.get("role");  
        String projectId = data.get("project_id");  
        log.info("execute test(), fields:[ username = {}, password = {}, role = {}, projectId = {} ]",  
                username, password, role, projectId);  
    }  
  
}

2.3 List - List

image.png

@Given("创建帐号")  
public void test(List<List<String>> dataTable) {  
  
    for (List<String> data : dataTable) {  
        log.info("execute test(), fields:[ data = {} ]", data);  
    }  
  
}

image.png

2.4 Map - List

@Given("创建帐号")  
public void test(Map<String, List<String>> dataTable) {  
  
    for (Map.Entry<String, List<String>> data : dataTable.entrySet()) {  
        String username = data.getKey();  
        List<String> infos = data.getValue();  
        log.info("execute test(), fields:[ username = {}, infos = {} ]", username, infos);  
    }  
  
}

3. 列表化

3.1 直列表

Feature: 授权功能  
  
  Scenario: 鉴权场景  
  
    Given 删除帐号  
      | waiting001@qq.com |  
      | waiting002@qq.com |  
      | waiting003@qq.com |

image.png

@Given("删除帐号")  
public void test(List<String> usernames) {  
    log.info("execute test(), fields:[ usernames = {} ]", usernames);  
}

1649931504164

3.2 横列表

Feature: 授权功能  
  
  Scenario: 鉴权场景  
  
    Given 删除帐号  
      | waiting001@qq.com | waiting002@qq.com | waiting003@qq.com |

@Given("删除帐号")  
public void test(@Transpose List<String> usernames) {  
    log.info("execute test(), fields:[ usernames = {} ]", usernames);  
}

记得加上 @Transpose,告诉 Cucumber 需要进行数据转换

4. 对象化

4.1 撰写 Gherkin 语法

Feature: 授权功能  
  
  Scenario: 鉴权场景  
  
    Given 新增 帐号  
      | username        | role      | project    |  
      | waiting1@qq.com | admin     | default    |  
      | waiting2@qq.com | analyst   | production |  
      | waiting3@qq.com | developer | default    |

4.2 定义 Java 对象

@Data  
static class Account {  
  
    /**  
     * 帐号  
     */  
    private String username;  
  
    /**  
     * 角色  
     */  
    private String role;  
  
    /**  
     * 项目  
     */  
    private String project;  
  
}

4.3 撰写 封装 Java 对象的 方法

/**  
 * 帐号对象 的 封装方法  
 */  
@DataTableType  
public Account defineAccount(Map<String, String> entry) {  
  
    Account account = new Account();  
  
    // 如果有指定数据,则将数据写入  
Optional.ofNullable(entry.get("username")).ifPresent(account::setUsername);  
    Optional.ofNullable(entry.get("role")).ifPresent(account::setRole);  Optional.ofNullable(entry.get("project")).ifPresent(account::setProject);  
  
    return account;  
}

在封装方法上方,需要加上 @DataTableType 注解

4.4 关联具体 Step 方法

@Given("新增 帐号")  
public void addAccount(List<Account> accountList) {  
  
    for (Account account : accountList) {  
        log.info("execute addAccount(), fields:[ account = {} ]", account);  
    }  
  
}

方法参数中,直接指定 对象封装方法 返回的 对象类型,Cucumber 就能直接进行关联

5. 参数化、表格化、列表化 混合使用

DataTable 与 List 必须作为 Java 方法的最后一个参数

5.1 表格化&参数化

Feature: 授权功能  
  
  Scenario: 鉴权场景  
  
    Given 创建帐号,项目 'default'  
      | username          | password | role      |  
      | waiting001@qq.com | a123456  | admin     |  
      | waiting002@qq.com | b123456  | analyst   |  
      | waiting003@qq.com | c123456  | developer |

@Given("创建帐号,项目 {string}")  
public void test(String project, List<Map<String, String>> dataTable) {  
  
    log.info("execute test(), fields:[ project = {} ]", project);  
  
    for (Map<String, String> data : dataTable) {  
        String username = data.get("username");  
        String password = data.get("password");  
        String role = data.get("role");  
        log.info("execute test(), fields:[ username = {}, password = {}, role = {} ]",  
                username, password, role);  
    }  
  
}

List<Map<String, String>> 必须作为最后一个方法参数

5.2 直列表&参数化

Feature: 授权功能  
  
  Scenario: 鉴权场景  
  
    Given 删除帐号,项目 'default'  
      | waiting001@qq.com |  
      | waiting002@qq.com |  
      | waiting003@qq.com |

@Given("删除帐号,项目 {string}")  
public void test(String projectName, List<String> usernames) {  
    log.info("execute test(), fields:[ projectName = {}, usernames = {} ]", projectName, usernames);  
}

List 必须作为最后一个方法参数

5.3 横列表&参数化

Feature: 授权功能  
  
  Scenario: 鉴权场景  
  
    Given 删除帐号,项目 'default'  
      | waiting001@qq.com | waiting002@qq.com | waiting003@qq.com |

@Given("删除帐号,项目 {string}")  
public void test(String project, @Transpose List<String> usernames) {  
    log.info("execute test(), fields:[ project = {}, usernames = {} ]", project, usernames);  
}

5.4 对象化&参数化

Feature: 授权功能  
  
  Scenario: 鉴权场景  
  
    Given 新增 帐号,机构 '北京部门'  
      | username        | role      | project    |  
      | waiting1@qq.com | admin     | default    |  
      | waiting2@qq.com | analyst   | production |  
      | waiting3@qq.com | developer | default    |

@Given("新增 帐号,机构 {string}")  
public void addAccount(String organization, List<Account> accountList) {  
  
    log.info("execute addAccount(), fields:[ organization = {} ]", organization);  
  
    for (Account account : accountList) {  
        log.info("execute addAccount(), fields:[ account = {} ]", account);  
    }  
  
}

6. 钩子方法(Hook)

6.1 注解

注解执行时机
@BeforeAll在启动 Cucumber 时执行
@Before在所有 Scenario 执行之前执行
@BeforeStep在所有 Step 执行之前执行
@AfterAll在结束 Cucumber 时执行
@After在所有 Scenario 执行之后执行
@AfterStep在所有 Step 执行之后执行

指定注解的 value,可以指定 Hook 的运行范围,不指定则表示在全项目生效。 注解可以定义在项目中的任意位置。

如果同时定义了多个钩子方法,则会依照注解中 order 属性的顺序多次执行。

6.2 全局生效范例

Gherkin 代码

Feature: 测试功能  
  
  Scenario: 测试场景1  
  
    Given 测试功能1  
    When  测试功能2  
    Then  测试功能3  
  
  Scenario: 测试场景2  
  
    Given 测试功能1  
    When  测试功能2  
    Then 测试功能3

Java 代码

@Slf4j  
public class TestDefs {  
  
    @Before  
    public void before() {  
        log.info("执行 @Before 方法");  
    }  
  
    @BeforeStep  
    public void beforeStep() {  
        log.info("执行 @BeforeStep 方法");  
    }  
  
    @After  
    public void after() {  
        log.info("执行 @After 方法");  
    }  
  
    @AfterStep  
    public void afterStep() {  
        log.info("执行 @AfterStep 方法");  
    }  
  
    @Given("测试功能1")  
    public void test1() {  
        log.info("执行 测试功能1");  
    }  
  
    @Given("测试功能2")  
    public void test2() {  
        log.info("执行 测试功能2");  
    }  
  
    @Given("测试功能3")  
    public void test3() {  
        log.info("执行 测试功能3");  
    }  
}

最终结果 image.png

6.3 局部生效范例

Gherkin 代码 在不同的 Scenario 上,可以增加 自定义注解 作为标记

Feature: 测试功能  
  
  @MyHook  
  Scenario: 测试场景1  
  
    Given 测试功能1  
    When  测试功能2  
  
  @YourHook  
  Scenario: 测试场景2  
  
    Given 测试功能1  
    When  测试功能2

Java 代码 Hook 注解的 value 值,可以用来指定,只有当 Scenario 上有该自定义注解时,才会执行 Hook 方法。

如果想将 Hook 方法在多个自定义注解下进行复用,可以通过 , 隔开,例如:@Before("@MyHook,@YourHook")

@Slf4j  
public class TestDefs {  
  
    @Before("@MyHook")  
    public void myHookBefore() {  
        log.info("执行 @MyHook 的 @Before 方法");  
    }  
  
    @BeforeStep("@MyHook")  
    public void myHookBeforeStep() {  
        log.info("执行 @MyHook 的 @BeforeStep 方法");  
    }  
  
    @Before("@YourHook")  
    public void yourHookBefore() {  
        log.info("执行 @YourHook 的 @Before 方法");  
    }  
  
    @BeforeStep("@YourHook")  
    public void yourHookBeforeStep() {  
        log.info("执行 @YourHook 的 @BeforeStep 方法");  
    }  
  
    @Given("测试功能1")  
    public void test1() {  
        log.info("执行 测试功能1");  
    }  
  
    @When("测试功能2")  
    public void test2() {  
        log.info("执行 测试功能2");  
    }  
  
}

最终结果 image.png

四、Gherkin 模版范例

0. Gherkin 模版撰写规范

  1. 所有 模版功能 只运行在 测试项目(project_id = 1) (避免有时要指定 project_id 有时又不需要的麻烦)
  2. 使用 Gherkin 参数化 的 字串 功能时,使用 单引号 将字串包裹 (考虑到模版参数可能出现 Json 字串,使用双引号会需要额外进行转译)
  3. 模版参数中,不要出现 id(包含 帐号id、角色id、职务id ... 等),应该改用 名称(用户名、角色中文名称、职务名称)去反查 (使用 id 将无可避免的出现要使用 上下文 将多个 Step 进行关联的问题,这将使测试用例变得脆弱,所以宁可用 名称 去反查 id,也不要直接指定 id)
  4. Gherkin 语法表格化的表头字段,使用 下滑线命名法 来命名
  5. 模版方法名称定义遵守以下规范 (当一个模版可能同时存在于 Given 与 When 时,以 When 为主)
模版关键字说明范例
Given以 [模块][新增or更新or删除] + 空格 开头[帐号][新增] 新增帐号、[角色][更新] 更新角色
When以 [模块][功能] + 空格 开头[权限][功能] 授权数据资源权限、[权限][功能] 删除数据资源权限数据
Then以 [模块][校验] + 空格 开头[权限][校验] 校验帐号权限

随时注意编写的模版是否尽量符合第 2 大项的最佳实践

1. 帐号

通常为 Given 的最后一步,比如 帐号 要绑定 角色,一定是先创建好 角色,再通过 新增帐号 进行绑定

1.1 新增 帐号

(创建前自动删除原来的帐号、创建时允许指定角色)

属性说明必填默认值
username用户名
password密码AAAaaa111@
user_cname帐号中文名称与 username 相同
email信箱与 username 相同
role_cname角色中文名称,默认角色用 role_name 即可admin
# 最简单的范例

Given [帐号][新增] 新增帐号  
  | username          |  
  | waiting001@qq.com |  
  | waiting002@qq.com |  
  | waiting003@qq.com |
  

# 最完整的范例

Given [帐号][新增] 新增帐号  
  | username          | password   | user_cname        | email             | role_cname |  
  | waiting001@qq.com | AAAaaa111@ | waiting001@qq.com | waiting001@qq.com | admin      |  
  | waiting002@qq.com | AAAaaa111@ | waiting002@qq.com | waiting002@qq.com | analyst    |  
  | waiting003@qq.com | AAAaaa111@ | waiting003@qq.com | waiting003@qq.com | developer  |

1.2 更新 帐号 的 角色

Given [帐号][更新] 更新帐号 'waiting001@qq.com' 的角色  
  | 角色A | 角色B | 角色C |

1.3 更新 帐号 的 职务

Given [帐号][更新] 更新帐号 'waiting001@qq.com' 的职务  
  | 职务A | 职务B | 职务C |

1.4 删除 帐号

Given [帐号][删除] 删除帐号  
  | waiting001@qq.com |  
  | waiting002@qq.com |  
  | waiting003@qq.com |  
  | waiting004@qq.com |