本文属于《从入门到放弃》系列,但这次我要劝你别放弃,因为不写单元测试的代码,就像没系安全带的汽车——平时感觉挺自由,出事了直接起飞。
前言:一个没写单元测试的程序员的一天
早上9点,产品经理说:"这个需求很简单,就改一行代码。"
你信了,改了。
下午3点,线上告警疯狂轰炸你的钉钉。
晚上11点,你还在公司排查为什么改了A模块,B模块炸了,C模块也顺便炸了。
你开始怀疑人生:"我明明只改了一行啊?!"
这,就是没有单元测试的代价。
如果你写了单元测试,这一行代码改完,跑一下测试,3秒钟就知道有没有搞砸。而不是等到线上用户帮你测试。
用户测试的成本,那可是真金白银的赔偿啊兄弟!
一、单元测试是什么?不就是写代码测代码吗?
没错,单元测试就是用代码测试代码。
但很多人对单元测试有误解,以为就是启动整个Spring容器,然后调一下接口看看返回值对不对。那叫集成测试,不叫单元测试。
来看一张图,理解一下测试金字塔:
数量最少 | 成本最高 | 速度最慢"] INT["🔶 集成测试
数量适中 | 成本中等 | 速度中等"] UNIT["🟢 单元测试
数量最多 | 成本最低 | 速度最快"] end E2E --> INT INT --> UNIT style E2E fill:#ff6b6b,stroke:#333,stroke-width:2px,color:#fff style INT fill:#feca57,stroke:#333,stroke-width:2px,color:#333 style UNIT fill:#1dd1a1,stroke:#333,stroke-width:2px,color:#fff
单元测试的特点:
| 特性 | 说明 |
|---|---|
| 快 | 毫秒级执行,跑几百个测试也就几秒钟 |
| 独立 | 不依赖数据库、Redis、MQ等外部服务 |
| 可重复 | 跑一万次结果都一样 |
| 自动化 | CI/CD流水线自动跑 |
单元测试测的是"单元",在Java里,通常指的是一个方法,最多是一个类。
二、Spring Boot测试全家桶
Spring Boot贴心地给我们准备了一套测试工具,加个依赖就能用:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>这一个依赖,包含了以下豪华套餐:
测试框架本体"] Mockito["Mockito
Mock神器"] AssertJ["AssertJ
断言增强"] Hamcrest["Hamcrest
匹配器"] JSONPath["JSONPath
JSON断言"] Spring["Spring Test
Spring测试支持"] end style JUnit5 fill:#6c5ce7,color:#fff style Mockito fill:#00b894,color:#fff style AssertJ fill:#0984e3,color:#fff style Hamcrest fill:#fdcb6e,color:#333 style JSONPath fill:#e17055,color:#fff style Spring fill:#00cec9,color:#fff
2.1 JUnit 5 基础写法
先来个最简单的例子热热身:
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
@Test
@DisplayName("1 + 1 应该等于 2,如果不等于,那数学就完蛋了")
void testAdd() {
Calculator calculator = new Calculator();
int result = calculator.add(1, 1);
assertEquals(2, result);
}
@Test
@DisplayName("除以0应该抛异常,不然宇宙会爆炸")
void testDivideByZero() {
Calculator calculator = new Calculator();
assertThrows(ArithmeticException.class, () -> {
calculator.divide(10, 0);
});
}
}JUnit 5常用注解速查表:
| 注解 | 作用 | 使用场景 |
|---|---|---|
@Test | 标记测试方法 | 每个测试方法都要加 |
@DisplayName | 给测试起个人话的名字 | 让测试报告更易读 |
@BeforeEach | 每个测试前执行 | 初始化测试数据 |
@AfterEach | 每个测试后执行 | 清理资源 |
@BeforeAll | 所有测试前执行一次 | 重量级初始化 |
@Disabled | 禁用测试 | 临时跳过某个测试 |
@ParameterizedTest | 参数化测试 | 同一逻辑不同参数 |
2.2 参数化测试:一次写完,多组数据
假设你要测试一个判断成年的方法,难道要写18个测试?不用,参数化测试安排上:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
class AgeValidatorTest {
@ParameterizedTest
@DisplayName("成年人判断")
@CsvSource({
"17, false",
"18, true",
"19, true",
"100, true",
"0, false"
})
void testIsAdult(int age, boolean expected) {
AgeValidator validator = new AgeValidator();
assertEquals(expected, validator.isAdult(age));
}
@ParameterizedTest
@DisplayName("这些年龄都应该是成年人")
@ValueSource(ints = {18, 25, 30, 65, 100})
void testAdultAges(int age) {
AgeValidator validator = new AgeValidator();
assertTrue(validator.isAdult(age));
}
}三、Mock:假装有对象
在真实项目中,你的Service不可能是孤独的,它依赖Repository、依赖其他Service、依赖第三方API...
但单元测试要"隔离",怎么办?
Mock!用假的对象替代真的依赖。
这就像拍电影,男主角要从大楼跳下去,你不能真的让他跳吧?找个替身(Mock)啊!
3.1 Mockito基础用法
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class) // 启用Mockito
class UserServiceTest {
@Mock // 这是个假的!
private UserRepository userRepository;
@Mock
private EmailService emailService;
@InjectMocks // 把上面的假货注入到这里
private UserService userService;
@Test
@DisplayName("根据ID查用户 - 用户存在")
void testGetUserById_Found() {
// Given: 准备数据,告诉Mock对象该怎么表演
User fakeUser = new User(1L, "张三", "zhangsan@test.com");
when(userRepository.findById(1L)).thenReturn(Optional.of(fakeUser));
// When: 执行被测方法
User result = userService.getUserById(1L);
// Then: 验证结果
assertNotNull(result);
assertEquals("张三", result.getName());
assertEquals("zhangsan@test.com", result.getEmail());
// 验证Mock对象的方法确实被调用了
verify(userRepository, times(1)).findById(1L);
}
@Test
@DisplayName("根据ID查用户 - 用户不存在,应该抛异常")
void testGetUserById_NotFound() {
// Given
when(userRepository.findById(999L)).thenReturn(Optional.empty());
// When & Then
assertThrows(UserNotFoundException.class, () -> {
userService.getUserById(999L);
});
}
}3.2 Mock的各种骚操作
@Test
void testMockitoFeatures() {
// 1. 返回固定值
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
// 2. 抛异常
when(userRepository.findById(-1L)).thenThrow(new RuntimeException("ID不能为负数!"));
// 3. 根据参数动态返回(Answer)
when(userRepository.findById(anyLong())).thenAnswer(invocation -> {
Long id = invocation.getArgument(0);
if (id > 0) {
return Optional.of(new User(id, "用户" + id, "user" + id + "@test.com"));
}
return Optional.empty();
});
// 4. 连续调用返回不同值
when(userRepository.count())
.thenReturn(1L) // 第一次调用返回1
.thenReturn(2L) // 第二次调用返回2
.thenReturn(3L); // 第三次及以后返回3
// 5. void方法抛异常
doThrow(new RuntimeException("发送失败"))
.when(emailService).sendEmail(anyString(), anyString());
// 6. 验证方法调用次数
verify(userRepository, times(1)).findById(1L);
verify(userRepository, never()).deleteById(anyLong());
verify(emailService, atLeast(2)).sendEmail(anyString(), anyString());
// 7. 捕获参数
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(userRepository).save(captor.capture());
User savedUser = captor.getValue();
assertEquals("张三", savedUser.getName());
}3.3 @MockBean vs @Mock
这俩长得像,但用的地方不一样:
四、Service层测试实战
来个实际的例子,假设我们有个订单服务:
// 被测试的Service
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final ProductService productService;
private final InventoryService inventoryService;
private final PaymentService paymentService;
@Transactional
public Order createOrder(CreateOrderRequest request) {
// 1. 检查商品是否存在
Product product = productService.getProductById(request.getProductId());
if (product == null) {
throw new ProductNotFoundException("商品不存在");
}
// 2. 检查库存
if (!inventoryService.checkStock(request.getProductId(), request.getQuantity())) {
throw new InsufficientStockException("库存不足");
}
// 3. 计算金额
BigDecimal totalAmount = product.getPrice()
.multiply(BigDecimal.valueOf(request.getQuantity()));
// 4. 创建订单
Order order = Order.builder()
.userId(request.getUserId())
.productId(request.getProductId())
.quantity(request.getQuantity())
.totalAmount(totalAmount)
.status(OrderStatus.PENDING)
.createTime(LocalDateTime.now())
.build();
return orderRepository.save(order);
}
}测试代码:
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private ProductService productService;
@Mock
private InventoryService inventoryService;
@Mock
private PaymentService paymentService;
@InjectMocks
private OrderService orderService;
private CreateOrderRequest validRequest;
private Product testProduct;
@BeforeEach
void setUp() {
validRequest = CreateOrderRequest.builder()
.userId(1L)
.productId(100L)
.quantity(2)
.build();
testProduct = Product.builder()
.id(100L)
.name("机械键盘")
.price(new BigDecimal("299.00"))
.build();
}
@Test
@DisplayName("创建订单 - 正常流程")
void testCreateOrder_Success() {
// Given
when(productService.getProductById(100L)).thenReturn(testProduct);
when(inventoryService.checkStock(100L, 2)).thenReturn(true);
when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> {
Order order = invocation.getArgument(0);
order.setId(1L); // 模拟数据库生成ID
return order;
});
// When
Order result = orderService.createOrder(validRequest);
// Then
assertNotNull(result);
assertEquals(1L, result.getUserId());
assertEquals(100L, result.getProductId());
assertEquals(2, result.getQuantity());
assertEquals(new BigDecimal("598.00"), result.getTotalAmount()); // 299 * 2
assertEquals(OrderStatus.PENDING, result.getStatus());
// 验证调用链
verify(productService).getProductById(100L);
verify(inventoryService).checkStock(100L, 2);
verify(orderRepository).save(any(Order.class));
}
@Test
@DisplayName("创建订单 - 商品不存在")
void testCreateOrder_ProductNotFound() {
// Given
when(productService.getProductById(100L)).thenReturn(null);
// When & Then
ProductNotFoundException exception = assertThrows(
ProductNotFoundException.class,
() -> orderService.createOrder(validRequest)
);
assertEquals("商品不存在", exception.getMessage());
// 验证后续方法没有被调用
verify(inventoryService, never()).checkStock(anyLong(), anyInt());
verify(orderRepository, never()).save(any());
}
@Test
@DisplayName("创建订单 - 库存不足")
void testCreateOrder_InsufficientStock() {
// Given
when(productService.getProductById(100L)).thenReturn(testProduct);
when(inventoryService.checkStock(100L, 2)).thenReturn(false);
// When & Then
assertThrows(InsufficientStockException.class, () -> {
orderService.createOrder(validRequest);
});
verify(orderRepository, never()).save(any());
}
@Test
@DisplayName("创建订单 - 金额计算正确性(多组数据)")
@ParameterizedTest
@CsvSource({
"1, 299.00, 299.00",
"2, 299.00, 598.00",
"10, 99.99, 999.90",
"100, 0.01, 1.00"
})
void testCreateOrder_AmountCalculation(int quantity, String price, String expectedTotal) {
// Given
testProduct.setPrice(new BigDecimal(price));
validRequest.setQuantity(quantity);
when(productService.getProductById(100L)).thenReturn(testProduct);
when(inventoryService.checkStock(anyLong(), anyInt())).thenReturn(true);
when(orderRepository.save(any(Order.class))).thenAnswer(i -> i.getArgument(0));
// When
Order result = orderService.createOrder(validRequest);
// Then
assertEquals(new BigDecimal(expectedTotal), result.getTotalAmount());
}
}五、Controller层测试
Controller层测试用MockMvc,可以模拟HTTP请求,但不需要启动真正的服务器:
@WebMvcTest(UserController.class) // 只加载Controller层
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean // 注意这里用@MockBean,因为需要Spring容器
private UserService userService;
@Autowired
private ObjectMapper objectMapper;
@Test
@DisplayName("GET /users/{id} - 查询用户成功")
void testGetUser_Success() throws Exception {
// Given
User user = new User(1L, "张三", "zhangsan@test.com");
when(userService.getUserById(1L)).thenReturn(user);
// When & Then
mockMvc.perform(get("/users/1")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("张三"))
.andExpect(jsonPath("$.email").value("zhangsan@test.com"));
}
@Test
@DisplayName("GET /users/{id} - 用户不存在返回404")
void testGetUser_NotFound() throws Exception {
// Given
when(userService.getUserById(999L))
.thenThrow(new UserNotFoundException("用户不存在"));
// When & Then
mockMvc.perform(get("/users/999"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("用户不存在"));
}
@Test
@DisplayName("POST /users - 创建用户")
void testCreateUser() throws Exception {
// Given
CreateUserRequest request = new CreateUserRequest("李四", "lisi@test.com");
User createdUser = new User(2L, "李四", "lisi@test.com");
when(userService.createUser(any(CreateUserRequest.class))).thenReturn(createdUser);
// When & Then
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(2))
.andExpect(jsonPath("$.name").value("李四"));
}
@Test
@DisplayName("POST /users - 参数校验失败")
void testCreateUser_ValidationFailed() throws Exception {
// Given: 空的name
CreateUserRequest request = new CreateUserRequest("", "invalid-email");
// When & Then
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
}六、Repository层测试
Repository层用@DataJpaTest,它会自动配置内存数据库(H2):
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // 如果要用真实数据库配置
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager entityManager;
@Test
@DisplayName("测试自定义查询方法")
void testFindByEmail() {
// Given
User user = User.builder()
.name("测试用户")
.email("test@example.com")
.build();
entityManager.persistAndFlush(user);
// When
Optional<User> found = userRepository.findByEmail("test@example.com");
// Then
assertTrue(found.isPresent());
assertEquals("测试用户", found.get().getName());
}
@Test
@DisplayName("测试复杂查询")
void testFindActiveUsersByNameContaining() {
// Given
entityManager.persist(new User("张三丰", "zsf@test.com", true));
entityManager.persist(new User("张三", "zs@test.com", true));
entityManager.persist(new User("张三疯", "zsf2@test.com", false)); // 非活跃
entityManager.persist(new User("李四", "ls@test.com", true));
entityManager.flush();
// When
List<User> users = userRepository.findByNameContainingAndActiveTrue("张三");
// Then
assertEquals(2, users.size());
assertTrue(users.stream().allMatch(User::isActive));
}
}七、测试覆盖率:别让你的代码裸奔
测试写了多少?够不够?用覆盖率说话。
推荐使用JaCoCo:
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.10</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>运行mvn test后,在target/site/jacoco/index.html查看报告。
覆盖率的真相:
- 80%覆盖率是个不错的目标
- 100%覆盖率是个美丽的谎言(有些代码真的不用测)
- 覆盖率高不代表测试质量高(你可以写一堆不带断言的测试)
八、测试最佳实践
8.1 命名规范
// ❌ 不好的命名
@Test
void test1() { }
@Test
void testUser() { }
// ✅ 好的命名:被测方法_场景_期望结果
@Test
void getUserById_WhenUserExists_ShouldReturnUser() { }
@Test
void createOrder_WhenStockInsufficient_ShouldThrowException() { }
// ✅ 或者用中文@DisplayName
@Test
@DisplayName("创建订单 - 库存不足时应抛出异常")
void testCreateOrderWithInsufficientStock() { }8.2 测试结构:Given-When-Then
@Test
void testTransferMoney() {
// Given: 准备测试数据和Mock行为
Account from = new Account(1L, new BigDecimal("1000"));
Account to = new Account(2L, new BigDecimal("500"));
when(accountRepository.findById(1L)).thenReturn(Optional.of(from));
when(accountRepository.findById(2L)).thenReturn(Optional.of(to));
// When: 执行被测试的方法
accountService.transfer(1L, 2L, new BigDecimal("300"));
// Then: 验证结果
assertEquals(new BigDecimal("700"), from.getBalance());
assertEquals(new BigDecimal("800"), to.getBalance());
verify(accountRepository, times(2)).save(any(Account.class));
}8.3 一个测试只测一件事
// ❌ 测太多东西
@Test
void testUserService() {
// 测创建
User created = userService.create(request);
assertNotNull(created);
// 测查询
User found = userService.getById(created.getId());
assertEquals(created, found);
// 测更新
userService.update(created.getId(), updateRequest);
// ...
// 测删除
userService.delete(created.getId());
// ...
}
// ✅ 每个测试专注一个场景
@Test void create_ValidRequest_Success() { }
@Test void create_DuplicateEmail_ThrowException() { }
@Test void getById_Exists_ReturnUser() { }
@Test void getById_NotExists_ThrowException() { }8.4 测试数据构建器模式
当测试数据复杂时,用Builder模式:
public class UserTestDataBuilder {
private Long id = 1L;
private String name = "默认用户";
private String email = "default@test.com";
private boolean active = true;
public static UserTestDataBuilder aUser() {
return new UserTestDataBuilder();
}
public UserTestDataBuilder withId(Long id) {
this.id = id;
return this;
}
public UserTestDataBuilder withName(String name) {
this.name = name;
return this;
}
public UserTestDataBuilder inactive() {
this.active = false;
return this;
}
public User build() {
return new User(id, name, email, active);
}
}
// 使用
User activeUser = aUser().withName("张三").build();
User inactiveUser = aUser().withName("李四").inactive().build();九、常见问题QA
Q1: 测试代码也需要维护吗?
是的!测试代码是一等公民。烂测试比没测试更可怕,因为它会给你虚假的安全感。
Q2: 什么代码不需要测试?
- getter/setter(除非有特殊逻辑)
- 简单的委托方法
- 配置类
- 纯粹的数据传输对象(DTO)
Q3: Mock太多是不是有问题?
如果一个类需要Mock 5个以上依赖,可能说明这个类职责太多了,考虑重构。
Q4: 测试要不要测私有方法?
不要。测试应该通过公有方法来覆盖私有方法。如果私有方法很难被覆盖,可能需要重构。
十、总结
写单元测试确实会让你多花一些时间,但这是投资,不是成本。
它会在未来某个深夜,当你需要重构一段祖传代码时,默默地保护你。
它会在CI流水线上,帮你拦住那些"改了一行就爆炸"的提交。
它会让你成为一个更好的程序员,因为可测试的代码,往往也是设计良好的代码。
所以,从今天开始,让我们一起告别"能跑就行",拥抱"敢改就改"!