第41讲:设计原则在测试中的应用
说实话,很多C++程序员写代码时,测试是最后才想的事。我早年也这样——先把功能怼出来,测试?再说吧。结果呢?每次改需求,代码就像多米诺骨牌一样倒一片。后来我明白了,可测试性不是测试人员的事,是架构设计的事。
这一讲,我们就聊聊怎么用设计原则让代码变得好测。核心就三个关键词:依赖注入、Mock对象、SRP。它们不是孤立的,而是一套组合拳。
1. 可测试性设计:从源头解决问题
什么叫可测试性?说白了,就是你的代码能不能被单独拎出来测。我见过太多代码,想测一个函数,结果要连数据库、连网络、读文件……测一个点,得搭一整套环境。这哪是单元测试?这是集成测试。
可测试性设计的核心原则就一条:减少依赖,控制边界。怎么做到?看下面这张图。
你看,这三个点互相支撑。没有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值对象:像
Point、Money这种纯数据类,直接new出来用就行,别Mock它们。 - 测试代码也是代码:它同样需要遵守设计原则。我见过有人把测试写成一个几千行的函数,那还不如不写。
- 依赖注入框架慎用:C++不像Java有Spring那样成熟的DI容器。手动注入其实更可控,也更清晰。
一句话总结:可测试性不是测出来的,是设计出来的。用SRP拆分职责,用依赖注入解耦,用Mock隔离外部依赖——这三板斧下去,你的代码会变得又好测又健壮。
嗯,这一讲就到这里。记住,写可测试的代码,不是为了讨好测试人员,而是为了让你自己晚上睡得踏实。
公众号:蓝海资料掘金营,微信deep3321