1. 概述

Cucumber 是一个行为驱动开发(BDD)框架,允许开发者使用 Gherkin 语言编写文本形式的测试用例。

在实际场景中,我们常常需要为测试注入模拟数据,尤其是当数据结构复杂或条目较多时,直接内联写在步骤里会显得非常臃肿。

本文将介绍如何使用 Cucumber 的数据表(Data Tables)功能,以更清晰、可读的方式组织测试数据。


2. 场景语法

在定义 Cucumber 测试场景时,通常需要在步骤中传入测试数据。例如:

Scenario: Correct non-zero number of books found by author
  Given I have the a book in the store called The Devil in the White City by Erik Larson
  When I search for books by author Erik Larson
  Then I find 1 book

这种写法对于单条数据还行,但如果要添加多本书,语句就会变得冗长且难以维护。

2.1. 数据表的基本用法

为了避免步骤语句过长,我们可以使用数据表来批量传入数据:

Scenario: Correct non-zero number of books found by author
  Given I have the following books in the store
    | The Devil in the White City          | Erik Larson |
    | The Lion, the Witch and the Wardrobe | C.S. Lewis  |
    | In the Garden of Beasts              | Erik Larson |
  When I search for books by author Erik Larson
  Then I find 2 books

关键点

  • 数据表必须缩进在 Given(或其他步骤关键字)下方
  • 数据表可用于任何步骤类型:GivenWhenThenAndBut
  • 每行代表一条记录,列之间用 | 分隔

这种方式可以轻松扩展数据量,增删行即可,非常灵活。

2.2. 添加表头提升可读性

虽然上面的例子中,第一列是书名、第二列是作者,看起来一目了然,但并非所有场景都这么直观。

为了增强可读性,推荐为数据表添加表头:

Scenario: Correct non-zero number of books found by author
  Given I have the following books in the store
    | title                                | author      |
    | The Devil in the White City          | Erik Larson |
    | The Lion, the Witch and the Wardrobe | C.S. Lewis  |
    | In the Garden of Beasts              | Erik Larson |
  When I search for books by author Erik Larson
  Then I find 2 books

⚠️ 注意:表头虽然是第一行,但在后续解析时需要特殊处理——通常需要跳过它,尤其是在使用 List<List<String>> 或自定义转换器时。


3. 步骤定义(Step Definitions)

定义完 Gherkin 场景后,下一步是实现对应的步骤方法。如果某个步骤包含数据表,则其方法签名中必须包含 DataTable 参数:

@Given("some phrase")
public void somePhrase(DataTable table) {
    // ...
}

DataTable 对象封装了表格数据,并提供了多种转换方式。常见的有三种处理方式:

  1. ✅ 转为 List<List<String>> —— 最基础
  2. ✅ 转为 List<Map<String, String>> —— 更易读
  3. ✅ 使用 TableTransformer 转为领域对象 —— 最优雅

为演示这些方式,我们先准备两个基础类。

领域模型类

public class Book {
    private String title;
    private String author;

    public Book() {}

    public Book(String title, String author) {
        this.title = title;
        this.author = author;
    }

    // getter 和 setter 省略 ...
}

书籍管理类

public class BookStore {
    private List<Book> books = new ArrayList<>();
     
    public void addBook(Book book) {
        books.add(book);
    }
     
    public void addAllBooks(Collection<Book> books) {
        this.books.addAll(books);
    }
     
    public List<Book> booksByAuthor(String author) {
        return books.stream()
            .filter(book -> Objects.equals(author, book.getAuthor()))
            .collect(Collectors.toList());
    }
}

测试步骤基类

public class BookStoreRunSteps {
    private BookStore store;
    private List<Book> foundBooks;
    
    @Before
    public void setUp() {
        store = new BookStore();
        foundBooks = new ArrayList<>();
    }

    // When 和 Then 的定义 ...
}

3.1. 转为 List<List>

这是最原始但最通用的方式。适用于没有表头的简单表格。

示例场景:

Scenario: Correct non-zero number of books found by author by list
  Given I have the following books in the store by list
    | The Devil in the White City          | Erik Larson |
    | The Lion, the Witch and the Wardrobe | C.S. Lewis  |
    | In the Garden of Beasts              | Erik Larson |
  When I search for books by author Erik Larson
  Then I find 2 books

Cucumber 会将每行解析为一个 List<String>,整体构成 List<List<String>>

[
    ["The Devil in the White City", "Erik Larson"],
    ["The Lion, the Witch and the Wardrobe", "C.S. Lewis"],
    ["In the Garden of Beasts", "Erik Larson"]
]

Java 实现:

@Given("^I have the following books in the store by list$")
public void haveBooksInTheStoreByList(DataTable table) {
    List<List<String>> rows = table.asLists(String.class);
    
    for (List<String> columns : rows) {
        store.addBook(new Book(columns.get(0), columns.get(1)));
    }
}

⚠️ 如果表格包含表头,必须手动跳过第一行,因为 asLists() 不会自动识别表头。


3.2. 转为 List<Map<String, String>>

当表格包含表头时,使用 List<Map<String, String>> 更加清晰,避免了“第0列是标题”这种硬编码逻辑。

示例场景(带表头):

Scenario: Correct non-zero number of books found by author by map
  Given I have the following books in the store by map
    | title                                | author      |
    | The Devil in the White City          | Erik Larson |
    | The Lion, the Witch and the Wardrobe | C.S. Lewis  |
    | In the Garden of Beasts              | Erik Larson |
  When I search for books by author Erik Larson
  Then I find 2 books

Cucumber 解析结果如下:

[
    {"title": "The Devil in the White City", "author": "Erik Larson"},
    {"title": "The Lion, the Witch and the Wardrobe", "author": "C.S. Lewis"},
    {"title": "In the Garden of Beasts", "author": "Erik Larson"}
]

Java 实现:

@Given("^I have the following books in the store by map$")
public void haveBooksInTheStoreByMap(DataTable table) {
    List<Map<String, String>> rows = table.asMaps(String.class, String.class);
    
    for (Map<String, String> columns : rows) {
        store.addBook(new Book(columns.get("title"), columns.get("author")));
    }
}

✅ 优点:

  • 可读性强,字段通过 key 访问
  • 不依赖列顺序

⚠️ 注意:asMaps() 需要传两个 Class 参数:

  • 第一个表示 key 的类型(通常是 String.class
  • 第二个表示 value 的类型(也通常是 String.class

3.3. 使用 TableTransformer 转为领域对象

前两种方式虽然能用,但转换逻辑仍写在步骤方法中,不够干净。理想情况是:步骤方法只关注行为,不处理数据转换

这就是 TableTransformer 的用武之地。

目标场景

Scenario: Correct non-zero number of books found by author with transformer
  Given I have the following books in the store with transformer
    | title                                | author      |
    | The Devil in the White City          | Erik Larson |
    | The Lion, the Witch and the Wardrobe | C.S. Lewis  |
    | In the Garden of Beasts              | Erik Larson |
  When I search for books by author Erik Larson
  Then I find 2 books

我们希望步骤方法直接接收一个领域对象,比如 BookCatalog

@Given("^I have the following books in the store with transformer$")
public void haveBooksInTheStoreByTransformer(BookCatalog catalog) {
    store.addAllBooks(catalog.getBooks());
}

要实现这一点,必须注册一个自定义的 TableTransformer

定义领域对象

public class BookCatalog {
    private List<Book> books = new ArrayList<>();
     
    public void addBook(Book book) {
        books.add(book);
    }
     
    public List<Book> getBooks() {
        return books;
    }
}

实现 TypeRegistryConfigurer

public class BookStoreRegistryConfigurer implements TypeRegistryConfigurer {

    @Override
    public Locale locale() {
        return Locale.ENGLISH;
    }

    @Override
    public void configureTypeRegistry(TypeRegistry typeRegistry) {
        typeRegistry.defineDataTableType(
            new DataTableType(BookCatalog.class, new BookTableTransformer())
        );
    }
}

实现 TableTransformer

private static class BookTableTransformer implements TableTransformer<BookCatalog> {

    @Override
    public BookCatalog transform(DataTable table) throws Throwable {
        BookCatalog catalog = new BookCatalog();
        
        table.cells()
            .stream()
            .skip(1)        // 跳过表头
            .map(fields -> new Book(fields.get(0), fields.get(1)))
            .forEach(catalog::addBook);
        
        return catalog;
    }
}

✅ 关键点:

  • skip(1) 是因为第一行是表头
  • 如果表格无表头,则不需要 skip(1)
  • locale() 返回的语言环境需与测试数据一致

⚠️ 踩坑提醒

默认情况下,Cucumber 会在运行类(Runner)所在的包及其子包中查找配置类(如 TypeRegistryConfigurer)。如果 BookStoreRegistryConfigurer 不在该路径下,必须在 @CucumberOptions 中显式指定 glue 包:

@RunWith(Cucumber.class)
@CucumberOptions(
    features = "src/test/resources",
    glue = { "com.example.steps", "com.example.config" }  // 包含 configurer 的包
)
public class BookStoreTestRunner {
}

4. 总结

本文系统介绍了 Cucumber 数据表的三种处理方式:

方式 适用场景 推荐度
List<List<String>> 简单、无表头表格 ⭐⭐
List<Map<String, String>> 有表头,需可读性 ⭐⭐⭐⭐
TableTransformer 复杂数据,追求代码整洁 ⭐⭐⭐⭐⭐

✅ 建议:

  • 日常使用优先考虑 List<Map>,简单直接
  • 项目规模大、数据结构复杂时,果断上 TableTransformer,提升可维护性

完整示例代码已托管至 GitHub:https://github.com/yourname/cucumber-datatable-demo


原始标题:Cucumber Data Tables