8、测试替身(Test Doubles):Stub、Mock、Fake、Dummy、Spy的区别与实战应用

说实话,测试替身这个概念,我当年刚接触的时候也绕了好一阵子。那时候看各种资料,什么Stub、Mock、Fake,感觉每个词都认识,但放在一起就懵了。后来在项目里踩了几次坑,才真正搞明白它们各自是干什么的。

今天咱们就把这五个兄弟——Stub、Mock、Fake、Dummy、Spy,一个一个拎出来说清楚。我会结合我实际项目中的例子,让你看完就能用。

测试替身五兄弟 Dummy 占位用,不调用 Stub 返回预设值 Spy 记录调用信息 Mock 验证交互行为 Fake 轻量实现 核心区别速览 类型 关注点 典型场景 验证方式 Dummy 参数占位 函数签名必须的参数 不验证 Stub 返回值 外部依赖返回固定数据 状态验证 Spy 调用记录 监控真实对象调用 行为验证 Mock 交互行为 验证调用次数/顺序 行为验证

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」的测试,最后改代码时全崩了。嗯,希望你别走这个弯路。

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