如何寫好單元測試:Mock 脫離數據庫,告別 @SpringBootTest 的重型啟動
作者:Killian(重慶) — 歡迎各位架構獵頭、技術布道者聯系我,項目實戰豐富,代碼穩健,Mock測試愛好者。
技術棧:Java 17、JUnit 5、Mockito 5、Spring Boot 3.x(可選)
一、前言
你是否遇到過以下問題:
- 每次跑測試都要加載整個 Spring 容器,慢如蝸牛?
- 明明只測一個方法,卻啟動了 Redis、MySQL、MQ 等服務?
- 想 Mock 一個 Bean 卻被 @Autowired 綁死?
這時候,我們該說:不需要 @SpringBootTest!
本篇文章將系統講解:
- 如何編寫真正的“單元”測試(Unit Test)
- 如何使用 Mockito 精準 Mock 依賴,避免啟動數據庫等外部依賴
- 如何寫出高覆蓋率、快反饋、可維護的業務邏輯測試
二、為什么要避免 @SpringBootTest?
問題 | 描述 |
---|---|
啟動慢 | @SpringBootTest 會加載整個上下文(Controller、Service、Repository、Config) |
依賴重 | 需要配置數據庫、緩存、RabbitMQ 等外部環境 |
不穩定 | 環境不一致容易導致測試 flaky(有時通過,有時失敗) |
非單元測試 | 實際上是“集成測試”,容易誤用 |
三、正確的方式:使用 Mockito + JUnit 寫真正的單元測試
示例背景
我們有一個服務類:
@Service
public class OrderService {private final OrderRepository orderRepository;private final PaymentClient paymentClient;public OrderService(OrderRepository orderRepository, PaymentClient paymentClient) {this.orderRepository = orderRepository;this.paymentClient = paymentClient;}public String pay(String orderId) {Order order = orderRepository.findById(orderId).orElseThrow(() -> new RuntimeException("訂單不存在"));if (order.isPaid()) {return "重復支付";}boolean result = paymentClient.callPayGateway(order);if (result) {order.markPaid();orderRepository.save(order);return "支付成功";} else {return "支付失敗";}}
}
單元測試寫法(脫離容器 + Mock 依賴)
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {@Mock OrderRepository orderRepository;@Mock PaymentClient paymentClient;@InjectMocks OrderService orderService;@Test@DisplayName("支付成功時,訂單狀態應更新并保存")void testPaySuccess() {Order mockOrder = new Order("123", false);when(orderRepository.findById("123")).thenReturn(Optional.of(mockOrder));when(paymentClient.callPayGateway(mockOrder)).thenReturn(true);String result = orderService.pay("123");assertEquals("支付成功", result);assertTrue(mockOrder.isPaid());verify(orderRepository).save(mockOrder);}@Test@DisplayName("找不到訂單時,應拋出異常")void testOrderNotFound() {when(orderRepository.findById("999")).thenReturn(Optional.empty());assertThrows(RuntimeException.class, () -> orderService.pay("999"));}@Test@DisplayName("已支付訂單不應重復支付,也不應保存")void testAlreadyPaid() {Order paidOrder = new Order("456", true);when(orderRepository.findById("456")).thenReturn(Optional.of(paidOrder));String result = orderService.pay("456");assertEquals("重復支付", result);verify(orderRepository, never()).save(any());}
}
四、關鍵技巧:Mock 什么?怎么 Mock?
1. 只 Mock “外部依賴”
- 數據庫 Repository
- 第三方客戶端(如 FeignClient、HttpClient)
- Redis 操作、MQ 發送器、ES 操作器
2. 不 Mock 的部分
- 自己寫的業務邏輯類(即你要測的類)
3. 使用 Mockito 提供的能力
when(...).thenReturn(...)
:設置返回值verify(...)
:驗證方法是否調用argThat(...)
:匹配參數條件doThrow(...)
:模擬異常
五、單元測試 vs 集成測試:職責邊界與框架選擇
對比表格
維度 | 單元測試(Unit Test) | 集成測試(Integration Test) |
---|---|---|
啟動方式 | 不啟動 Spring 容器 | 啟動 Spring 容器(或部分) |
測試目標 | 業務邏輯、算法正確性 | Bean 交互、配置、環境集成 |
Mock 使用 | 必須 Mock 外部依賴 | 通常不 Mock,使用真實組件 |
性能 | 快,毫秒級 | 慢,秒級 |
數據源 | 無數據庫或 H2 Mock | 真正連接數據庫(如 Docker 啟動 MySQL) |
斷言粒度 | 精確控制方法行為 | 更偏向流程通路與集成穩定性 |
@DataJpaTest
用于測試 JPA Repository 層(不加載 Service、Controller):
@DataJpaTest
class UserRepositoryTest {@Autowired UserRepository repo;@Test@DisplayName("根據用戶名查詢用戶,應返回結果")void testFindByUsername() {User u = new User("tom", "123");repo.save(u);assertTrue(repo.findByUsername("tom").isPresent());}
}
自動配置內嵌數據庫(如 H2),速度適中,適合數據層測試。
@Mapper + MyBatis 的 Mapper 層測試(兩種方式)
? 方式一:真實數據庫 + @MybatisTest
@MybatisTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // 保持使用真實數據庫配置
class OrderMapperTest {@Autowired OrderMapper orderMapper;@Test@DisplayName("根據訂單ID查詢,應返回訂單信息")void testSelectById() {Order order = orderMapper.selectById("order123");assertNotNull(order);}
}
說明:
@MybatisTest
會只加載 MyBatis 相關的配置(不會加載 Service、Controller)- 默認使用 H2,可通過
@AutoConfigureTestDatabase
強制保留 MySQL 等真實庫 - 可以測試 XML 映射、注解 SQL、分頁插件等
? 方式二:Mock Mapper(更適合單元測試)
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {@Mock OrderMapper orderMapper;@InjectMocks OrderService orderService;@Test@DisplayName("Mock Mapper 查詢訂單,應返回正確訂單")void testOrderFetch() {Order mockOrder = new Order("order789", false);when(orderMapper.selectById("order789")).thenReturn(mockOrder);Order result = orderService.getOrder("order789");assertEquals("order789", result.getId());}
}
說明:
- Mapper 在 Service 中作為依賴,Mock 掉即可測試業務邏輯
- 不需要數據庫、不用 @SpringBootTest,速度快、適合 CI
@WebMvcTest
用于測試 Controller 層(不加載業務邏輯):
@WebMvcTest(UserController.class)
class UserControllerTest {@Autowired MockMvc mockMvc;@MockBean UserService userService;@Test@DisplayName("調用 /hello 接口,應返回 hello tom")void testHelloApi() throws Exception {when(userService.getName()).thenReturn("tom");mockMvc.perform(get("/hello")).andExpect(status().isOk()).andExpect(content().string("hello tom"));}
}
優點是啟動快,只加載 Web 層相關 Bean,可精準控制 Controller 輸入輸出。
要點 | 說明 |
---|---|
不使用 @SpringBootTest | 減少啟動時間,提高測試速度 |
用 @ExtendWith(MockitoExtension.class) | 使用 Mockito 管理依賴注入 |
用 @InjectMocks | 注入被測類(業務類) |
用 @Mock | 模擬依賴(Repository、外部接口) |
每個測試只驗證一件事 | 保證測試原子性和可維護性 |
六、擴展閱讀
- Mockito 官方文檔:https://site.mockito.org
- JUnit 5 用戶指南:https://junit.org/junit5/docs/current/user-guide/
- 推薦閱讀:Martin Fowler《Unit Test vs Integration Test》
七、結語
如果你寫單元測試還依賴 @SpringBootTest,那就像每次微波爐加熱都要重啟電廠。Mock 依賴、聚焦業務、輕量高效,才是測試真正的姿勢。
下一次寫測試時,請問自己:“我是在測試業務邏輯,還是在啟動一個服務器?”
本文由 @killian 原創,轉載請注明出處。
? 請作者喝杯咖啡,持續更新更深入的干貨
💡 彩蛋時間:如果你看到了這里,說明你是那種喜歡動手實戰的人。那我悄悄分享一個開發圈流傳的工具試用入口,貌似跟高效調試很有關系,地址也挺特別的:
🔗 入口
據說注冊還能解鎖一些隱藏功能,懂的都懂(別外傳 😂)