Spring Boot 單元測試

Evan Chen

--

Spring Boot TDD系列

本文將介紹如何為 Spring Boot 專案撰寫單元測試,以會員註冊功能為例。

功能說明:實作一個會員註冊的 API,允許使用者透過帳號密碼進行註冊,並將資料儲存至 MySQL 資料庫。

完整程式碼可參考 https://github.com/evanchen76/SpringTDDSample

API 規格

註冊帳號API

  • URL: /auth/signup
  • 方法: POST
  • Content-Type: application/json

請求參數

userId

  • 類型:string
  • 必填:是
  • 描述:使用者Id

password

  • 類型:string
  • 必填:是
  • 描述:使用者密碼

請求範例

{
"userId": "EvanChen10",
"password": "Aaaaaaaa1"
}

成功回應

狀態碼: 200 OK

{
"success": true,
"data": {
"uuid": "c261758b-b1f0-4998-812a-14c601e4d71d",
"userId": "EvanChen10"
}
}

錯誤回應

狀態碼: 400 Bad Request

{
"success": false,
"error": "User already exists"
}

另外這個註冊功能會檢查帳號格式

帳號長度為3–20、密碼長度為8–30

錯誤回應

狀態碼: 400 Bad Request

{
"success": false,
"error": "User id must be between 3 and 20 characters long"
}

資料庫:

使用MySQL

user_account Table:

  1. id: bigint (主鍵) (NN)
  2. uuid: varchar(36) (NN)
  3. user_id: varchar(255)
  4. password_hash: varchar(255)

資料庫,你可以在MySQL執行以下指令匯入。

-- MySQL dump 10.13  Distrib 8.0.38, for macos14 (arm64)
--
-- Host: localhost Database: mydb
-- ------------------------------------------------------
-- Server version 9.0.1

/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;

--
-- Table structure for table `user_account`
--

DROP TABLE IF EXISTS `user_account`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `user_account` (
`id` bigint NOT NULL AUTO_INCREMENT,
`uuid` varchar(36) NOT NULL,
`user_id` varchar(255) NOT NULL,
`password_hash` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uuid` (`uuid`),
UNIQUE KEY `user_id_UNIQUE` (`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=33 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;

--
-- Dumping data for table `user_account`
--
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;

/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;

-- Dump completed on 2024-10-30 18:28:26

撰寫Controller 測試

請開啟SpringTDDSample,首先讓我們看看要測試的 AuthController:

@RestController
@RequestMapping("/auth")
public class AuthController {

private final IUserService userService;

public AuthController(IUserService userService) {
this.userService = userService;
}

@PostMapping("/signup")
public ApiResponse<SignupResponse> signup(@Valid @RequestBody SignupRequest request) {
SignupResult result = userService.signup(request.userId(), request.password());
if (result.isSuccess()) {
return ApiResponse.success(new SignupResponse(result.getCreatedUser().uuid().toString(), result.getCreatedUser().userId()));
} else {
return ApiResponse.badRequest(result.getValidationErrors().toString());
}
}
}

相關的 Request/Response 類別

public record SignupRequest(
@NotBlank(message = "UserId cannot be empty")
String userId,
@NotBlank(message = "Password cannot be empty")
String password) {
}

public record SignupResponse(String uuid, String userId) { }

測試策略

對於註冊API,我們需要測試以下三個主要場景:

  1. 輸入驗證失敗:當請求的欄位不符合要求時
  2. 註冊成功:當 Service 層成功創建用戶時
  3. 註冊失敗:當 Service 層返回驗證錯誤時

測試案例 1:輸入驗證失敗

Request

{
"userId": "",
"password": ""
}

Response

{
"success": false,
"error": "password: Password cannot be empty, userId: UserId cannot be empty"
}

測試重點:

  • 使用帳號密碼為空字串建立請求
  • 驗證 HTTP 狀態碼為 400 Bad Request
  • 驗證回應中的success為false
  • 驗證回應中包含錯誤訊息
@Test
void signup_WhenAllFieldsEmpty_ShouldFailValidation() throws Exception {
//使用帳號密碼為空字串建立請求
SignupRequest request = new SignupRequest("", "");
//請求API
mockMvc.perform(post(SIGNUP_URL)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
//驗證 HTTP 狀態碼為 400 Bad Request
.andExpect(status().isBadRequest())
//驗證回應中的success為false
.andExpect(jsonPath("$.success").value(false))
//驗證回應中包含錯誤訊息
.andExpect(jsonPath("$.error").isNotEmpty());
}

測試案例 2:註冊成功

驗證 Service 成功處理請求時,Controller 是否正確返回成功回應。

Request

{
"userId": "EvanChen",
"password": "password01"
}

Response

{
"success": true,
"data": {
"uuid": "c261758b-b1f0-4998-812a-14c601e4d71d",
"userId": "EvanChen"
}
}

測試重點

  • 使用可通過驗證的userId與password
  • Mock Service 層返回成功結果
  • 驗證回應內容
@WebMvcTest(AuthController.class)
@Import(SecurityConfig.class)
class AuthControllerTest {
public static final String USER_ID = "userId";

@Test
void signup_WhenSuccessful_ShouldReturnOkResponse() throws Exception {

SignupRequest request = new SignupRequest(USER_ID, PASSWORD);
CreatedUser createdUser = new CreatedUser(UUID.randomUUID(), USER_ID);

//mock 呼叫userService傳入userId及password,回傳UserId及UserID
when(userService.signup(request.userId(), request.password())).thenReturn(
new SignupResult(createdUser, new ArrayList<>())
);

mockMvc.perform(post("/auth/signup")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
//驗證status是200
.andExpect(status().isOk())
//success = true
.andExpect(jsonPath("$.success").value(true))
//uuid
.andExpect(jsonPath("$.data.uuid").value("uuid"))
//userId
.andExpect(jsonPath("$.data.userId").value("userId"));
}
}

測試案例 3:註冊失敗

這個測試驗證當 Service 層返回驗證錯誤時,Controller 是否正確處理失敗情況。

  • Mock Service 層返回失敗結果
  • 驗證錯誤回應的內容
@Test
void signup_WhenServiceReturnsFails_ShouldReturnBadRequest() throws Exception {
SignupRequest request = new SignupRequest(USER_ID, PASSWORD);
List<String> validationErrors = List.of("error1", "error2");
when(userService.signup(request.userId(), request.password())).thenReturn(
new SignupResult(null, validationErrors)
);
mockMvc.perform(post("/auth/signup")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error").value(validationErrors.toString()));
}

測試資料管理

在進行 Controller 測試時,有幾個重要的實踐原則需要特別注意。首先是測試資料的管理策略。建議將常用的測試資料,如 userId、password 等,定義為類別級別的Constant。這樣不僅可以提高程式碼的可維護性,也能確保測試資料的一致性。例如:

class AuthControllerTest {
public static final String USER_ID = "userId";
public static final String PASSWORD = "password";
}

避免使用過於具體的資料

在選擇測試資料時,應該使用有意義但不過度具體的值。舉例來說,在測試案例3中,當測試驗證錯誤時,不應該使用具體的業務相關錯誤訊息:

// 較好的做法
List<String> validationErrors = List.of("error1", "error2");

// 避免使用
List<String> validationErrors = List.of("User id must be between 3 and 20 characters long");

使用較為中性的錯誤訊息可以降低測試的維護成本,因為具體的錯誤訊息可能會隨著業務規則的變化而改變。

選擇適當的驗證程度

在驗證策略方面,我們需要根據測試的重點來決定驗證的深度。對於容易變動的內容,比如錯誤訊息,我們通常只需要驗證其存在性:

.andExpect(jsonPath("$.error").isNotEmpty());

但對於關鍵的業務資料,如 userId 或 uuid,則應該進行完整的值比對:

.andExpect(jsonPath("$.data.userId").value(USER_ID));

互動測試

在測試範圍的選擇上,Controller 測試主要關注的是 HTTP 請求處理流程。不需要過度關注 Service 層的呼叫細節,例如驗證 Service 方法被呼叫的次數。另外以這個例子,如果沒有正確的呼叫service.signup,mock也會失敗,所以某種程度也是驗證了呼叫service這件事。

//不需要用這方式驗證
verify(userService, times(1)).signup(userId, password);

//在mock時,如果userId及password傳遞錯誤,測試也會失敗
when(userService.signup(request.userId(), request.password())).thenReturn(
new SignupResult(createdUser, new ArrayList<>())
);

避免撰寫與被測試物件無關的案例

在設計 API 時,我將帳號密碼的格式驗證視為業務邏輯,因此這些驗證規則是在 Service 層級處理,而不是在 Controller 層級。(一般格式應在Controller檢查,但這裡我把帳號密碼的規則試為業務邏輯)。依據單元測試的原則,我們應該專注在被測試物件(在這裡是 AuthController)的核心職責上:

以下是一個不好的範例

  • 依賴了實際的驗證邏輯(Service層的業務規則)
  • 透過輸入無效的資料來觸發錯誤,這屬於整合測試的範疇
  • 測試結果會受到 Service 層驗證規則改變的影響
@Test
void signup_WhenServiceReturnsFails_ShouldReturnBadRequest() throws Exception {
//不好的寫法:用帳號a密碼a來表示無法通過service的註冊流程
SignupRequest request = new SignupRequest("a", "a");

mockMvc.perform(post("/auth/signup")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error").value(validationErrors.toString()));
}

格式驗證這類的端對端行為測試,應該要在整合測試中進行,而且是在多個層級(Controller、Service、Repository)都整合在一起的情況下測試。

單元測試 或 整合測試

這個Controller測試,其實算是整合測試。單元測試應該會如下方程式碼這樣,直接呼叫Controller的方法並驗證回傳值。但這樣的測試會有幾個問題。例如不容易驗證@Valid、url的mapping、Interceptor等。

@Test
void signup_WhenSuccessful_ShouldReturnOkResponse() {
CreatedUser response = new CreatedUser(UUID.randomUUID(), USER_ID);
SignupResult successResult = SignupResult.success(response);
when(userService.signup(USER_ID, PASSWORD)).thenReturn(successResult);
//直接呼叫authController.signup
var response = authController.signup(new SignupRequest(USER_ID, PASSWORD));
assertThat(response)
.extracting(ApiResponse::getBody)
.extracting(ApiResponse.Body::getData)
.extracting(SignupResponse::userId)
.isEqualTo(USER_ID);
}

所以關於單元測試和整合測試的選擇,需要考慮 Controller 的主要職責。一般來說,Controller 主要負責:

  • 接收請求和返回回應
  • 進行基本的參數驗證
  • 呼叫適當的 Service 方法

基於這些職責,使用 MockMvc 進行整合測試是較為合適的選擇。這種方式可以測試完整的請求處理流程,同時也能驗證 Spring 相關的配置。雖然與純單元測試相比,整合測試的執行時間可能較長,但 MockMvc 提供了一個很好的平衡點,它不需要啟動完整的服務,同時又能覆蓋到包括 @Valid 等 Spring 功能。

如果 Controller 中包含了較為複雜的業務邏輯(雖然這種情況應該盡量避免),那麼可以考慮額外寫單元測試。但在一般情況下,建議將複雜的業務邏輯放在 Service 層處理,保持 Controller 的簡潔性。這樣不僅可以使程式碼結構更清晰,也能讓測試更加聚焦和有效。

下一篇繼續介紹Service單元測試

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

No responses yet

Write a response