单元测试的目标是验证一个方法自身的分支逻辑,而不是验证外部支付网关、短信平台或缓存集群是否工作。可一旦业务方法内部直接发起 HTTP 调用或打开数据库连接,测试就很难保持快速和稳定。网络超时、第三方限流、环境数据被并发修改,都会让同一个用例在没有改动代码的情况下失败。要正确测试这类方法,需要先做依赖隔离:把外部服务抽象成接口,测试时用可控替身替换真实实现,让业务逻辑在确定条件下运行。

外部依赖为什么会让单元测试失去意义
单元测试有三个基本要求:执行快、结果确定、能够反复运行。真实的外部服务几乎同时破坏这三点。一个支付接口可能要几百毫秒才返回,一个短信通道可能被运营商限流,一个共享数据库可能被其他人的测试写入脏数据。测试一旦依赖这些服务,通过与否就不完全取决于业务代码,而取决于外部环境是否配合。这样的用例经常在本地通过、在 CI 失败,最终只会让团队失去对测试的信任。
更隐蔽的问题是,外部服务会把业务测试变成集成测试。比如订单方法调用支付网关,支付成功时创建订单,支付失败时关闭订单。如果测试真的调用支付网关,你要准备一个可以支付的账号、保证余额充足、清理每次产生的交易记录,成本极高。而单元测试真正想验证的是:当支付返回成功时,订单状态是否正确;当支付返回失败时,是否正确拦截。这些分支只需要一个返回成功或失败的替身就能覆盖。
因此,隔离外部依赖不是偷懒,而是让单元测试回到它的职责范围。外部服务的真实连通性、协议兼容性、超时重试机制,应该交给集成测试或契约测试去验证。单元测试里只保留业务规则和调用参数的判断,这样用例会非常稳定。
Mock、Stub 和 Fake 到底怎么选
测试替身有很多种,最常被混淆的是 Mock、Stub 和 Fake。它们解决不同问题:Stub 用来提供固定返回值,让测试进入某个预设分支;Mock 用来记录交互过程,验证方法是否按预期调用依赖;Fake 则是一个轻量的替代实现,通常带内存状态。对查询类依赖,比如读取用户信息、查询库存,用 Stub 足够;对命令类依赖,比如发起支付、发送通知,用 Mock 更合适,因为你要确认这些动作真的发生了。
举例来说,CustomerRepository.findByEmail 是一个读操作,测试里只需要让它返回一个固定用户,不需要验证它被调用几次。而 NotificationSender.send 是一个有副作用的写操作,应该用 Mock 验证下单成功后确实发送了通知,以及发送的对象和次数是否匹配。Fake 则适合订单号生成器、内存队列这类有多步状态变化的组件,例如一个 InMemoryInventory 可以在测试中模拟扣减库存和恢复库存。
选择替身时不要一刀切。不要把一个查询接口也做成 Mock,然后每个测试都写 verify(repository, times(1)).findByEmail(...)。这种断言绑定了实现细节,会让测试变脆。原则是:需要确认的动作才用 Mock,只需要准备数据的分支用 Stub,需要连续状态变化的用 Fake。
依赖注入是隔离外部服务的前提
很多业务方法难以测试,并不是逻辑复杂,而是依赖被写死在方法内部。例如直接在方法里 new PaymentApiClient() 或者调用 HttpClient.post(),测试时无法替换这个真实客户端。要让外部服务可被隔离,必须引入依赖注入:业务类不负责创建依赖,而是通过构造函数、方法参数或工厂接收依赖。
构造函数注入是最直观的方式。下面的订单服务不关心支付网关由谁实现,只依赖 PaymentGateway 接口。测试时传入 Mockito 创建的替身,生产环境传入真实适配器。
public interface PaymentGateway {
boolean charge(int amount);
}
public class OrderService {
private final PaymentGateway paymentGateway;
public OrderService(PaymentGateway paymentGateway) {
this.paymentGateway = paymentGateway;
}
public OrderResult placeOrder(Order order) {
if (order.getQuantity() <= 0) {
return OrderResult.invalid("数量必须大于0");
}
boolean paid = paymentGateway.charge(order.getAmount());
if (!paid) {
return OrderResult.failed("支付失败");
}
return OrderResult.success(order.getId());
}
}
如果历史代码已经硬编码了外部客户端,可以先提取一个最小接口,再让真实客户端实现该接口。改造初期可以保留一个默认构造函数调用真实实现,同时增加一个接受接口的测试构造函数,逐步把生产代码迁移到显式注入。虽然这会增加一点样板代码,但换来的是业务方法可以在几毫秒内完成测试,且完全不依赖网络。
另外,依赖注入的粒度要控制在端口级别,而不是把整个服务容器传进去。业务方法只需要它真正会调用的那几个接口,传入大而全的容器会让测试准备成本变高,也难以看清类与类之间的协作关系。
用 Mockito 编写稳定的业务单元测试
下面是一个完整的 JUnit 5 加 Mockito 测试示例。测试用例分别覆盖支付成功、支付失败和支付异常三种情况,每个用例都只准备最小限度的替身,然后断言业务结果。
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class OrderServiceTest {
@Test
void shouldReturnSuccessWhenPaymentSucceeds() {
PaymentGateway paymentGateway = mock(PaymentGateway.class);
OrderService service = new OrderService(paymentGateway);
Order order = new Order("1001", 2, 199);
when(paymentGateway.charge(398)).thenReturn(true);
OrderResult result = service.placeOrder(order);
assertTrue(result.isSuccess());
assertEquals("1001", result.getOrderId());
verify(paymentGateway, times(1)).charge(398);
}
@Test
void shouldReturnFailedWhenPaymentFails() {
PaymentGateway paymentGateway = mock(PaymentGateway.class);
OrderService service = new OrderService(paymentGateway);
Order order = new Order("1002", 1, 99);
when(paymentGateway.charge(99)).thenReturn(false);
OrderResult result = service.placeOrder(order);
assertFalse(result.isSuccess());
assertEquals("支付失败", result.getMessage());
verify(paymentGateway, times(1)).charge(99);
}
}
第一个用例验证正向流程:支付成功时订单创建成功。第二个用例验证反向分支:支付失败时返回业务错误。注意 verify 只检查了支付网关是否按预期金额调用了一次,没有去验证支付网关内部的通信细节。这样即使未来把 HTTP 实现换成消息队列实现,只要接口不变,测试仍然成立。
当外部服务抛异常时,业务方法应当把异常转换成可预期的业务结果,而不是让测试直接失败。下面这个用例模拟支付连接超时,断言订单服务返回失败结果。
@Test
void shouldReturnFailedWhenPaymentThrowsException() {
PaymentGateway paymentGateway = mock(PaymentGateway.class);
OrderService service = new OrderService(paymentGateway);
Order order = new Order("1003", 1, 129);
when(paymentGateway.charge(129)).thenThrow(new RuntimeException("连接超时"));
OrderResult result = service.placeOrder(order);
assertFalse(result.isSuccess());
assertTrue(result.getMessage().contains("支付失败"));
verify(paymentGateway, times(1)).charge(129);
}
这里的关键点是业务方法内部捕获了支付异常,并返回统一的失败结果。单元测试验证的是这个容错行为,而不是让异常穿透到调用方。至于支付网关为什么超时、是否需要重试,属于真实适配器和集成测试的范畴。
还要避免一种反模式:为了覆盖所有分支,把 Mock 的返回值设置得非常复杂,甚至模拟真实服务的内部状态机。单元测试应当简单直接,一个用例只驱动一个关键分支。测试数据可以通过工厂方法或 Builder 构造,减少重复代码,但不要为了复用而把多个用例串成一个长流程。