第41讲:设计原则在测试中的应用

说实话,很多C++程序员写代码时,测试是最后才想的事。我早年也这样——先把功能怼出来,测试?再说吧。结果呢?每次改需求,代码就像多米诺骨牌一样倒一片。后来我明白了,可测试性不是测试人员的事,是架构设计的事

这一讲,我们就聊聊怎么用设计原则让代码变得好测。核心就三个关键词:依赖注入Mock对象SRP。它们不是孤立的,而是一套组合拳。

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

什么叫可测试性?说白了,就是你的代码能不能被单独拎出来测。我见过太多代码,想测一个函数,结果要连数据库、连网络、读文件……测一个点,得搭一整套环境。这哪是单元测试?这是集成测试。

可测试性设计的核心原则就一条:减少依赖,控制边界。怎么做到?看下面这张图。

可测试性设计核心逻辑 可测试性设计 依赖注入 (DI) Mock对象 单一职责 (SRP) 接口注入 / 构造注入 隔离外部依赖 每个类只做一件事 三者结合 → 高内聚、低耦合、易测试

你看,这三个点互相支撑。没有DI,Mock就不好使;没有SRP,依赖关系会乱成一团。下面我们一个一个拆开讲。

2. 依赖注入:把控制权交出去

依赖注入,英文叫Dependency Injection,简称DI。我个人的理解是:别让你的类自己去new依赖,让别人把依赖送进来

举个例子。你写了一个订单服务,里面要调用支付接口。最直接的做法是:

class OrderService {
public:
    void process(Order& order) {
        PaymentGateway gateway;  // 直接在内部创建
        gateway.charge(order.getAmount());
    }
};

这代码有什么问题?问题大了。你想测process函数,就必须真的去调支付网关。万一网关挂了?万一扣了真钱?你想想看,这哪是单元测试,这是烧钱测试。

用依赖注入改造一下:

class OrderService {
public:
    // 通过构造函数注入依赖
    explicit OrderService(std::shared_ptr<PaymentGateway> gateway)
        : gateway_(std::move(gateway)) {}
    
    void process(Order& order) {
        gateway_->charge(order.getAmount());
    }
private:
    std::shared_ptr<PaymentGateway> gateway_;
};

现在,OrderService不再关心PaymentGateway是怎么来的。你给它什么,它就用什么。测试时传一个Mock进去,生产环境传真实实现。完美解耦。

核心要点:依赖注入的本质是「控制反转」。不是类自己控制依赖的创建,而是由外部容器或调用方来注入。这样,测试时就能轻松替换依赖。

3. Mock对象:给测试装上「替身」

有了依赖注入,Mock对象就派上用场了。Mock是什么?说白了,就是一个假的实现。它不干真活,只记录你调了它什么、传了什么参数、返回什么结果。

我在项目中遇到过这样一个场景:要测试一个数据同步模块,它依赖一个远程的REST API。每次测试都要等网络响应,慢得要命。后来我用Mock替换了HTTP客户端,测试从30秒降到了0.1秒。

看代码:

// 定义接口
class HttpClient {
public:
    virtual ~HttpClient() = default;
    virtual Response post(const std::string& url, const std::string& body) = 0;
};

// Mock实现(用Google Mock举例)
class MockHttpClient : public HttpClient {
public:
    MOCK_METHOD(Response, post, (const std::string&, const std::string&), (override));
};

// 测试代码
TEST(DataSyncTest, should_sync_successfully) {
    auto mock = std::make_shared<MockHttpClient>();
    EXPECT_CALL(*mock, post(testing::_, testing::_))
        .WillOnce(testing::Return(Response{200, "ok"}));
    
    DataSync sync(mock);
    EXPECT_TRUE(sync.execute());
}

这里要注意一点:Mock只适合测行为,不适合测状态。什么意思?Mock验证的是「你有没有调某个函数」「传了什么参数」,而不是「最终数据对不对」。数据对不对,那是集成测试的事。

避坑指南:我曾经犯过一个错误——过度使用Mock。一个测试里Mock了七八个对象,结果代码改了,Mock没改,测试全绿,上线全挂。记住:Mock是用来隔离外部依赖的,不是用来掩盖设计问题的。如果一个类需要Mock太多东西,说明它违反了SRP。

4. SRP与单元测试:一个类只做一件事

单一职责原则(SRP)和单元测试的关系,我打个比方:一个类就像一把螺丝刀,你用它拧螺丝就行,别指望它还能当锤子用

如果一个类既负责业务逻辑,又负责数据持久化,又负责网络通信……那它的测试会变成噩梦。因为你要Mock的东西太多了,而且每次改需求,测试代码也得跟着大改。

看个反面例子:

class UserManager {
public:
    void saveUser(const User& user) {
        // 业务校验
        if (user.getName().empty()) throw std::invalid_argument("name required");
        // 数据库操作
        db_.insert("users", user.toJson());
        // 发送通知
        emailService_.sendWelcome(user.getEmail());
    }
private:
    Database db_;
    EmailService emailService_;
};

这个类干了三件事:校验、存库、发邮件。测它的时候,你得Mock数据库和邮件服务。而且,如果将来发邮件的逻辑变了,你还要改UserManager的测试。这不合理。

按SRP拆开:

class UserValidator {
public:
    void validate(const User& user) {
        if (user.getName().empty()) throw std::invalid_argument("name required");
    }
};

class UserRepository {
public:
    void save(const User& user) {
        db_.insert("users", user.toJson());
    }
private:
    Database db_;
};

class WelcomeNotifier {
public:
    void send(const User& user) {
        emailService_.sendWelcome(user.getEmail());
    }
private:
    EmailService emailService_;
};

现在每个类只做一件事。测试UserValidator不需要Mock,测试UserRepository只需要Mock数据库,测试WelcomeNotifier只需要Mock邮件服务。每个测试都小而专注。

我的习惯:写一个新类之前,先问自己三个问题:这个类叫什么名字?它做哪一件事?如果明天要改需求,改的是这个类还是别的类?如果答案不清晰,说明职责没分好。

5. 三者如何协同工作

依赖注入、Mock对象、SRP,这三者不是各自为战,而是环环相扣的。

  • SRP让类的职责单一,依赖关系清晰。这是基础。
  • 依赖注入让类不再硬编码依赖,而是通过接口接收。这是手段。
  • Mock对象利用接口多态,在测试时替换真实依赖。这是工具。

举个例子,一个完整的测试流程:

// 1. 定义接口(SRP的体现)
class Logger {
public:
    virtual ~Logger() = default;
    virtual void log(const std::string& msg) = 0;
};

// 2. 真实实现
class FileLogger : public Logger {
public:
    void log(const std::string& msg) override {
        // 写入文件...
    }
};

// 3. Mock实现
class MockLogger : public Logger {
public:
    MOCK_METHOD(void, log, (const std::string&), (override));
};

// 4. 业务类(依赖注入)
class ReportGenerator {
public:
    explicit ReportGenerator(std::shared_ptr<Logger> logger)
        : logger_(std::move(logger)) {}
    
    void generate() {
        // 生成报告...
        logger_->log("report generated");
    }
private:
    std::shared_ptr<Logger> logger_;
};

// 5. 测试
TEST(ReportTest, should_log_when_generated) {
    auto mockLogger = std::make_shared<MockLogger>();
    EXPECT_CALL(*mockLogger, log("report generated")).Times(1);
    
    ReportGenerator gen(mockLogger);
    gen.generate();
}

你看,每个环节都清晰。测试只关心ReportGenerator的行为,不关心日志到底写到哪里去了。这就是可测试性设计的精髓。

6. 一些实战建议

最后,分享几个我踩过的坑和总结的经验:

  • 接口先行:写实现之前,先定义接口。接口就是契约,有了契约,Mock才能工作。
  • 不要Mock值对象:像PointMoney这种纯数据类,直接new出来用就行,别Mock它们。
  • 测试代码也是代码:它同样需要遵守设计原则。我见过有人把测试写成一个几千行的函数,那还不如不写。
  • 依赖注入框架慎用:C++不像Java有Spring那样成熟的DI容器。手动注入其实更可控,也更清晰。

一句话总结:可测试性不是测出来的,是设计出来的。用SRP拆分职责,用依赖注入解耦,用Mock隔离外部依赖——这三板斧下去,你的代码会变得又好测又健壮。

嗯,这一讲就到这里。记住,写可测试的代码,不是为了讨好测试人员,而是为了让你自己晚上睡得踏实。


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