单元测试
单元测试:写好单测是程序员的基本修养
很多人觉得写单测是浪费时间,觉得"我的代码逻辑很清楚,不需要测试"。但真正经历过线上事故的人都知道——每一行没测到的代码,都是一颗定时炸弹。这篇文章从 Mock 框架讲到 TDD,帮你建立起扎实的单测思维。
一、Mock:给测试造一个"假人"
1.1 什么是 Mock
你见过汽车碰撞测试吗?车里坐的不是真人,而是仿真假人。假人的体重、关节、组织强度都和真人一样,但不会受伤。而且为了测试的全面性,还要用到各种类型的假人——成年人、老人、小孩、男性、女性。
单元测试中的 Mock 就是这个假人。当你要测试的代码依赖了外部服务(RPC 接口、数据库、消息队列),你不可能每次跑测试都真的去调用它们——太慢、不稳定、还会产生脏数据。而且有些异常场景(比如网络超时、服务不可用)在真实环境中很难复现。
Mock 的做法是:用一个假的对象替代真实的依赖,让你只关注自己代码的逻辑是否正确。
比如你的支付接口依赖了银行通道,你可以:
- Mock 银行通道返回"成功"→ 测试正常流程
- Mock 返回"余额不足"→ 测试异常处理
- Mock 抛出超时异常 → 测试容错逻辑
- Mock 返回 null → 测试空值防御
所有的边界情况都能轻松模拟,这就是 Mock 的威力。
1.2 三大 Mock 框架对比
Java 生态中最主流的 Mock 框架有三个:Mockito、EasyMock、JMockit。
| 维度 | Mockito | EasyMock | JMockit |
|---|---|---|---|
| 学习曲线 | 低,API 简洁直观 | 中等,需要 record-replay 模式 | 较高,概念多 |
| 静态方法 Mock | 3.4+ 版本支持 | 不支持 | 原生支持 |
| private 方法 | 不直接支持 | 不支持 | 支持 |
| final 类/方法 | 2.x 开始支持 | 不支持 | 支持 |
| 社区活跃度 | 最高,GitHub 14k+ star | 一般,更新慢 | 较低,近年不太活跃 |
| Spring Boot 集成 | 原生集成,开箱即用 | 需要额外配置 | 好 |
实际项目中 Mockito 用得最多,API 简洁、文档丰富、和 Spring Boot 集成极好(spring-boot-starter-test 默认带了 Mockito)。如果需要 Mock 静态方法或 private 方法,可以考虑 JMockit,或者用 Mockito 3.4+ 的 mockStatic。
Mockito 基本用法示例:
// 1. 创建 Mock 对象
PaymentService paymentService = Mockito.mock(PaymentService.class);
// 2. 设定行为:调用 pay() 时返回成功
when(paymentService.pay(any())).thenReturn(Result.success());
// 3. 设定异常:调用 refund() 时抛出超时异常
when(paymentService.refund(any()))
.thenThrow(new TimeoutException("Connection timeout"));
// 4. 在被测代码中注入这个 Mock 对象
OrderService orderService = new OrderService(paymentService);
Result result = orderService.createOrder(request);
// 5. 断言返回值
assertEquals("SUCCESS", result.getStatus());
// 6. 验证交互:pay() 确实被调用了 1 次
verify(paymentService, times(1)).pay(any());
// refund() 没有被调用
verify(paymentService, never()).refund(any());在 Spring Boot 测试中,还可以用 @MockBean 注解更优雅地注入 Mock:
@SpringBootTest
class OrderServiceTest {
@MockBean
private PaymentService paymentService;
@Autowired
private OrderService orderService;
@Test
void testCreateOrder() {
when(paymentService.pay(any())).thenReturn(Result.success());
// ... 测试逻辑
}
}1.3 接口级 Mock
Mock 不只是单元测试的专利。在微服务架构下,你依赖的下游服务可能还没开发完,或者某些异常 case 很难在联调环境中构造。这时候可以用接口级 Mock 工具:
- YApi:去哪儿开源,接口管理 + Mock 一体化,可以根据接口结构自动生成 Mock 数据
- Moco:轻量级 Mock Server,支持 API 调用和独立 jar 运行两种模式
- RAP2:阿里出品,接口文档管理 + 自动 Mock
这些工具的典型使用场景:
- 前后端并行开发——后端接口没写好,前端先用 Mock 数据联调
- 联调环境中模拟下游超时、异常等极端情况
- 压测时 Mock 掉外部依赖,只测自身服务的性能
二、TDD:先写测试,再写代码
TDD(Test Driven Development,测试驱动开发)的思路和常规开发完全相反:
这个循环也叫"红-绿-重构":
- 红灯:先写一个测试用例,此时代码还没实现,测试必然失败
- 绿灯:写最少的代码让测试通过
- 重构:在测试保护下优化代码结构,不改变行为
TDD 的好处:
- 代码天然具有高测试覆盖率
- 接口设计更清晰——你从调用者的角度先思考了接口该怎么用
- 重构有信心——随时改代码,跑一遍测试就知道有没有改坏
- 避免过度设计——只写通过测试需要的最少代码
国内用 TDD 的公司确实不多,但它的思维方式值得借鉴:写代码之前先想清楚要测什么。哪怕不严格执行 TDD,在编码前列一份测试用例清单,也能让你的代码质量上一个台阶。
DDD(领域驱动设计)中提倡的明确边界划分、事件风暴、防腐层设计,天然为 TDD 和单元测试做了很好的铺垫。一个设计良好的领域模型,每个方法职责清晰,单元测试写起来会非常顺畅。
三、什么是好的单测
3.1 发现 bug 的成本曲线
先说一个重要的背景:一个 bug 发现得越晚,修复成本越高。
| 阶段 | 发现成本 | 说明 |
|---|---|---|
| 需求评审 | 小时级 | 设计不合理,会上就能否掉 |
| 编码阶段 | 小时~天级 | 逻辑写错,改改就好 |
| 单元测试 | 毫秒~秒级 | 跑一个 case 几百毫秒,快速反馈 |
| 集成测试 | 分钟级 | 改完要重新部署,改一次等几分钟 |
| 预发/灰度 | 小时级 | 需要回滚、重新发布、重新验证 |
| 线上事故 | 不可估量 | 经济损失 + 用户流失 + 事故通报 |
单元测试是成本最低的质量保障手段——跑一个 case 只要几百毫秒,却能在代码离开你手之前就拦住绝大多数 bug。很多公司把单测放入 CI/CD 流程中,所有 case 跑过才允许部署。
3.2 好单测的七条标准
- 可读:命名符合实际场景(比如
should_return_error_when_balance_insufficient),有适当注释,别人能看懂你在测什么 - 有断言:不要只 print 结果肉眼看。跑全部 case 的时候谁会盯着看?必须用 assert 自动验证
- 测边界:正常输入、异常输入、空值、超大值、边界值——都要覆盖
- 可重复:每次运行结果确定,不依赖外部变化和不确定因素
- 随机数 → Mock 掉
- 网络 IO(数据库、外部接口)→ Mock 掉
- 时间相关 → 注入 Clock 对象来控制
- 粒度细:一个 case 只测一件事。一个方法只干一件事,单测自然好写
- 纳入 CI:单测跑不过就不让部署,强制保障质量
- 测异常:
- 外部异常:依赖的接口抛异常,你的代码会怎样?
- 内部异常:代码自身抛 RuntimeException,会不会把整个流程搞挂?
- 传入错误参数,会正确返回错误信息还是直接 NPE?
3.3 Spock 框架
如果你觉得 JUnit + Mockito 写起来太啰嗦,可以试试 Spock 框架。它用 Groovy 语言编写单测,由于是动态语言,语法极其简洁灵活:
数据驱动测试——一个 case 用表格覆盖所有输入/输出组合:
def "订单金额计算"() {
expect:
calculator.calculate(price, quantity) == expectedTotal
where:
price | quantity || expectedTotal
10.0 | 1 || 10.0
10.0 | 3 || 30.0
0 | 5 || 0
99.9 | 0 || 0
}Spock 相比传统 JUnit + Mockito 的优势:
- Mock/Spy/Stub 概念清晰分离:Mock 验证交互次数和参数,Stub 只设定返回值不关心调用,Spy 只对部分方法 Mock
- 直接测试 private 方法和变量:无视封装等级限制
- 方便验证异常:
thrown(NullPointerException)一行搞定 - 集成 Spring Boot:可以直接测试 HTTP 接口层,不需要启动 Web 容器
3.4 数据库层的单测
业务代码中大量逻辑涉及数据库增删改查,直连数据库做测试有三大问题:
- 速度慢——每个 case 都要做网络 IO
- 脏数据——测试写入的数据会污染开发库
- 不稳定——数据库中已有的历史数据可能影响测试结果
解决方案是内存数据库。H2 是最常用的 Java 内存数据库,它可以:
- 在测试启动时自动创建表结构和初始化数据
- 在测试结束后自动销毁,不留痕迹
- 支持大部分 MySQL 语法(兼容模式)
- 启动速度极快,几百毫秒就能就绪
Spring Boot 集成 H2 非常简单,在 src/test/resources/application.yml 中配置:
spring:
datasource:
url: jdbc:h2:mem:testdb;MODE=MySQL
driver-class-name: org.h2.Driver
sql:
init:
schema-locations: classpath:schema.sql
data-locations: classpath:data.sql这样单测中所有的数据库操作都走 H2 内存数据库,既快速又干净。
四、面试高频题
题目一:单元测试和集成测试有什么区别?
单元测试针对最小单元(方法/类),由开发者编写,自动化运行,毫秒级反馈,外部依赖用 Mock 隔离。集成测试针对模块间的交互和端到端流程,通常由测试人员编写,需要部署完整环境,分钟级反馈。两者互补不可替代——单测保证每个零件没问题,集成测试保证零件组装起来也没问题。
题目二:你平时是怎么写单测的?
先列出要测试的场景(正常流程 + 异常流程 + 边界条件),用 Mockito 隔离外部依赖(RPC、DB、MQ),每个 case 只测一件事,必须有断言。代码保证单一职责,高内聚低耦合,每个方法只干一件事,单测粒度就能很细。单测纳入 CI/CD 流程,跑不过不允许部署。数据库层用 H2 内存数据库替代,保证速度和隔离性。
题目三:单测覆盖率是怎么统计的?
通过字节码插桩实现。工具(如 JaCoCo)在编译时或运行时向字节码中插入监控探针,记录哪些行、哪些分支、哪些方法被执行了。测试跑完后,根据"被执行的代码 / 总代码"计算覆盖率。
常见的覆盖率维度:
- 行覆盖率:有多少行代码被执行了
- 分支覆盖率:if/else 的每个分支是否都走到了
- 方法覆盖率:有多少方法被调用了
注意:覆盖率高不等于测试质量高——没有断言的测试也能刷高覆盖率,但完全没有验证逻辑正确性。覆盖率是必要条件,不是充分条件。
小结
| 知识点 | 一句话记忆 |
|---|---|
| Mock | 用假对象替代真实依赖,隔离外部因素 |
| Mockito | Java 最主流的 Mock 框架,API 简洁,Spring Boot 默认集成 |
| TDD | 先写测试再写代码,红-绿-重构循环 |
| 好单测 | 有断言、测边界、可重复、粒度细、纳入 CI |
| Spock | Groovy 编写,数据驱动测试,语法简洁 |
| 覆盖率 | 字节码插桩统计,高覆盖率 ≠ 高质量 |
| H2 | 内存数据库,测试用完即销毁 |
写单测不是额外的工作量,而是对自己代码的负责。敬畏每一次上线——有用户访问的服务和没有用户访问的服务完全不一样,一个线上 bug 带来的损失,可能比你一年写的所有单测的时间成本都高。
补充:单测实战中的常见问题
如何测试 void 方法
void 方法没有返回值,不能用 assertEquals 来验证。这时候要用 verify 来验证行为:
@Test
void testSendNotification() {
// 准备
when(userDao.findById(1L)).thenReturn(new User(1L, "张三"));
// 执行
notificationService.sendWelcomeEmail(1L);
// 验证:确认 emailSender.send() 被调用了,且参数正确
verify(emailSender, times(1)).send(
eq("张三"),
contains("欢迎")
);
}核心思路:void 方法的正确性体现在"它对外部产生了正确的副作用"——调用了正确的下游方法、传了正确的参数、调用了正确的次数。
如何测试私有方法
严格来说,私有方法不应该被直接测试。如果一个私有方法有足够复杂的逻辑需要单独测试,说明它可能应该被提取成一个独立的类或公共方法。
但如果非要测试,有几种方式:
- 反射:用 Java 反射机制调用私有方法(JUnit 5 提供了
ReflectionUtils) - Spock:直接忽略封装等级,可以调用任何方法
- 改可见性:把 private 改成 package-private(去掉访问修饰符),测试类放在同一个包下
推荐做法还是通过公共方法间接测试私有方法——如果公共方法的测试覆盖了私有方法的所有分支,就够了。
如何测试异步代码
异步代码的测试比较棘手,因为断言执行的时候异步操作可能还没完成。几种常见方案:
// 方案一:CountDownLatch 等待异步完成
@Test
void testAsyncOperation() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<String> result = new AtomicReference<>();
asyncService.doSomething(value -> {
result.set(value);
latch.countDown();
});
assertTrue(latch.await(5, TimeUnit.SECONDS)); // 最多等 5 秒
assertEquals("expected", result.get());
}
// 方案二:Awaitility 库(推荐,更优雅)
@Test
void testAsyncWithAwaitility() {
asyncService.doSomething();
await().atMost(5, SECONDS)
.until(() -> orderDao.findById(1L).getStatus(), equalTo("COMPLETED"));
}如何处理时间相关的测试
如果代码中有 new Date() 或 System.currentTimeMillis(),每次运行测试结果都可能不一样。
解决办法:注入 Clock 对象代替直接获取系统时间。
// 业务代码
public class OrderService {
private final Clock clock;
public OrderService(Clock clock) {
this.clock = clock;
}
public boolean isExpired(Order order) {
return clock.instant().isAfter(order.getExpireTime());
}
}
// 测试代码:用固定时间的 Clock
@Test
void testIsExpired() {
Clock fixedClock = Clock.fixed(
Instant.parse("2024-01-15T10:00:00Z"),
ZoneId.of("UTC")
);
OrderService service = new OrderService(fixedClock);
Order expiredOrder = new Order();
expiredOrder.setExpireTime(Instant.parse("2024-01-15T09:00:00Z"));
assertTrue(service.isExpired(expiredOrder));
}单测的组织结构
推荐的测试方法命名方式:should_预期结果_when_条件
@Test
void should_return_success_when_balance_sufficient() { ... }
@Test
void should_throw_exception_when_user_not_found() { ... }
@Test
void should_send_notification_when_order_created() { ... }测试类的组织推荐 AAA 模式(Arrange-Act-Assert):
@Test
void should_calculate_total_correctly() {
// Arrange: 准备测试数据和 Mock
Order order = new Order(100.0, 3);
when(discountService.getDiscount(any())).thenReturn(0.85);
// Act: 执行被测方法
double total = orderService.calculateTotal(order);
// Assert: 验证结果
assertEquals(255.0, total, 0.01);
}每个 case 清晰地分为"准备-执行-验证"三个部分,可读性极强。