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
(或其他步骤关键字)下方 - 数据表可用于任何步骤类型:
Given
、When
、Then
、And
、But
- 每行代表一条记录,列之间用
|
分隔
这种方式可以轻松扩展数据量,增删行即可,非常灵活。
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
对象封装了表格数据,并提供了多种转换方式。常见的有三种处理方式:
- ✅ 转为
List<List<String>>
—— 最基础 - ✅ 转为
List<Map<String, String>>
—— 更易读 - ✅ 使用
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