34. 设计模式与测试:可测试性设计,依赖注入(策略模式),Mock对象(代理模式)
聊到设计模式与测试,我得先坦白一件事。早些年我写代码,脑子里只有「功能跑通就行」。测试?那是测试工程师的事。直到有一次,我接手一个遗留系统,里面全是硬编码的数据库连接、文件路径、第三方API调用。想写个单元测试?门都没有。那套代码,说白了就是一团乱麻,牵一发而动全身。
从那以后,我深刻理解了一个道理:可测试性不是测试阶段才考虑的事,而是设计阶段就要埋下的种子。设计模式在这里扮演的角色,就是帮我们把代码里的「硬依赖」变成「软接口」,让测试变得可行、简单、优雅。
可测试性设计:从源头解决问题
什么叫可测试性设计?我个人的理解是:让代码中的每个模块都能被独立验证。你想想看,如果一段代码里直接 new 了一个数据库对象,那测试时就必须连上真实的数据库。这哪是单元测试?这分明是集成测试,甚至冒烟测试。
可测试性设计的核心原则就两条:
- 依赖倒置:高层模块不依赖低层模块,两者都依赖抽象。
- 接口隔离:每个接口只做一件事,职责单一。
这两条原则,说白了就是让代码「松耦合」。耦合松了,测试才能「拆得开」。我在项目中见过太多反面教材——一个类里塞了十几个依赖,构造函数里全是 new 操作。这种代码,别说测试了,连读懂都费劲。
依赖注入与策略模式:让测试「换芯」
依赖注入(DI)和策略模式,其实是一对好搭档。策略模式定义了一组算法族,让它们可以互相替换。依赖注入则是把这些算法「注入」到使用方中,而不是让使用方自己去创建。
我举个例子。假设你有一个订单计算器,需要根据不同的促销策略计算折扣:
// 策略接口
class DiscountStrategy {
public:
virtual double calculate(double price) = 0;
virtual ~DiscountStrategy() = default;
};
// 具体策略:无折扣
class NoDiscount : public DiscountStrategy {
public:
double calculate(double price) override { return price; }
};
// 具体策略:满减
class FullReduction : public DiscountStrategy {
public:
double calculate(double price) override {
return price > 100 ? price - 20 : price;
}
};
// 使用方:订单计算器
class OrderCalculator {
private:
std::unique_ptr<DiscountStrategy> strategy_;
public:
// 依赖注入:策略从外部传入
explicit OrderCalculator(std::unique_ptr<DiscountStrategy> strategy)
: strategy_(std::move(strategy)) {}
double calculateTotal(double price) {
return strategy_->calculate(price);
}
};
看到没?OrderCalculator 不关心具体是哪种折扣策略。测试时,我可以注入一个「测试专用策略」:
// 测试专用策略:固定返回0
class MockDiscount : public DiscountStrategy {
public:
double calculate(double price) override { return 0.0; }
};
// 测试代码
TEST(OrderCalculatorTest, ShouldReturnZeroWithMockStrategy) {
auto mock = std::make_unique<MockDiscount>();
OrderCalculator calc(std::move(mock));
EXPECT_EQ(calc.calculateTotal(100.0), 0.0);
}
这就是依赖注入的魅力。它让测试代码可以「偷梁换柱」,把真实的、复杂的、有副作用的依赖,替换成简单的、可控的、无副作用的Mock对象。我习惯把这种设计叫做「可插拔架构」——每个模块都是一个插头,测试时拔掉真的,插上假的。
Mock对象与代理模式:测试的「替身」
Mock对象,说白了就是测试中的「替身演员」。真实对象太复杂、太慢、太不稳定,那就找个替身来顶替。代理模式正好提供了这种「替身」的机制——代理对象和真实对象实现同一个接口,客户端无感知。
我画了一张图,帮你理清它们的关系:
从图中可以看出,客户端只依赖抽象接口。真实对象、Mock对象、代理对象都实现了同一个接口。测试时,客户端拿到的是Mock对象;生产环境,客户端拿到的是真实对象或代理对象。这一切对客户端来说,完全透明。
我举个实际例子。假设你的系统需要调用一个外部邮件服务:
// 邮件服务接口
class EmailService {
public:
virtual void send(const std::string& to, const std::string& body) = 0;
virtual ~EmailService() = default;
};
// 真实邮件服务
class RealEmailService : public EmailService {
public:
void send(const std::string& to, const std::string& body) override {
// 真实的SMTP调用,很慢,还可能失败
}
};
// Mock邮件服务(用于测试)
class MockEmailService : public EmailService {
public:
void send(const std::string& to, const std::string& body) override {
// 什么都不做,或者记录调用参数
lastTo_ = to;
lastBody_ = body;
sendCount_++;
}
std::string lastTo() const { return lastTo_; }
int sendCount() const { return sendCount_; }
private:
std::string lastTo_;
std::string lastBody_;
int sendCount_ = 0;
};
测试时,注入MockEmailService,验证调用次数和参数:
TEST(NotificationTest, ShouldSendEmailOnOrderPlaced) {
auto mockEmail = std::make_shared<MockEmailService>();
NotificationService notifier(mockEmail);
notifier.onOrderPlaced("user@example.com");
EXPECT_EQ(mockEmail->sendCount(), 1);
EXPECT_EQ(mockEmail->lastTo(), "user@example.com");
}
这就是代理模式在测试中的典型应用。Mock对象充当了真实对象的「代理」,只不过这个代理什么都不做,只记录行为。我习惯把这种模式叫做「测试代理」——它和真实代理的区别在于:真实代理会转发请求并添加额外逻辑(如缓存、日志),而测试代理只负责「假装」和「记录」。
三者如何协同工作
可测试性设计、依赖注入、Mock对象,这三者其实是层层递进的关系:
| 层次 | 设计模式 | 测试中的作用 |
|---|---|---|
| 架构层 | 可测试性设计 | 定义接口、分离关注点、降低耦合 |
| 组装层 | 依赖注入(策略模式) | 将依赖从外部注入,支持运行时替换 |
| 测试层 | Mock对象(代理模式) | 提供轻量级替身,隔离外部依赖 |
我个人的经验是:先有可测试性设计,才有依赖注入的用武之地;有了依赖注入,Mock对象才能自然融入。这三者缺一不可,环环相扣。
最后说一句。很多开发者觉得写测试是浪费时间,尤其是写Mock对象,感觉像是在「造假」。但你想过没有?没有Mock的测试,就像没有安全网的杂技表演——一旦出错,代价巨大。设计模式给了我们一张安全网,让我们可以放心地重构、优化、演进代码。这笔投资,绝对值。