29、常见TDD陷阱与反模式:测试太脆弱、测试与实现耦合、过度Mock等问题的解决

说实话,TDD 这条路我走了快十年。刚开始那会儿,我踩过的坑比写过的测试还多。

测试跑不过,不是代码有 bug,而是测试本身太脆弱。改一行注释,测试就红了。你想想看,这得多让人崩溃?

今天我就把那些年我掉进去的坑,一个一个给你指出来。顺便告诉你,怎么爬出来。

陷阱一:测试太脆弱——改一行代码,碎一片测试

什么叫脆弱测试?就是被测代码稍微动一下,测试就挂了。哪怕只是重构内部实现,根本没改外部行为。

我在项目中遇到过最夸张的一次:一个工具类,我优化了一下排序算法,结果 47 个测试用例红了 39 个。为什么?因为测试直接断言了中间步骤的某个临时变量值。

典型反例:测试断言了不该断言的内部细节。
// 反模式:测试与实现细节耦合
TEST(CalculatorTest, AddShouldWork) {
    Calculator calc;
    auto result = calc.add(3, 5);
    // 这里断言了内部缓存的大小——这是实现细节!
    ASSERT_EQ(calc.cacheSize(), 1);
    ASSERT_EQ(result, 8);
}

正确的做法是什么?只断言外部可见的行为。说白了,就是只关心「输入-输出」对不对,别管它内部怎么折腾。

// 正确做法:只测试外部行为
TEST(CalculatorTest, AddShouldReturnCorrectSum) {
    Calculator calc;
    auto result = calc.add(3, 5);
    ASSERT_EQ(result, 8);
    // 不关心 cache,不关心内部状态
}
我的经验:写测试时问自己一个问题——「如果我把这个函数内部完全重写,测试还能不能过?」如果答案是「不能」,那这个测试就太脆弱了。

陷阱二:测试与实现耦合——测试成了实现的「影子」

这个陷阱比上一个更隐蔽。测试与实现耦合,不是说测试断言了内部变量,而是测试的结构完全照搬了实现的结构。

举个例子:你有一个函数,里面调用了三个子函数。于是你写了三个测试,分别测试这三个子函数。看起来没问题对吧?

问题在于:哪天你重构了,把三个子函数合并成两个,你的测试也得跟着重构。测试不是在测行为,而是在测结构。

核心原则:测试应该面向接口,而不是面向实现。接口稳定,实现可变。

我曾经接手过一个项目,测试代码比业务代码还多一倍。但每次重构,测试改得比业务还痛苦。为什么?因为测试和实现绑死了。后来我花了整整两周,把所有测试重写了一遍,只测公开接口的行为。从那以后,重构轻松多了。

陷阱三:过度 Mock——Mock 到连自己都不信

Mock 是个好东西,但用多了会出事。

我见过最夸张的:一个测试里 mock 了 8 个对象,设置了 20 多个期望。测试跑过了,但谁都不敢说代码真的没问题。因为 mock 得太多了,测试已经脱离了真实环境。

// 反模式:过度 Mock
TEST(OrderServiceTest, PlaceOrderShouldWork) {
    auto mockDb = std::make_shared<MockDatabase>();
    auto mockCache = std::make_shared<MockCache>();
    auto mockQueue = std::make_shared<MockQueue>();
    auto mockLogger = std::make_shared<MockLogger>();
    auto mockNotifier = std::make_shared<MockNotifier>();
    
    // 设置一堆期望...
    EXPECT_CALL(*mockDb, save).Times(1);
    EXPECT_CALL(*mockCache, update).Times(1);
    EXPECT_CALL(*mockQueue, push).Times(1);
    EXPECT_CALL(*mockLogger, log).Times(1);
    EXPECT_CALL(*mockNotifier, send).Times(1);
    
    OrderService service(mockDb, mockCache, mockQueue, mockLogger, mockNotifier);
    service.placeOrder(order);
}

这个测试的问题在哪?它不是在验证业务逻辑,而是在验证「我调用了这些 mock 方法」。说白了,测试的是调用序列,而不是行为结果。

我的建议:Mock 的数量控制在 2 个以内。超过 3 个,你就该想想是不是设计有问题了。另外,优先使用 Fake(轻量级实现)而不是 Mock。

陷阱四:测试覆盖了「不该测」的东西

有些东西真的不需要测。比如:

  • 第三方库的行为——你不需要测试 std::vector 的 push_back 是否正常工作
  • 简单的 getter/setter——除非里面有业务逻辑
  • 配置文件的读取——测的是框架,不是你的代码

我见过有人给每个 getter 都写了测试。40 个 getter,40 个测试用例。有意义吗?没有。这些测试除了增加维护成本,什么用都没有。

记住:测试是有成本的。每个测试用例都是负债,只有它能帮你发现 bug 时,才变成了资产。

陷阱五:测试太「聪明」——过度使用测试框架的高级特性

有些测试框架提供了各种高级匹配器、参数化测试、自定义断言。用得好是利器,用不好就是灾难。

我见过一个测试,用了 5 层嵌套的匹配器,加上自定义的 matcher,再加上 lambda 表达式。写的时候很爽,三个月后回来维护,没人看得懂。

// 反模式:过度使用高级特性
TEST_F(ComplexTest, ShouldMatchCrazyCondition) {
    EXPECT_THAT(result, 
        AllOf(
            Property(&Data::name, StartsWith("test")),
            Property(&Data::value, Gt(10) && Lt(100)),
            Field(&Data::items, Each(
                AllOf(
                    Field(&Item::id, AnyOf(1, 2, 3)),
                    Field(&Item::status, Eq(Status::Active))
                )
            ))
        )
    );
}

这个测试能跑过,但你能一眼看出它在测什么吗?不能。测试应该是「读起来像文档」,而不是「读起来像谜题」。

我的原则:如果一行断言超过 80 个字符,或者嵌套超过两层,就拆成多个简单的断言。可读性优先于「优雅」。

如何系统性地避免这些陷阱?

说了这么多问题,总得给点解决方案。我总结了一套检查清单,每次写测试前过一遍:

检查项 自问问题 通过标准
测试独立性 这个测试能单独运行吗? 不依赖其他测试的执行顺序
行为 vs 实现 我是在测行为,还是在测实现? 只断言外部可见的结果
Mock 数量 我 mock 了几个对象? 不超过 2 个
可读性 别人能一眼看懂这个测试在测什么吗? 测试名 + 前 3 行代码就能说明意图
维护成本 如果重构实现,这个测试需要改吗? 不需要改(接口不变的情况下)

一张图总结:TDD 陷阱与反模式全景

下面这张图是我自己整理的,把常见的陷阱、它们的症状、以及解决方案串在了一起。你可以把它当成一张「避坑地图」。

TDD 常见陷阱与反模式全景图 TDD 陷阱与反模式 测试太脆弱 测试与实现耦合 过度 Mock 测试太「聪明」 典型症状 改一行注释,测试变红 断言内部临时变量 测试结构照搬实现 重构时测试大面积改 Mock 超过 3 个 测试脱离真实环境 嵌套匹配器过多 测试读起来像谜题 解决方案 只测外部行为 不关心内部实现 面向接口测试 接口稳定,实现可变 Mock 不超过 2 个 优先使用 Fake 保持测试简单 可读性优先 核心原则:测试是文档,不是枷锁

最后说几句

TDD 本身是个好实践,但用不好就会变成负担。我见过太多团队,因为测试太脆弱、太耦合,最后放弃了 TDD。其实不是 TDD 的问题,是写法的问题。

记住三个关键词:行为接口简单。测试只测行为,面向接口写测试,保持测试简单可读。做到这三点,你的测试就不会成为负担,而是真正的安全网。

嗯,今天就聊到这儿。这些坑我当年都踩过,希望你能绕过去。

一句话总结:好的测试,改实现不改测试;坏的测试,改一行注释都得改测试。

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