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 方法」。说白了,测试的是调用序列,而不是行为结果。
陷阱四:测试覆盖了「不该测」的东西
有些东西真的不需要测。比如:
- 第三方库的行为——你不需要测试 std::vector 的 push_back 是否正常工作
- 简单的 getter/setter——除非里面有业务逻辑
- 配置文件的读取——测的是框架,不是你的代码
我见过有人给每个 getter 都写了测试。40 个 getter,40 个测试用例。有意义吗?没有。这些测试除了增加维护成本,什么用都没有。
陷阱五:测试太「聪明」——过度使用测试框架的高级特性
有些测试框架提供了各种高级匹配器、参数化测试、自定义断言。用得好是利器,用不好就是灾难。
我见过一个测试,用了 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))
)
))
)
);
}
这个测试能跑过,但你能一眼看出它在测什么吗?不能。测试应该是「读起来像文档」,而不是「读起来像谜题」。
如何系统性地避免这些陷阱?
说了这么多问题,总得给点解决方案。我总结了一套检查清单,每次写测试前过一遍:
| 检查项 | 自问问题 | 通过标准 |
|---|---|---|
| 测试独立性 | 这个测试能单独运行吗? | 不依赖其他测试的执行顺序 |
| 行为 vs 实现 | 我是在测行为,还是在测实现? | 只断言外部可见的结果 |
| Mock 数量 | 我 mock 了几个对象? | 不超过 2 个 |
| 可读性 | 别人能一眼看懂这个测试在测什么吗? | 测试名 + 前 3 行代码就能说明意图 |
| 维护成本 | 如果重构实现,这个测试需要改吗? | 不需要改(接口不变的情况下) |
一张图总结:TDD 陷阱与反模式全景
下面这张图是我自己整理的,把常见的陷阱、它们的症状、以及解决方案串在了一起。你可以把它当成一张「避坑地图」。
最后说几句
TDD 本身是个好实践,但用不好就会变成负担。我见过太多团队,因为测试太脆弱、太耦合,最后放弃了 TDD。其实不是 TDD 的问题,是写法的问题。
记住三个关键词:行为、接口、简单。测试只测行为,面向接口写测试,保持测试简单可读。做到这三点,你的测试就不会成为负担,而是真正的安全网。
嗯,今天就聊到这儿。这些坑我当年都踩过,希望你能绕过去。
公众号:蓝海资料掘金营,微信deep3321