一个测试写错,项目延期两周:Mock和Stub到底怎么用?_千锋教育

来源:千锋教育
发布时间:2026-07-03 13:43:12

千锋教育品牌logo

01 测试替身:为什么你的单元测试总是不“单元”?

单元测试的核心是“隔离”。但现实中的代码,没有哪个方法能独自存活。一个订单服务要调用支付网关,一个用户模块要查询数据库,一个通知系统要发送邮件。这些外部依赖,就是单元测试的“污染源”。

真正的单元测试,必须切断所有外部依赖。

这就是测试替身(Test Double)的战场。测试替身是一个通用术语,指代所有在测试中替代真实对象的“替身演员”。这个家族里有两个核心成员:Stub(桩)和Mock(模拟对象)。

先看一个反面教材。假设你有一个OrderService,它依赖PaymentGateway:

java
public class OrderService {
    private PaymentGateway paymentGateway;
    
    public OrderService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }
    
    public boolean processOrder(Order order) {
        boolean paymentSuccess = paymentGateway.charge(order.getAmount());
        if (paymentSuccess) {
            order.setStatus(OrderStatus.PAID);
            return true;
        }
        return false;
    }
}

最差的测试写法是直接调用真实的PaymentGateway:

java
// 错误示范:依赖真实外部服务
public class OrderServiceTest {
    @Test
    public void testProcessOrder() {
        PaymentGateway realGateway = new RealPaymentGateway(); // 需要网络、账号、余额
        OrderService service = new OrderService(realGateway);
        Order order = new Order(100.0);
        boolean result = service.processOrder(order);
        assertTrue(result);
    }
}

这个测试跑一次要3秒,跑100次要5分钟。更致命的是,如果支付网关宕机,你的测试全部失败。这不是单元测试,是集成测试。

测试不隔离,等于没测试。

正确的做法是用测试替身替换真实依赖。Stub和Mock是两种不同的替身策略。Stub只负责“提供数据”,Mock负责“验证行为”。理解这个区别,是写出高质量单元测试的第一步。

02 Stub:给测试一个“假”但“稳”的答案

Stub的核心使命是提供预设的返回值。它不关心调用次数、调用顺序,只关心“当被调用时,返回什么”。

适用场景:当你的代码需要从外部获取数据,但你不关心数据来源,只关心代码如何处理这些数据。

继续用OrderService的例子。我们需要一个Stub版本的PaymentGateway:

java
// Stub:只提供返回值,不记录调用行为
public class PaymentGatewayStub implements PaymentGateway {
    private boolean chargeResult;
    
    public PaymentGatewayStub(boolean chargeResult) {
        this.chargeResult = chargeResult;
    }
    
    @Override
    public boolean charge(double amount) {
        return chargeResult; // 直接返回预设值
    }
}

测试代码变得干净、快速:

java
public class OrderServiceTest {
    @Test
    public void testProcessOrder_Success() {
        // 创建Stub,预设返回true
        PaymentGatewayStub stub = new PaymentGatewayStub(true);
        OrderService service = new OrderService(stub);
        Order order = new Order(100.0);
        
        boolean result = service.processOrder(order);
        
        assertTrue(result);
        assertEquals(OrderStatus.PAID, order.getStatus());
    }
    
    @Test
    public void testProcessOrder_Failure() {
        // 创建Stub,预设返回false
        PaymentGatewayStub stub = new PaymentGatewayStub(false);
        OrderService service = new OrderService(stub);
        Order order = new Order(100.0);
        
        boolean result = service.processOrder(order);
        
        assertFalse(result);
        assertEquals(OrderStatus.PENDING, order.getStatus());
    }
}

这个测试跑一次只要0.01秒,100次只要1秒。没有网络延迟,没有数据库连接,没有外部依赖。

Stub让你掌控数据,Mock让你掌控行为。

但Stub有局限性。它只能验证“结果”,不能验证“过程”。比如,你想确认processOrder是否真的调用了paymentGateway.charge(),或者是否传入了正确的金额。Stub做不到这一点,它只会傻傻地返回预设值。

03 Mock:不仅给答案,还要查作业

Mock比Stub多一层责任:验证交互行为。它记录下每一次调用,并允许你在测试结束时检查:这个方法被调用了多少次?传入了什么参数?调用顺序是否正确?

适用场景:当你的代码有副作用(发送邮件、写入队列、记录日志),你需要确保这些操作确实发生了,并且参数正确。

看一个需要Mock的场景。假设OrderService在支付成功后,还需要发送一封邮件:

java
public class OrderService {
    private PaymentGateway paymentGateway;
    private EmailService emailService;
    
    public OrderService(PaymentGateway paymentGateway, EmailService emailService) {
        this.paymentGateway = paymentGateway;
        this.emailService = emailService;
    }
    
    public boolean processOrder(Order order) {
        boolean paymentSuccess = paymentGateway.charge(order.getAmount());
        if (paymentSuccess) {
            order.setStatus(OrderStatus.PAID);
            emailService.sendPaymentConfirmation(order); // 副作用:发送邮件
            return true;
        }
        return false;
    }
}

用Stub测试,你只能验证order.getStatus()。但无法确认邮件是否被发送。这就是Mock登场的时候。

手动写一个Mock版本的EmailService:

java
// Mock:记录调用行为,供后续验证
public class EmailServiceMock implements EmailService {
    private int sendCount = 0;
    private Order lastOrder = null;
    
    @Override
    public void sendPaymentConfirmation(Order order) {
        sendCount++;
        lastOrder = order;
    }
    
    // 验证方法
    public void verifySendCalled(int expectedCount) {
        assertEquals(expectedCount, sendCount);
    }
    
    public void verifySendCalledWith(Order expectedOrder) {
        assertEquals(expectedOrder, lastOrder);
    }
}

测试代码:

java
public class OrderServiceTest {
    @Test
    public void testProcessOrder_SendsEmail() {
        PaymentGatewayStub stub = new PaymentGatewayStub(true);
        EmailServiceMock mock = new EmailServiceMock();
        OrderService service = new OrderService(stub, mock);
        Order order = new Order(100.0);
        
        service.processOrder(order);
        
        // 验证:邮件被发送了一次
        mock.verifySendCalled(1);
        // 验证:发送的订单对象正确
        mock.verifySendCalledWith(order);
    }
}

Mock是测试的“摄像头”,记录每一个关键动作。

手动写Mock很累。实际项目中,99%的情况会用Mock框架(如Mockito、Moq)。但理解手动Mock的原理,能让你更清楚框架底层在做什么。

04 实战:用Mockito写出专业级单元测试

Mockito是Java世界最流行的Mock框架。它把Stub和Mock的功能合二为一,语法简洁到令人发指。

先看依赖(Maven):

xml
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>5.0.0</version>
    <scope>test</scope>
</dependency>

场景1:创建Stub,返回预设值

java
import static org.mockito.Mockito.*;

public class OrderServiceTest {
    @Test
    public void testProcessOrder_WithStub() {
        // 创建Mock对象(同时具备Stub能力)
        PaymentGateway gateway = mock(PaymentGateway.class);
        EmailService emailService = mock(EmailService.class);
        
        // 设置Stub行为:当调用charge(100.0)时,返回true
        when(gateway.charge(100.0)).thenReturn(true);
        
        OrderService service = new OrderService(gateway, emailService);
        Order order = new Order(100.0);
        
        boolean result = service.processOrder(order);
        
        assertTrue(result);
        assertEquals(OrderStatus.PAID, order.getStatus());
    }
}

场景2:创建Mock,验证交互行为

java
@Test
public void testProcessOrder_VerifyEmailSent() {
    PaymentGateway gateway = mock(PaymentGateway.class);
    EmailService emailService = mock(EmailService.class);
    
    when(gateway.charge(100.0)).thenReturn(true);
    
    OrderService service = new OrderService(gateway, emailService);
    Order order = new Order(100.0);
    
    service.processOrder(order);
    
    // 验证:emailService的sendPaymentConfirmation方法被调用了1次,且参数是order
    verify(emailService, times(1)).sendPaymentConfirmation(order);
}

场景3:参数匹配器,灵活验证

java
@Test
public void testProcessOrder_VerifyWithAnyAmount() {
    PaymentGateway gateway = mock(PaymentGateway.class);
    EmailService emailService = mock(EmailService.class);
    
    // 只要金额大于0,就返回true
    when(gateway.charge(anyDouble())).thenReturn(true);
    
    OrderService service = new OrderService(gateway, emailService);
    Order order = new Order(99.99);
    
    service.processOrder(order);
    
    // 验证:支付网关被调用,金额是99.99
    verify(gateway, times(1)).charge(99.99);
    // 验证:邮件被发送
    verify(emailService, times(1)).sendPaymentConfirmation(order);
}

场景4:异常模拟,测试错误处理

java
@Test(expected = PaymentException.class)
public void testProcessOrder_PaymentFails() {
    PaymentGateway gateway = mock(PaymentGateway.class);
    EmailService emailService = mock(EmailService.class);
    
    // 模拟支付网关抛出异常
    when(gateway.charge(anyDouble())).thenThrow(new PaymentException("Insufficient funds"));
    
    OrderService service = new OrderService(gateway, emailService);
    Order order = new Order(1000.0);
    
    service.processOrder(order); // 应该抛出异常
}

一行when,胜过十行if;一句verify,查遍所有调用。

使用Mockito后,每个测试方法不超过10行。测试速度从秒级降到毫秒级。这才是单元测试该有的样子。

05 选Stub还是Mock?一个决策框架

新手最常问的问题:什么时候用Stub,什么时候用Mock?

核心原则:优先用Stub,只在需要验证行为时用Mock。

决策框架:

1. 你的测试对象是否返回数据? → 用Stub

2. 你的测试对象是否产生副作用(发送消息、写入文件、调用外部API)? → 用Mock

3. 你只关心最终结果? → 用Stub

4. 你需要确认过程是否正确? → 用Mock

看几个实际场景:

场景推荐替身原因
从数据库查询用户信息Stub只关心返回的用户数据
写入订单到数据库Mock需要验证写入操作是否发生
调用第三方支付APIStub只关心支付成功/失败的结果
发送短信通知Mock需要验证短信是否被发送
读取配置文件Stub只关心配置值
记录审计日志Mock需要验证日志内容是否正确

还有一个反直觉的事实:过度使用Mock会让测试变脆弱。如果你对每个内部方法都做Mock验证,测试就和实现细节强耦合。改一行代码,10个测试同时失败。

正确做法:只Mock外部边界(数据库、网络、文件系统),不Mock内部逻辑。

Stub测逻辑,Mock测边界。混用是灾难,分清是艺术。

总结

Stub给你数据,Mock给你真相。Stub回答“是什么”,Mock回答“发生了什么”。Stub让测试变快,Mock让测试变准。

一个测试不用Mock,就像闭着眼走路;一个测试全是Mock,就像带着镣铐跳舞。

从今天开始,拒绝在单元测试中调用真实数据库、真实API。用Stub隔离数据,用Mock验证行为。你的测试速度会提升10倍,你的bug率会下降90%。

记住:真正的单元测试,不需要网络,不需要数据库,只需要代码和逻辑。

声明:本站部分稿件版权来源于网络,如有侵犯版权,请及时联系我们。

相关推荐

  • 从手动测试到自动验证:Truffle与Hardhat智能合约测试实战_千锋教育 01为什么智能合约测试不是“锦上添花”,而是“生死存亡”2022年,跨链桥Ronin被黑,损失6.2亿美元。2023年,CurveFinance重入攻击,2.5亿美元蒸发。这些事故的共性?智能合约代码中的逻辑漏洞,在部署前未被充分测试。区块
  • 一个测试写错,项目延期两周:Mock和Stub到底怎么用?_千锋教育 01测试替身:为什么你的单元测试总是不“单元”?单元测试的核心是“隔离”。但现实中的代码,没有哪个方法能独自存活。一个订单服务要调用支付网关,一个用户模块要查询数据库,一个通知系统要发送邮件。这些外部依赖,就是单元测试的“污染源”。真正的单
  • 软件测试学习成果展示方法:别让你的能力“隐形”_千锋教育 01为什么展示比学习重要?——你的能力藏得太深了零基础学测试的人,有一个共同的死穴:你以为学会=别人知道你会。看一组数据:千锋教育500名学员的追踪统计,面试前做成果展示的学员,面试通过率82%,没做展示的只有31%。差距51个百分点。不是
  • 学了3个月测试,你还在原地打转?90%的人踩了这5个坑_千锋教育 01理论看了100遍,动手就懵?从“知道”到“做到”,只差这一步你打开B站,收藏了30个“软件测试零基础入门”视频;你买了三本测试书,从《软件测试》到《测试架构师》;你笔记做了200页,画满了流程图和用例模板。然后,面试官让你写一个登录模块
  • 3步搞定Appium+XCUITest测试结果导入Grafana,效率提升200%_千锋教育 01为什么你的iOS自动化测试总在“裸奔”?移动端测试团队最怕什么?不是Bug多,是不知道Bug什么时候冒出来的。跑完1000条用例,第二天才发现昨晚的回归测试挂了80条,这种滞后感让测试工程师沦为“事后诸葛亮”。Appium搭配XCUIT
  • 测试数据可视化:用Superset 10分钟搭建仪表盘,效率提升200%_千锋教育 01为什么测试团队必须扔掉Excel?测试数据从来不是问题,问题是你看数据的方式。一个中等规模的互联网项目,每天产生10万条测试用例执行记录、5000条缺陷日志、200次构建结果。你用Excel打开这个CSV文件,加载需要3分钟,筛选条件要