34. 设计模式与测试:可测试性设计,依赖注入(策略模式),Mock对象(代理模式)

聊到设计模式与测试,我得先坦白一件事。早些年我写代码,脑子里只有「功能跑通就行」。测试?那是测试工程师的事。直到有一次,我接手一个遗留系统,里面全是硬编码的数据库连接、文件路径、第三方API调用。想写个单元测试?门都没有。那套代码,说白了就是一团乱麻,牵一发而动全身。

从那以后,我深刻理解了一个道理:可测试性不是测试阶段才考虑的事,而是设计阶段就要埋下的种子。设计模式在这里扮演的角色,就是帮我们把代码里的「硬依赖」变成「软接口」,让测试变得可行、简单、优雅。

可测试性设计:从源头解决问题

什么叫可测试性设计?我个人的理解是:让代码中的每个模块都能被独立验证。你想想看,如果一段代码里直接 new 了一个数据库对象,那测试时就必须连上真实的数据库。这哪是单元测试?这分明是集成测试,甚至冒烟测试。

可测试性设计的核心原则就两条:

  • 依赖倒置:高层模块不依赖低层模块,两者都依赖抽象。
  • 接口隔离:每个接口只做一件事,职责单一。

这两条原则,说白了就是让代码「松耦合」。耦合松了,测试才能「拆得开」。我在项目中见过太多反面教材——一个类里塞了十几个依赖,构造函数里全是 new 操作。这种代码,别说测试了,连读懂都费劲。

避坑指南:我曾经在一个支付系统中,看到业务逻辑里直接调用了第三方支付SDK的静态方法。想Mock?根本不可能。最后只能把整个支付流程拆成接口+实现,才勉强把单元测试补上。这个教训让我记住了:静态方法、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对象。我习惯把这种设计叫做「可插拔架构」——每个模块都是一个插头,测试时拔掉真的,插上假的。

个人经验:依赖注入不一定非要引入IoC容器。对于中小型项目,手动在构造函数里传参就足够了。我见过有人为了用DI而用DI,引入Spring、Guice之类的框架,结果配置比代码还复杂。记住:设计模式是工具,不是目的

Mock对象与代理模式:测试的「替身」

Mock对象,说白了就是测试中的「替身演员」。真实对象太复杂、太慢、太不稳定,那就找个替身来顶替。代理模式正好提供了这种「替身」的机制——代理对象和真实对象实现同一个接口,客户端无感知。

我画了一张图,帮你理清它们的关系:

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对象(代理模式) 提供轻量级替身,隔离外部依赖

我个人的经验是:先有可测试性设计,才有依赖注入的用武之地;有了依赖注入,Mock对象才能自然融入。这三者缺一不可,环环相扣。

核心总结:设计模式不是为了炫技,而是为了解决实际问题。在测试领域,策略模式帮我们实现依赖注入,代理模式帮我们实现Mock对象。而这一切的根基,是可测试性设计——让代码从一开始就「为测试而生」。

最后说一句。很多开发者觉得写测试是浪费时间,尤其是写Mock对象,感觉像是在「造假」。但你想过没有?没有Mock的测试,就像没有安全网的杂技表演——一旦出错,代价巨大。设计模式给了我们一张安全网,让我们可以放心地重构、优化、演进代码。这笔投资,绝对值。


专注资料整理