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逼着你把依赖「注入」进来。你根本不需要刻意去学依赖注入模式,测试的需求会自然引导你走向它。

我的经验: 我在一个支付系统项目里,一开始没注意依赖注入,结果所有测试都依赖真实的数据库连接。每次跑测试要等30秒,还经常因为网络波动挂掉。后来重构为依赖注入,测试秒级完成,而且可以并行跑。那感觉,真爽。

接口隔离: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;
};

如果你只用到saveload,测试时你会怎么做?

// 测试里只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对接口隔离的促进作用。

避坑指南: 我曾经在一个项目里,把接口拆得太细,导致一个类要继承五六个接口。结果构造函数参数列表长得吓人。后来我意识到:接口隔离不是「越细越好」,而是「刚好够用」。一般一个接口3-5个方法比较合理。

TDD与设计模式的「化学反应」

除了依赖注入和接口隔离,TDD还会自然催生其他设计模式。我列几个常见的:

设计模式 TDD如何促进 我的实际案例
策略模式 测试需要切换不同算法,自然把算法抽成独立策略 做过一个价格计算系统,不同客户有不同折扣策略,测试时直接注入Mock策略
工厂模式 测试需要控制对象创建,把new操作封装起来 数据库连接池,测试时用工厂返回Mock连接
观察者模式 测试需要验证事件通知,把通知逻辑抽象成接口 订单状态变更通知,测试时注入Mock观察者验证调用
适配器模式 测试需要隔离第三方库,用适配器包装外部依赖 对接支付宝SDK,写一个适配器接口,测试时用Mock替代真实SDK

你看,这些模式不是硬套上去的,而是TDD过程中「长」出来的。你写测试时发现「这里不好测」,然后重构,模式就自然出现了。

核心逻辑:TDD如何驱动良好设计?

我画了一张图,帮你理解TDD和设计模式之间的关系:

TDD驱动良好设计的核心逻辑 TDD循环 1. 写一个失败的测试 2. 写刚好能通过的代码 3. 重构(消除坏味道) 发现设计问题 • 依赖难以Mock • 接口太胖 • 耦合度过高 • 职责不单一 • 难以独立测试 应用设计模式 • 依赖注入 • 接口隔离 • 策略模式 • 工厂模式 • 适配器模式 反馈循环:更好的设计让测试更容易写

这张图想表达的是:TDD不是孤立存在的。它通过「写测试 -> 发现问题 -> 应用模式 -> 更好测试」这个循环,持续推动代码质量提升。

说白了,TDD就像一面照妖镜。代码设计好不好,一写测试就现原形。那些耦合紧、职责乱、依赖多的代码,在TDD面前根本藏不住。

核心观点: 不要为了用设计模式而用设计模式。先写测试,让测试告诉你哪里需要重构。设计模式是解决问题的「工具」,不是「目标」。

总结一下

今天聊了TDD如何促进依赖注入和接口隔离。其实核心就一句话:测试驱动设计,而不是设计驱动测试

我个人习惯是:拿到需求后,先想「这个功能怎么测」,而不是「这个类怎么设计」。测试的视角会逼着你思考依赖、接口、职责这些关键问题。等你把测试写好了,代码结构自然就清晰了。

你想想看,如果每个类都能轻松写出单元测试,那它的设计大概率不会差。反过来,如果一个类让你写测试时抓耳挠腮,那它的设计一定有问题。

嗯,这就是TDD的魅力所在。它不只是测试方法,更是一种设计哲学。


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