15、测试与设计模式:TDD如何促进依赖注入、接口隔离等良好设计?
说实话,这个话题我特别想聊。很多团队把TDD当成「先写测试再写代码」的流程,但我觉得,TDD真正的价值在于——它逼着你把代码写「好」。
什么叫「好」?就是可测试、可扩展、可维护。而设计模式,恰恰是通往这个目标的路径。我见过太多人把设计模式当八股文背,但TDD能让你在实践中「自然」地用到它们。
咱们今天重点聊两个:依赖注入和接口隔离。这两个是TDD最常催生的设计模式,也是我项目里用得最多的。
为什么TDD会催生依赖注入?
先问个问题:你写单元测试时,最头疼的是什么?
我个人觉得,是「依赖」。比如一个类要连数据库、要发网络请求、要读写文件。这些外部依赖一掺和进来,测试就变得又慢又不稳定。
TDD的解决思路很简单:把依赖从类内部「抽」出来,通过外部传入。这就是依赖注入的核心思想。
举个例子。假设你要写一个订单服务,它需要调用支付接口:
// 不好的写法:直接在类里new依赖
class OrderService {
public:
bool processOrder(Order& order) {
PaymentGateway gateway; // 硬编码依赖
return gateway.charge(order.total());
}
};
这种代码,测试时根本没法mock支付网关。你总不能每次跑测试都真的扣钱吧?
用TDD的思路,你会先写测试,然后发现——嗯,得把PaymentGateway作为参数传进来:
// 测试先行
TEST(OrderServiceTest, ShouldChargePaymentGateway) {
MockPaymentGateway mockGateway;
EXPECT_CALL(mockGateway, charge(100.0)).WillOnce(Return(true));
OrderService service(&mockGateway);
Order order(100.0);
EXPECT_TRUE(service.processOrder(order));
}
然后你自然会把代码改成这样:
class OrderService {
public:
explicit OrderService(IPaymentGateway* gateway) : gateway_(gateway) {}
bool processOrder(Order& order) {
return gateway_->charge(order.total());
}
private:
IPaymentGateway* gateway_;
};
看到了吗?TDD逼着你把依赖「注入」进来。你根本不需要刻意去学依赖注入模式,测试的需求会自然引导你走向它。
接口隔离:TDD如何帮你「瘦身」接口?
接口隔离原则(ISP)说:客户端不应该依赖它不需要的接口。说白了,就是接口要小、要专一。
但现实中,很多人写接口时喜欢「大而全」。一个接口塞十几个方法,美其名曰「通用」。结果呢?实现类里一堆空方法,或者抛出NotImplementedException。
TDD怎么解决这个问题?很简单:你写测试时,只会mock你真正用到的方法。
假设你有一个数据访问接口:
// 糟糕的「胖接口」
class IDataStore {
public:
virtual void save(const Record& r) = 0;
virtual Record load(int id) = 0;
virtual void deleteRecord(int id) = 0;
virtual std::vector<Record> search(const std::string& query) = 0;
virtual void bulkInsert(const std::vector<Record>& records) = 0;
virtual void exportToCsv(const std::string& path) = 0;
};
如果你只用到save和load,测试时你会怎么做?
// 测试里只mock需要的方法
class MockDataStore : public IDataStore {
public:
MOCK_METHOD(void, save, (const Record&), (override));
MOCK_METHOD(Record, load, (int), (override));
// 其他方法?不关心,但必须实现,否则编译不过
MOCK_METHOD(void, deleteRecord, (int), (override));
MOCK_METHOD(std::vector<Record>, search, (const std::string&), (override));
MOCK_METHOD(void, bulkInsert, (const std::vector<Record>&), (override));
MOCK_METHOD(void, exportToCsv, (const std::string&), (override));
};
你发现了吗?你被迫mock了6个方法,但只用了2个。这就是坏味道。TDD会让你觉得「这接口太胖了,拆开吧」。
更好的做法是拆成多个小接口:
class IWritable {
public:
virtual void save(const Record& r) = 0;
};
class IReadable {
public:
virtual Record load(int id) = 0;
};
class IDeletable {
public:
virtual void deleteRecord(int id) = 0;
};
// 需要时再组合
class IDataStore : public IWritable, public IReadable, public IDeletable {};
这样,你的测试只需要mock真正需要的接口:
TEST(OrderServiceTest, ShouldSaveOrder) {
MockWritable mockWritable;
EXPECT_CALL(mockWritable, save(_)).Times(1);
OrderService service(&mockWritable);
service.saveOrder(Order{...});
}
接口小了,测试简单了,代码也更容易维护。这就是TDD对接口隔离的促进作用。
TDD与设计模式的「化学反应」
除了依赖注入和接口隔离,TDD还会自然催生其他设计模式。我列几个常见的:
| 设计模式 | TDD如何促进 | 我的实际案例 |
|---|---|---|
| 策略模式 | 测试需要切换不同算法,自然把算法抽成独立策略 | 做过一个价格计算系统,不同客户有不同折扣策略,测试时直接注入Mock策略 |
| 工厂模式 | 测试需要控制对象创建,把new操作封装起来 | 数据库连接池,测试时用工厂返回Mock连接 |
| 观察者模式 | 测试需要验证事件通知,把通知逻辑抽象成接口 | 订单状态变更通知,测试时注入Mock观察者验证调用 |
| 适配器模式 | 测试需要隔离第三方库,用适配器包装外部依赖 | 对接支付宝SDK,写一个适配器接口,测试时用Mock替代真实SDK |
你看,这些模式不是硬套上去的,而是TDD过程中「长」出来的。你写测试时发现「这里不好测」,然后重构,模式就自然出现了。
核心逻辑:TDD如何驱动良好设计?
我画了一张图,帮你理解TDD和设计模式之间的关系:
这张图想表达的是:TDD不是孤立存在的。它通过「写测试 -> 发现问题 -> 应用模式 -> 更好测试」这个循环,持续推动代码质量提升。
说白了,TDD就像一面照妖镜。代码设计好不好,一写测试就现原形。那些耦合紧、职责乱、依赖多的代码,在TDD面前根本藏不住。
核心观点: 不要为了用设计模式而用设计模式。先写测试,让测试告诉你哪里需要重构。设计模式是解决问题的「工具」,不是「目标」。
总结一下
今天聊了TDD如何促进依赖注入和接口隔离。其实核心就一句话:测试驱动设计,而不是设计驱动测试。
我个人习惯是:拿到需求后,先想「这个功能怎么测」,而不是「这个类怎么设计」。测试的视角会逼着你思考依赖、接口、职责这些关键问题。等你把测试写好了,代码结构自然就清晰了。
你想想看,如果每个类都能轻松写出单元测试,那它的设计大概率不会差。反过来,如果一个类让你写测试时抓耳挠腮,那它的设计一定有问题。
嗯,这就是TDD的魅力所在。它不只是测试方法,更是一种设计哲学。
公众号:蓝海资料掘金营,微信deep3321