8、测试替身(Test Doubles):Stub、Mock、Fake、Dummy、Spy的区别与实战应用
说实话,测试替身这个概念,我当年刚接触的时候也绕了好一阵子。那时候看各种资料,什么Stub、Mock、Fake,感觉每个词都认识,但放在一起就懵了。后来在项目里踩了几次坑,才真正搞明白它们各自是干什么的。
今天咱们就把这五个兄弟——Stub、Mock、Fake、Dummy、Spy,一个一个拎出来说清楚。我会结合我实际项目中的例子,让你看完就能用。
8.1 先搞清楚:为什么需要测试替身?
写单元测试的时候,你肯定遇到过这种情况——被测代码里调了数据库、发了网络请求、或者读写文件。这些外部依赖,在单元测试里就是个大麻烦。
我举个例子。你写了个订单服务,里面调了支付接口。跑单元测试的时候,总不能真去扣钱吧?那测试一次扣一次,老板非把你开了不可。
测试替身就是干这个的。它用一个「替身」对象,替换掉真实的依赖。这样你的测试就能在隔离环境下运行,又快又稳。
核心原则:测试替身只替换外部依赖,不替换被测类自身的逻辑。说白了,就是「假扮别人,测试自己」。
8.2 Dummy:最没存在感的那个
Dummy 是最简单的测试替身。它就是个占位符,传递参数用的,但实际不会被调用。
我记得有一次写一个消息队列的消费者,构造函数需要传一个配置对象。但那个配置在测试里根本用不上,我就传了个 nullptr 进去。嗯,那个 nullptr 就是 Dummy。
// Dummy 示例:占位参数
class MessageConsumer {
public:
MessageConsumer(Config* config, Logger* logger)
: config_(config), logger_(logger) {}
void process(const std::string& msg) {
// 实际只用到了 logger_
logger_->log("Processing: " + msg);
}
private:
Config* config_; // 测试时传 nullptr
Logger* logger_;
};
// 测试代码
TEST(MessageConsumerTest, ProcessLogsMessage) {
MockLogger logger; // 真正的替身
// Config* 传 nullptr,这就是 Dummy
MessageConsumer consumer(nullptr, &logger);
consumer.process("test message");
ASSERT_TRUE(logger.wasCalled("Processing: test message"));
}
我的习惯:Dummy 对象我一般直接用 nullptr 或者默认构造的对象。如果必须传一个有效指针,我会定义一个空类,专门用来做 Dummy。
8.3 Stub:给你想要的值
Stub 是「有求必应」型的替身。你调用它的某个方法,它返回你预设好的数据。它不关心你调了几次,也不关心调用顺序,只管给值。
我在项目中遇到过最典型的 Stub 场景,就是数据库查询。测试一个用户服务时,我不想连真实数据库,就 Stub 了一个 UserRepository,让它返回固定的用户数据。
// Stub 示例:固定返回值
class StubUserRepository : public IUserRepository {
public:
User findById(int id) override {
// 不管传什么 id,都返回同一个用户
return User{1, "test_user", "test@example.com"};
}
std::vector<User> findAll() override {
return {User{1, "alice", "alice@test.com"},
User{2, "bob", "bob@test.com"}};
}
};
TEST(UserServiceTest, GetUserReturnsStubbedData) {
StubUserRepository repo;
UserService service(&repo);
auto user = service.getUser(42); // 传 42 也没用
EXPECT_EQ(user.name, "test_user");
// 注意:这里只验证返回值,不验证 repo 被调了几次
}
避坑指南:我曾经犯过一个错误——在 Stub 里加了调用次数验证。结果测试变得又脆又难维护。记住,Stub 只管「给什么值」,不管「怎么调」。验证调用行为是 Mock 的事。
8.4 Spy:悄悄记录一切
Spy 比 Stub 多了一个功能——它会记录调用信息。比如调了几次、传了什么参数、返回了什么结果。你可以事后去查这些记录。
我个人习惯用 Spy 来测试那些「有副作用」的方法。比如发送邮件、写日志、更新缓存。我不关心返回值,但我想确认它确实被调用了,而且参数是对的。
// Spy 示例:记录调用信息
class SpyEmailService : public IEmailService {
public:
void sendEmail(const std::string& to, const std::string& body) override {
callCount_++;
lastTo_ = to;
lastBody_ = body;
}
// 查询方法
int getCallCount() const { return callCount_; }
std::string getLastTo() const { return lastTo_; }
std::string getLastBody() const { return lastBody_; }
private:
int callCount_ = 0;
std::string lastTo_;
std::string lastBody_;
};
TEST(NotificationServiceTest, SendsWelcomeEmail) {
SpyEmailService spy;
NotificationService service(&spy);
service.sendWelcome("newuser@test.com");
EXPECT_EQ(spy.getCallCount(), 1);
EXPECT_EQ(spy.getLastTo(), "newuser@test.com");
EXPECT_TRUE(spy.getLastBody().find("Welcome") != std::string::npos);
}
Spy vs Stub:Stub 是「我给你数据」,Spy 是「我记下你做了什么」。如果你既需要返回值,又需要记录调用,可以把两者结合——用 Stub 提供数据,用 Spy 记录调用。
8.5 Mock:最严格的考官
Mock 是测试替身里最「较真」的那个。它不光要记录调用,还要验证调用是否符合预期——调了几次、传了什么参数、顺序对不对。只要有一点不符合,测试就失败。
我用 Mock 最多的地方,是测试那些「协作型」的代码。比如一个类委托另一个类干活,我想确认委托关系是否正确。
// Mock 示例:使用 Google Mock 框架
class MockPaymentGateway : public IPaymentGateway {
public:
MOCK_METHOD(bool, charge, (const std::string& userId, double amount), (override));
};
TEST(OrderServiceTest, ProcessOrderChargesCorrectAmount) {
MockPaymentGateway mockGateway;
OrderService service(&mockGateway);
// 设定预期:charge 方法应该被调用一次,参数为 "user123" 和 99.99
EXPECT_CALL(mockGateway, charge("user123", 99.99))
.Times(1)
.WillOnce(Return(true));
Order order{"user123", 99.99};
bool result = service.processOrder(order);
EXPECT_TRUE(result);
// 如果 charge 没有被调用,或者参数不对,测试会自动失败
}
注意:Mock 很容易被滥用。我曾经见过一个项目,每个测试都用了七八个 Mock,结果测试比业务代码还难维护。我的建议是——只在验证「跨模块交互」时用 Mock,内部逻辑用 Stub 就够了。
8.6 Fake:轻量级的真实实现
Fake 和其他几个不太一样。它不是一个「假对象」,而是一个「轻量级的真实实现」。比如用内存 Map 模拟数据库,用本地文件模拟远程存储。
Fake 的好处是——它真的有业务逻辑。你可以用它跑完整的流程测试,而不仅仅是单元测试。
// Fake 示例:内存数据库
class FakeUserRepository : public IUserRepository {
public:
void save(const User& user) override {
users_[user.id] = user;
}
User findById(int id) override {
auto it = users_.find(id);
if (it != users_.end()) {
return it->second;
}
throw std::runtime_error("User not found");
}
void deleteById(int id) override {
users_.erase(id);
}
private:
std::map<int, User> users_; // 用内存 Map 模拟数据库
};
TEST(UserRepositoryTest, SaveAndFindUser) {
FakeUserRepository repo;
User user{1, "alice", "alice@test.com"};
repo.save(user);
auto found = repo.findById(1);
EXPECT_EQ(found.name, "alice");
repo.deleteById(1);
EXPECT_THROW(repo.findById(1), std::runtime_error);
}
我的经验:Fake 最适合用在集成测试里。比如你有一个文件系统操作,用 Fake 换成内存操作,测试速度能快上百倍。但要注意——Fake 的实现必须和真实实现的行为一致,否则测试就失去了意义。
8.7 实战中怎么选?一张表说清楚
| 场景 | 推荐替身 | 原因 |
|---|---|---|
| 参数必须传,但用不上 | Dummy | 占位就行,不需要逻辑 |
| 外部依赖返回固定数据 | Stub | 只关心返回值,不关心调用 |
| 确认方法被调用了 | Spy | 记录调用信息,事后验证 |
| 验证调用次数和参数 | Mock | 严格的交互验证 |
| 需要完整功能但不想用真实依赖 | Fake | 轻量实现,有真实逻辑 |
8.8 一个综合例子
最后,咱们看一个完整的例子。假设你要测试一个订单处理系统,它依赖支付网关、邮件服务和数据库。
// 综合示例:订单处理测试
class OrderProcessorTest : public ::testing::Test {
protected:
void SetUp() override {
// 用 Fake 替代数据库
fakeRepo_ = std::make_unique<FakeOrderRepository>();
// 用 Mock 替代支付网关
mockGateway_ = std::make_unique<MockPaymentGateway>();
// 用 Spy 替代邮件服务
spyEmail_ = std::make_unique<SpyEmailService>();
processor_ = std::make_unique<OrderProcessor>(
fakeRepo_.get(), mockGateway_.get(), spyEmail_.get()
);
}
std::unique_ptr<FakeOrderRepository> fakeRepo_;
std::unique_ptr<MockPaymentGateway> mockGateway_;
std::unique_ptr<SpyEmailService> spyEmail_;
std::unique_ptr<OrderProcessor> processor_;
};
TEST_F(OrderProcessorTest, ProcessOrderSuccessfully) {
Order order{"user1", 100.0};
// Mock 验证支付调用
EXPECT_CALL(*mockGateway_, charge("user1", 100.0))
.Times(1)
.WillOnce(Return(true));
bool result = processor_->process(order);
EXPECT_TRUE(result);
// Spy 验证邮件发送
EXPECT_EQ(spyEmail_->getCallCount(), 1);
EXPECT_TRUE(spyEmail_->getLastBody().find("success") != std::string::npos);
// Fake 验证数据已保存
auto saved = fakeRepo_->findById(order.id);
EXPECT_EQ(saved.status, "completed");
}
你看,这个测试里三种替身各司其职——Mock 管支付交互,Spy 管邮件记录,Fake 管数据存储。每个替身只做自己擅长的事,测试既清晰又稳定。
最后说一句:测试替身没有银弹。别想着一个 Mock 打天下,也别觉得 Fake 万能。根据场景选对工具,你的测试才会真正好用。我在项目里见过太多「为了用 Mock 而用 Mock」的测试,最后改代码时全崩了。嗯,希望你别走这个弯路。