21、测试驱动文档:如何让测试用例成为可执行的文档?BDD风格测试简介。
说实话,我们写文档这件事,在C++项目里一直挺尴尬的。
需求文档写完了,没人看。设计文档写完了,很快就过时了。API文档倒是有人用,但一重构就全废了。我见过太多项目,文档和代码完全是两个世界的东西。你照着文档调接口,调了半天发现接口早就改了——嗯,这种坑我踩过不止一次。
那有没有一种办法,让文档本身就能跑起来?让文档和代码永远保持一致?
这就是我们今天要聊的——测试驱动文档。说白了,就是把测试用例写成可执行的文档。而BDD(行为驱动开发)风格,就是实现这个目标的最佳实践。
21.1 传统文档的痛点
先说说我自己的经历。几年前我带一个C++中间件团队,我们维护着一个几十万行的网络库。每次发版前,QA都会拿着一个100多页的测试用例文档来验收。那个文档写得非常详细,每个接口的输入输出、边界条件、异常场景,全都有。
但问题来了:
- 文档是Word写的,和代码完全分离
- 代码改了,文档没人同步更新
- 新同事看文档学接口,一调就崩
- QA验收时,文档里的用例和实际代码对不上
说白了,文档成了摆设。你想想看,一个不能执行的文档,它的价值到底有多大?
核心观点: 真正的文档,应该是可执行的。它不仅要描述“系统应该做什么”,还要能验证“系统确实做到了”。
21.2 什么是BDD风格测试?
BDD,行为驱动开发。它不是什么高深的理论,其实就是一种写测试的思维方式。
传统单元测试关注的是“函数返回了什么值”。而BDD关注的是“系统表现出了什么行为”。
举个例子。假设我们要测试一个订单系统的“取消订单”功能:
传统单元测试写法:
TEST(OrderTest, CancelOrder) {
Order order;
order.Create("item_001", 2);
bool result = order.Cancel();
ASSERT_TRUE(result);
ASSERT_EQ(order.GetStatus(), OrderStatus::CANCELLED);
}
BDD风格测试写法:
// 场景:用户取消一个已创建的订单
// 给定:用户创建了一个包含2件商品的订单
// 当:用户取消该订单
// 那么:订单状态应变为“已取消”
TEST(OrderTest, CancelOrder_ShouldSetStatusToCancelled) {
// 给定
Order order;
order.Create("item_001", 2);
// 当
bool result = order.Cancel();
// 那么
EXPECT_TRUE(result);
EXPECT_EQ(order.GetStatus(), OrderStatus::CANCELLED);
}
看出区别了吗?BDD风格的测试,本身就是一份可读的文档。它用“给定-当-那么”的结构,把业务逻辑描述得清清楚楚。
我的习惯: 测试用例的名字一定要写成一句完整的英文句子。比如 CancelOrder_ShouldSetStatusToCancelled。这样你在测试报告中一眼就能看出这个用例在测什么。
21.3 如何让测试用例成为可执行的文档?
这里我总结了一套方法,我自己在项目中用了好几年,效果不错。
21.3.1 用场景描述替代函数描述
传统测试用例的名字往往是这样的:TestCancelOrder、TestCreateOrder。你光看名字,根本不知道它在测什么场景。
BDD的做法是:用业务场景命名。
| 传统命名 | BDD命名 |
|---|---|
| TestCancelOrder | CancelOrder_WhenOrderExists_ShouldSucceed |
| TestCreateOrder | CreateOrder_WhenItemOutOfStock_ShouldFail |
| TestPayment | ProcessPayment_WhenInsufficientBalance_ShouldReturnError |
你看,BDD的命名本身就是一段文档。你不需要打开代码,光看测试列表就知道系统有哪些行为。
21.3.2 使用“给定-当-那么”结构
这是BDD最核心的套路。每个测试用例都分成三段:
- 给定(Given): 前置条件。系统处于什么状态?
- 当(When): 触发动作。用户做了什么操作?
- 那么(Then): 预期结果。系统应该表现出什么行为?
我在项目中要求团队所有测试用例都必须按这个结构写。一开始大家觉得麻烦,但后来发现,这个结构让测试的可读性提升了不止一个档次。
// 场景:用户查询一个不存在的订单
// 给定:系统中没有该订单ID
// 当:用户查询该订单
// 那么:系统应返回“订单不存在”错误
TEST(OrderQueryTest, QueryOrder_WhenOrderNotExist_ShouldReturnNotFound) {
// 给定
OrderService service;
// 当
auto result = service.QueryOrder("non_exist_id");
// 那么
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetErrorCode(), ErrorCode::ORDER_NOT_FOUND);
}
我曾经踩过的坑: 一开始我们只写“当”和“那么”,忽略了“给定”。结果很多测试用例跑失败,根本不知道是前置条件没准备好,还是代码逻辑有bug。后来我强制要求:每个测试用例必须显式写出“给定”部分,哪怕前置条件为空,也要写一句注释说明。
21.3.3 用测试框架生成可读报告
光写得好还不够,还得让测试结果能被人看懂。我推荐使用支持BDD风格的测试框架,比如Catch2或Google Test的扩展。
举个例子,用Catch2的BDD风格:
SCENARIO("用户取消订单", "[order]") {
GIVEN("用户创建了一个包含2件商品的订单") {
Order order;
order.Create("item_001", 2);
WHEN("用户取消该订单") {
bool result = order.Cancel();
THEN("订单状态应变为已取消") {
REQUIRE(result == true);
REQUIRE(order.GetStatus() == OrderStatus::CANCELLED);
}
THEN("库存应恢复") {
REQUIRE(Inventory::GetStock("item_001") == 100);
}
}
}
}
运行这个测试,生成的报告是这样的:
场景:用户取消订单
给定:用户创建了一个包含2件商品的订单
当:用户取消该订单
那么:订单状态应变为已取消 - PASSED
那么:库存应恢复 - PASSED
你看,这份报告本身就是一份可读的文档。产品经理看得懂,QA看得懂,开发也看得懂。
21.4 BDD测试的SVG知识图谱
下面这张图,帮你理清BDD测试的核心逻辑:
21.5 实践建议:从一个小模块开始
我知道,一下子让整个团队都改成BDD风格,不太现实。我个人的建议是:
- 选一个稳定的模块试点。 比如日志模块、配置模块。这些模块业务逻辑清晰,适合做BDD。
- 先写场景描述,再写代码。 和产品经理、QA一起,把“给定-当-那么”写清楚。这本身就是需求讨论的过程。
- 把测试报告纳入文档体系。 每次CI构建后,自动生成BDD测试报告,发给团队所有人。让测试报告成为“活文档”。
- 不要追求100%覆盖。 BDD适合描述核心业务场景。底层工具函数、算法逻辑,用传统单元测试就够了。
一个小技巧: 在代码评审时,我要求评审者先看测试用例的名字和“给定-当-那么”注释。如果光看这些就能理解业务逻辑,说明测试用例写得好。如果看不懂,说明测试用例需要重写。
21.6 总结
测试驱动文档,说白了就是让测试用例承担起文档的职责。BDD风格给了我们一套很好的表达方式——用“给定-当-那么”来描述系统行为。
你想想看,当你的测试用例本身就是一份可读的文档,当你的测试报告就是最新的需求说明,那种感觉真的很爽。代码改了,测试用例跟着改,文档自然就更新了。再也不会有“文档过期”这种事了。
嗯,从一个小模块开始试试吧。你会发现,写测试不再是一件苦差事,而是一种沟通方式。
公众号:蓝海资料掘金营,微信deep3321