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 用场景描述替代函数描述

传统测试用例的名字往往是这样的:TestCancelOrderTestCreateOrder。你光看名字,根本不知道它在测什么场景。

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测试的核心逻辑:

BDD测试驱动文档核心逻辑 给定 (Given) 前置条件 / 系统状态 当 (When) 触发动作 / 用户操作 那么 (Then) 预期结果 / 系统行为 输出:可执行的文档 ✅ 文档与代码同步 ✅ 非技术人员可读 ✅ 自动化验证 测试即文档,文档即测试 —— 不再有“过时文档”的烦恼

21.5 实践建议:从一个小模块开始

我知道,一下子让整个团队都改成BDD风格,不太现实。我个人的建议是:

  1. 选一个稳定的模块试点。 比如日志模块、配置模块。这些模块业务逻辑清晰,适合做BDD。
  2. 先写场景描述,再写代码。 和产品经理、QA一起,把“给定-当-那么”写清楚。这本身就是需求讨论的过程。
  3. 把测试报告纳入文档体系。 每次CI构建后,自动生成BDD测试报告,发给团队所有人。让测试报告成为“活文档”。
  4. 不要追求100%覆盖。 BDD适合描述核心业务场景。底层工具函数、算法逻辑,用传统单元测试就够了。

一个小技巧: 在代码评审时,我要求评审者先看测试用例的名字和“给定-当-那么”注释。如果光看这些就能理解业务逻辑,说明测试用例写得好。如果看不懂,说明测试用例需要重写。

21.6 总结

测试驱动文档,说白了就是让测试用例承担起文档的职责。BDD风格给了我们一套很好的表达方式——用“给定-当-那么”来描述系统行为。

你想想看,当你的测试用例本身就是一份可读的文档,当你的测试报告就是最新的需求说明,那种感觉真的很爽。代码改了,测试用例跟着改,文档自然就更新了。再也不会有“文档过期”这种事了。

嗯,从一个小模块开始试试吧。你会发现,写测试不再是一件苦差事,而是一种沟通方式。


公众号:蓝海资料掘金营,微信deep3321