# Spring Boot Test:從基礎到實戰
## 一、Spring Boot 測試的基礎概念
### 1.1 為什麼需要自動化測試
- 甚麼是測試?
- 通常一個需求,都會有預期想要達成的結果,而我們就可以透過測試來驗證我們所實作的功能是否符合預期。
- 為什麼要自動化測試?
- 我們預期達到的結果可能是一種結果,也可能是多種結果,甚至在我們所實作的功能可能會跑出無法預期的結果。我們會需要想辦法驗證我們的實作是否正確。如果使用人工來驗證的話,會需要大量的時間跟人力,也可能漏掉某些狀況。
- 那我們就透過自動化的測試,將測試也寫成程式碼,透過程式碼來驗證我們的功能是否正確。這樣的好處是,我們可以隨時隨地的執行測試,並且可以在每次的程式碼修改後,透過測試來確保我們的功能是否正確。
### 1.2 Spring Boot 測試框架介紹
#### JUnit 5(Jupiter)
- Junit 5 是目前最新的 Junit 版本,在 Java 的生態圈中算是很流行的單元測試框架。
- Junit 大致由三個部分組成
- Junit Platform:提供了執行測試的 API,也提供了執行測試的引擎 API。
- Junit Jupiter:引入新語法與特性,例如 Lambda、Parameterized Test、Extension Model 等。
- Junit Vintage:提供了向後兼容的 API,讓舊版的 Junit 3、4 可以在 Junit 5 上執行。
#### Spring Boot Test Starter
- Spring Boot 提供了一個專門用來測試的 Starter,因為是 Spring Boot 的親兒子,開箱即用,也許 Spring Boot 有更好的整合性。
- 其中包含的依賴有
- JUnit 5 : 主要的測試框架
- Spring Test : Spring 提供的測試支援
- AssertJ : 一個更好的斷言框架
- Mockito : 用來模擬對象的工具
- Hamcrest : 斷言庫,也支持自定義斷言
- JSONassert : 用來比較 JSON 的工具
- XmlUnit : 用來比較 XML 的工具
- 引用方式就是在 pom.xml 中加入 spring-boot-starter-test 這個依賴即可。
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
```
- 後面的章節,我們就來直接實作 Spring Boot 的測試。
### 1.3 測試種類與測試金字塔
#### 單元測試(Unit Test)
- 單元測試是針對程式中最小的可測試單元進行測試,通常是針對一個方法或一個類別進行測試。
- 單元測試追求
- 小範圍
- 快速
- 獨立,不依賴其他模組
- 適合使用單元測試的場景
- 驗證業務邏輯,如計算公式、條件判斷等
- 驗證工具類、輔助方法,如日期轉換、加密解密等
- 驗證單個模組的輸入與輸出是否符合預期
- 範例
```java
class CalculatorTest {
@Test
void testAdd() {
Calculator calculator = new Calculator();
int result = calculator.add(2, 3);
assertEquals(5, result); // 驗證加法結果
}
}
```
#### 整合測試(Integration Test)
- 整合測試是針對多個模組之間的整合進行測試,通常是針對模組之間的交互作用進行測試。
- 整合測試追求
- 中等範圍
- 需要真實的環境
- 需要真實的依賴
- 適合使用整合測試的場景
- 驗證模組之間的交互作用
- 測試資料庫操作是否符合預期
- 模擬真實環境中的系統行為
- 範例
```java
@SpringBootTest
class UserServiceIntegrationTest {
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@Test
void testCreateUser() {
User user = new User("testUser", "testEmail@example.com");
userService.createUser(user);
User savedUser = userRepository.findByName("testUser");
assertNotNull(savedUser); // 驗證用戶是否成功保存
}
}
```
#### 端到端測試(End-to-End Test)
- 也就是 E2E 測試,是針對整個系統的功能進行測試,通常是針對用戶的操作流程進行測試,是最高層次的測試。
- E2E 測試追求
- 全面性
- 需要真實的環境
- 需要真實的依賴
- 模擬真實用戶行為
- 現在的 Web 專案通常是前後端分離的,前端使用 E2E 測試框架,如 Selenium、Cypress 等。而這篇文章主要是針對後端的測試,前端 Cypress 可以參考我之前的[文章](https://hackmd.io/@ohQEG7SsQoeXVwVP2-v06A/S170WynZ3)。
- 所以當我們的前端搭配後端的測試,整合起來就是 E2E 測試。
### 1.5 測試切片(Slice Testing)
- 測試切片是一種測試策略
- 指專注於系統或應用程式鍾某一層或某一類組件的測試,而非整個應用程式的測試
- 藉由限制測試範圍,將問題的範圍縮小,提高測試效率,確認每一個組件的正確性
- 又分為
- 垂直切片測試(Vertical Slice Testing):
測試一個功能的所有層次
例如:測試一個使用者登入功能,從前端輸入到後端驗證,再到資料庫比對。
- 水平切片測試(Horizontal Slice Testing):
測試一層的所有功能
例如:測試所有的 Service 功能,或是所有的 Repository 功能。
## 二、測試環境搭建與配置
接下來,我們就來實作一些測試案例,並在過程中,解釋常用的測試配置與技巧。
### 2.1 添加測試相關依賴
- Maven 配置
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-suite-api</artifactId>
<version>1.8.2</version>
<scope>test</scope>
</dependency>
<!--swagger-->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.7.0</version>
</dependency>
</dependencies>
```
- 要注意 lombok 要寫版本,下面 plugin 也要寫,不然 IDE 可能會抓不到 lombok ,即便 import 了也沒用。
```xml
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
```
- Lombok 抓不到還有可能是因為 IDE Annotation Processors 的問題,要自己到 Setting > Build, Execution, Deployment > Compiler > Annotation Processors 勾選 Enable annotation processing、Obtain processors from project classpath、Module output directory,表示要從專案的 classpath 中獲取 processors,並且將 processors 的輸出目錄設置為模塊的輸出目錄。
### 2.2 application.yml 配置
```yaml
spring:
application:
name: test-practice
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password: password
h2:
console:
enabled: true
path: /h2-console
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: create-drop # 測試結束後刪除資料表
show-sql: true
open-in-view: false
generate-ddl: true
defer-datasource-initialization: true
# Swagger path: http://localhost:8080/swagger-ui/index.html
```
### 2.3 Entity
- 這個專案只會有一個 Entity,就是 User
```java
@Entity
@Table(name = "users")
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
@Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Name is required")
private String name;
@NotBlank(message = "Email is required")
@Email(message = "Email should be valid")
private String email;
private String code;
// create a new user
public static User createUser(CreateUserRequest userRequest) {
return User.builder()
.name(userRequest.getName())
.email(userRequest.getEmail())
.code("USER-" + System.currentTimeMillis())
.build();
}
}
```
### 2.4 RequestBody
- 用一個 RequestBody 提供給等等想要實作新增用戶的 API 使用
```java
@Data
public class CreateUserRequest {
@NotNull(message = "Name is required")
@NotBlank(message = "Name is required")
@Size(min = 3, max = 50, message = "Name should be between 3 and 50 characters")
@Schema(description = "Name of the user", example = "John Doe")
private String name;
@NotNull(message = "Email is required")
@NotBlank(message = "Email is required")
@Email(message = "Email should be valid")
@Schema(description = "Email of the user", example = "johndoe@example.com")
private String email;
}
```
### 2.5 Repository
- 新增一個方法拿來驗證 Email 是否已經存在
```java
public interface UserRepository extends JpaRepository<User, Long> {
boolean existsByEmail(@NotNull @NotBlank(message = "Email is required") @Email(message = "Email should be valid") String email);
}
```
### 2.6 Service
```java
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
// 獲取所有使用者
public List<User> getAllUsers() {
return userRepository.findAll();
}
// 創建新使用者
public synchronized User createUser(CreateUserRequest userRequest) {
// 檢查 email 是否已存在
if (userRepository.existsByEmail(userRequest.getEmail())) {
throw new IllegalArgumentException("Email already exists: " + userRequest.getEmail());
}
User newUser = User.createUser(userRequest);
return userRepository.save(newUser);
}
// 通過ID獲取使用者
public User getUserById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User with ID " + id + " not found."));
}
}
```
### 2.7 Controller
- 我們先在 Controller 中寫三支 api
- 由用戶 ID 取得用戶資訊
- GET /user/{id}
- 取得所有用戶資訊
- GET /users
- 新增用戶
- POST /user
- RequestBody
```json
{
"name": "testUser",
"email": "test@example.com"
}
```
```java
@RestController
@RequestMapping("/users")
@Tag(name = "User Management", description = "Endpoints for managing users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@Operation(summary = "Get user by ID", description = "Retrieve a specific user by their unique ID")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "User found",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = User.class))),
@ApiResponse(responseCode = "404", description = "User not found", content = @Content)
})
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
User user = userService.getUserById(id);
return ResponseEntity.ok(user);
}
@Operation(summary = "Get all users", description = "Retrieve a list of all users")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "List of users",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = User.class)))
})
@GetMapping
public ResponseEntity<List<User>> getAllUsers() {
List<User> users = userService.getAllUsers();
return ResponseEntity.ok(users);
}
@Operation(summary = "Create a new user", description = "Add a new user to the system")
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "User created successfully",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = User.class))),
@ApiResponse(responseCode = "400", description = "Invalid input", content = @Content)
})
@PostMapping
public ResponseEntity<User> createUser(
@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "User to create", required = true,
content = @Content(mediaType = "application/json", schema = @Schema(implementation = CreateUserRequest.class)))
@RequestBody @Valid CreateUserRequest user) {
User createdUser = userService.createUser(user);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(createdUser.getId())
.toUri();
return ResponseEntity.created(location).body(createdUser);
}
}
```
### 2.8 Exception
- 先新增一個自定義的 UserNotFoundException
```java
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(String message) {
super(message);
}
}
```
- 再新增一個全域的 GlobalExceptionHandler
```java
@RestControllerAdvice
public class GlobalExceptionHandler {
// 處理 UserNotFoundException
@ExceptionHandler(UserNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleUserNotFoundException(UserNotFoundException ex) {
return new ErrorResponse("User not found", HttpStatus.NOT_FOUND.toString(), ex.getMessage());
}
// 處理參數驗證失敗 (例如 @Valid 的驗證)
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidationException(MethodArgumentNotValidException ex) {
// 提取所有錯誤訊息
String errorMessage = ex.getBindingResult().getAllErrors().stream()
.map(error -> error.getDefaultMessage())
.findFirst()
.orElse("Validation failed");
return new ErrorResponse("Validation failed", HttpStatus.BAD_REQUEST.toString(), errorMessage);
}
// 處理 IllegalArgumentException 或其他自訂業務邏輯錯誤
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleIllegalArgumentException(IllegalArgumentException ex) {
return new ErrorResponse("Bad request", HttpStatus.BAD_REQUEST.toString(), ex.getMessage());
}
// 處理其他未預期的異常
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleGenericException(Exception ex) {
return new ErrorResponse("Internal server error", HttpStatus.INTERNAL_SERVER_ERROR.toString(), ex.getMessage());
}
// 定義統一的錯誤響應結構
static class ErrorResponse {
private String error;
private String httpStatus;
private String message;
private String timestamp;
public ErrorResponse(String error, String httpStatus, String message) {
this.error = error;
this.httpStatus = httpStatus;
this.message = message;
this.timestamp = java.time.LocalDateTime.now().toString(); // 增加時間戳
}
public String getError() {
return error;
}
public String getHttpStatus() {
return httpStatus;
}
public String getMessage() {
return message;
}
public String getTimestamp() {
return timestamp;
}
}
}
```
- 基本上到這樣,我們就有一個簡單的 Spring Boot 專案了,可以直接執行,並使用 Swagger 來測試 API。
## 三、單元測試(Unit Test)
接下來我們直接用實戰來看看如何撰測試以及其中常用到的 annotation 及 static method。
我就用 Controller 的部分來做測試,其他的部分也可以參考這個方式。
基本上就是大同小異了。
### 3.1 單元測試範例
```java
@SpringBootTest // 標註這是一個 Spring Boot 測試類,啟動完整的 Spring 應用上下文進行測試,適用於整合測試和模擬環境。
@AutoConfigureMockMvc // 自動配置 MockMvc,用於模擬 HTTP 請求和回應,無需啟動真實的伺服器。
public class UserControllerUnitTest {
@Autowired // Spring 自動注入 MockMvc 實例,用於發送模擬的 HTTP 請求。
private MockMvc mockMvc;
// 注意
// 在同一個測試類中同時使用 @MockBean 和 @Autowired 注入同一個類型的 Bean(例如 UserService),
// 會導致 測試中的行為不一致 或 出現衝突,
// 因為 Spring Boot 測試框架會優先使用 @MockBean 替換掉 Spring Context 中的該類型 Bean。
// @MockBean // 注意這個 Annotation 已經在 SpringBoot 3.4.0 版本中被棄用,改用 @MockitoBean
@MockitoBean// 使用 Mockito 模擬一個 Service 層的 Bean,將其注入到 Spring 測試上下文中。
private UserService mockUserService;
private User mockUser; // 用於測試的模擬數據,代表一個單一的 User 實例。
private List<User> mockUsers; // 用於測試的模擬數據列表,代表多個 User 實例。
@BeforeEach // 在每個測試方法執行之前執行,用於初始化測試所需的數據或狀態。
public void setUp() {
// 初始化單個模擬使用者
mockUser = User.builder()
.id(1L)
.name("John Doe")
.email("john.doe@example.com")
.code("123")
.build();
// 初始化模擬使用者列表
mockUsers = Arrays.asList(
mockUser,
User.builder().id(2L).name("Jane Smith").email("jane.smith@example.com").code("456").build()
);
// 使用 Mockito 模擬 Service 層的行為
Mockito.when(mockUserService.getUserById(1L)).thenReturn(mockUser);
Mockito.when(mockUserService.getAllUsers()).thenReturn(mockUsers);
}
@AfterEach // 在每個測試方法執行之後執行,用於清理測試環境或重置狀態。
public void tearDown() {
Mockito.reset(mockUserService); // 重置模擬對象,清除所有之前的行為模擬,確保不影響其他測試。
}
@Test // 標註這是一個測試方法,JUnit 會執行該方法進行測試。
public void testGetUserById() throws Exception {
// 構造一個模擬的使用者
User mockUser = User.builder()
.id(1L)
.name("John Doe")
.email("john.doe@example.com")
.code("123")
.build();
// 模擬 Service 層的行為
Mockito.when(mockUserService.getUserById(1L)).thenReturn(mockUser);
// 使用 MockMvc 發送 GET 請求,並驗證返回結果
mockMvc.perform(get("/users/1")) // 模擬對 "/users/1" 的 GET 請求
.andExpect(status().isOk()) // 驗證返回狀態碼為 200 (OK)
.andExpect(jsonPath("$.id").value(1L)) // 驗證 JSON 回應中的 id 為 1
.andExpect(jsonPath("$.name").value("John Doe")) // 驗證 JSON 回應中的 name 為 "John Doe"
.andExpect(jsonPath("$.email").value("john.doe@example.com")) // 驗證 JSON 回應中的 email 為 "john.doe@example.com"
.andExpect(jsonPath("$.code").value("123")); // 驗證 JSON 回應中的 code 為 "123"
}
@Test // 標註這是一個測試方法,測試創建使用者時的輸入驗證。
public void testCreateUserValidationError() throws Exception {
// 使用 MockMvc 發送 POST 請求,測試缺少 email 時的錯誤
mockMvc.perform(post("/users") // 模擬對 "/users" 的 POST 請求
.contentType("application/json") // 設定請求內容類型為 JSON
.content("{\"name\": \"Invalid User\"}")) // 傳遞不完整的 JSON 請求體
.andExpect(status().isBadRequest()) // 驗證返回狀態碼為 400 (Bad Request)
.andExpect(jsonPath("$.error").value("Validation failed")) // 驗證錯誤訊息
.andExpect(jsonPath("$.message").value("Email is required")); // 驗證具體的錯誤內容
}
@Test // 標註這是一個測試方法,用於測試獲取所有使用者的功能。
public void testGetAllUsers() throws Exception {
// 準備模擬的使用者列表
List<User> mockUsers = Arrays.asList(
User.builder().id(1L).name("John Doe").email("john.doe@example.com").code("123").build(),
User.builder().id(2L).name("Jane Smith").email("jane.smith@example.com").code("456").build()
);
// 模擬 Service 層的行為
Mockito.when(mockUserService.getAllUsers()).thenReturn(mockUsers);
// 使用 MockMvc 發送 GET 請求,並驗證返回結果
mockMvc.perform(get("/users")) // 模擬對 "/users" 的 GET 請求
.andExpect(status().isOk()) // 驗證返回狀態碼為 200 (OK)
.andExpect(jsonPath("$[0].id").value(1L)) // 驗證第一個使用者的 id 為 1
.andExpect(jsonPath("$[0].name").value("John Doe")) // 驗證第一個使用者的 name 為 "John Doe"
.andExpect(jsonPath("$[1].id").value(2L)) // 驗證第二個使用者的 id 為 2
.andExpect(jsonPath("$[1].name").value("Jane Smith")); // 驗證第二個使用者的 name 為 "Jane Smith"
}
@Test // 標註這是一個測試方法,用於測試創建新使用者的功能。
public void testCreateUser() throws Exception {
// 構造請求和回應對象
CreateUserRequest createUserRequest = new CreateUserRequest("John Doe", "john.doe@example.com");
User mockUser = User.builder()
.id(1L)
.name("John Doe")
.email("john.doe@example.com")
.code("123")
.build();
// 模擬 Service 層的行為
Mockito.when(mockUserService.createUser(Mockito.any(CreateUserRequest.class))).thenReturn(mockUser);
// 使用 MockMvc 發送 POST 請求,並驗證返回結果
mockMvc.perform(post("/users") // 模擬對 "/users" 的 POST 請求
.contentType(MediaType.APPLICATION_JSON) // 設定請求內容類型為 JSON
.content(new ObjectMapper().writeValueAsString(createUserRequest))) // 傳遞 JSON 請求體
.andExpect(status().isCreated()) // 驗證返回狀態碼為 201 (Created)
.andExpect(jsonPath("$.id").value(1L)) // 驗證 JSON 回應中的 id 為 1
.andExpect(jsonPath("$.name").value("John Doe")) // 驗證 JSON 回應中的 name 為 "John Doe"
.andExpect(jsonPath("$.email").value("john.doe@example.com")) // 驗證 JSON 回應中的 email 為 "john.doe@example.com"
.andExpect(jsonPath("$.code").value("123")); // 驗證 JSON 回應中的 code 為 "123"
}
}
```
## 四、整合測試(Integration Test) 與 E2E REST API 測試說明
- 整合測試與 E2E REST API 測試在簡單的專案中,可能會讓人感到困惑,好像寫起來都差不多的感覺,但兩者所專注的點不太一樣。
- 在這邊我們就把兩種測試寫在一起就好,但定義還是要清楚一點,這樣溝通才不會有問題。
### 4.1 **差別**
| **特徵** | **整合測試** | **E2E RESTful 測試** |
|-----------------------|-----------------------------------------------------|--------------------------------------------------|
| **測試範圍** | 驗證多層邏輯(Controller → Service → Repository)。 | 驗證 Controller 層是否正確處理 HTTP 請求和回應。 |
| **數據來源** | 使用真實資料庫(如 H2)。 | 使用模擬數據(通過 `@MockBean`)。 |
| **依賴的 Bean** | 真實的 Service 和 Repository。 | 模擬的 Service 和 Repository。 |
| **測試目標** | 驗證系統內部層與層之間的協作。 | 驗證 API 的 HTTP 行為是否符合規範。 |
| **性能** | 稍慢,需要初始化資料庫和整個應用上下文。 | 更快,因為不依賴資料庫和完整的應用上下文。 |
| **使用場景** | 測試資料庫邏輯、業務流程的整合性。 | 測試 API 是否符合設計(狀態碼、回應格式等)。 |
### 4.2 **相似之處**
| **特徵** | **整合測試** | **E2E RESTful 測試** |
|-----------------------|-------------------------|--------------------------|
| **工具相同** | 使用 MockMvc。 | 使用 MockMvc。 |
| **驗證 HTTP 層行為** | 驗證請求的輸入與回應輸出。 | 驗證請求的輸入與回應輸出。 |
| **測試方法結構類似** | 驗證 HTTP 狀態碼、JSON 格式等 | 驗證 HTTP 狀態碼、JSON 格式等 |
### 4.2 整合測試與 E2E REST API 測試範例
```java
@SpringBootTest // 標註為 Spring Boot 測試類,啟動完整的 Spring 應用上下文進行測試,適用於整合測試。
@AutoConfigureMockMvc // 自動配置 MockMvc,用於模擬 HTTP 請求和回應,無需啟動真實的伺服器。
@Sql(scripts = "/sql/test-data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) // 在每個測試方法之前執行 SQL 腳本,初始化測試數據。
@Sql(scripts = "/sql/cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) // 在每個測試方法之後執行 SQL 腳本,清理測試數據。
public class UserControllerIntegrationTest {
@Autowired // 自動注入 MockMvc 實例,用於模擬 HTTP 請求和回應。
private MockMvc mockMvc;
@Test // 標註這是一個測試方法,由 JUnit 運行。
public void testCreateAndGetUsers() throws Exception {
// 創建使用者
mockMvc.perform(post("/users") // 模擬對 "/users" 的 POST 請求,用於創建使用者。
.contentType("application/json") // 設置請求內容類型為 JSON。
.content("{\"name\": \"Test User\", \"email\": \"test@example.com\"}")) // 提供 JSON 格式的請求體。
.andDo(print()) // 打印請求與回應的詳細內容,用於調試。
.andExpect(status().isCreated()); // 驗證返回的 HTTP 狀態碼是否為 201 (Created)。
// 查詢所有使用者,驗證創建的使用者是否存在
mockMvc.perform(get("/users")) // 模擬對 "/users" 的 GET 請求,用於查詢所有使用者。
.andExpect(status().isOk()) // 驗證返回的 HTTP 狀態碼是否為 200 (OK)。
.andExpect(jsonPath("$[*].name").value(org.hamcrest.Matchers.hasItem("Test User"))) // 驗證返回的 JSON 包含指定的 name。
.andExpect(jsonPath("$[*].email").value(org.hamcrest.Matchers.hasItem("test@example.com"))); // 驗證返回的 JSON 包含指定的 email。
}
@ParameterizedTest // 標註這是一個參數化測試方法,允許使用多組測試數據重複執行測試。
@MethodSource("provideCreateUserData") // 指定測試數據來自名為 "provideCreateUserData" 的靜態方法。
public void testCreateUserWithMethodSource(String name, String email, String errorType) throws Exception {
var request = "{\"name\": \"" + name + "\", \"email\": \"" + email + "\"}"; // 創建 JSON 格式的請求數據。
switch (errorType) {
case "invalid": // 測試無效數據
mockMvc.perform(post("/users")
.contentType("application/json")
.content(request))
.andDo(print())
.andExpect(status().isBadRequest()) // 驗證返回狀態碼為 400 (Bad Request)。
.andExpect(jsonPath("$.error").value("Validation failed")); // 驗證錯誤訊息。
break;
case "duplicate": // 測試重複數據
mockMvc.perform(post("/users")
.contentType("application/json")
.content(request))
.andDo(print())
.andExpect(status().isBadRequest()) // 驗證返回狀態碼為 400。
.andExpect(jsonPath("$.message").value("Email already exists: " + email)); // 驗證錯誤訊息。
break;
case "work": // 測試有效數據
mockMvc.perform(post("/users")
.contentType("application/json")
.content(request))
.andDo(print())
.andExpect(status().isCreated()) // 驗證返回狀態碼為 201 (Created)。
.andExpect(jsonPath("$.name").value(name)) // 驗證返回的 JSON 包含指定的 name。
.andExpect(jsonPath("$.email").value(email)); // 驗證返回的 JSON 包含指定的 email。
break;
default:
throw new IllegalArgumentException("Invalid error type: " + errorType); // 對於未定義的錯誤類型,拋出異常。
}
}
private static Stream<Arguments> provideCreateUserData() {
// 提供參數化測試數據,每組數據包含 name、email 和 errorType。
return Stream.of(
Arguments.of("John", "john@example.com", "work"), // 有效數據
Arguments.of("A", "short@example.com", "invalid"), // 名字太短
Arguments.of("John", "invalidemail@", "invalid"), // 無效 Email
Arguments.of("John", "john.doe@example.com", "duplicate") // 重複 Email
);
}
@ParameterizedTest // 標註這是一個參數化測試方法。
@CsvSource({ // 使用 CSV 格式提供參數化測試數據。
"John, john@example.com, work", // 有效數據
"A, short@example.com, invalid", // 名字太短
"John, invalidemail@, invalid", // 無效 Email
"John, john.doe@example.com, duplicate" // 重複 Email
})
public void testCreateUserWithCsvSource(String name, String email, String errorType) throws Exception {
var request = "{\"name\": \"" + name + "\", \"email\": \"" + email + "\"}"; // 創建 JSON 格式的請求數據。
switch (errorType) {
case "invalid": // 測試無效數據
mockMvc.perform(post("/users")
.contentType("application/json")
.content(request))
.andDo(print())
.andExpect(status().isBadRequest()) // 驗證返回狀態碼為 400。
.andExpect(jsonPath("$.error").value("Validation failed")); // 驗證錯誤訊息。
break;
case "duplicate": // 測試重複數據
mockMvc.perform(post("/users")
.contentType("application/json")
.content(request))
.andDo(print())
.andExpect(status().isBadRequest()) // 驗證返回狀態碼為 400。
.andExpect(jsonPath("$.message").value("Email already exists: " + email)); // 驗證錯誤訊息。
break;
case "work": // 測試有效數據
mockMvc.perform(post("/users")
.contentType("application/json")
.content(request))
.andDo(print())
.andExpect(status().isCreated()) // 驗證返回狀態碼為 201 (Created)。
.andExpect(jsonPath("$.name").value(name)) // 驗證返回的 JSON 包含指定的 name。
.andExpect(jsonPath("$.email").value(email)); // 驗證返回的 JSON 包含指定的 email。
break;
default:
throw new IllegalArgumentException("Invalid error type: " + errorType); // 對於未定義的錯誤類型,拋出異常。
}
}
}
```
### 4.4 Mock 與 Real Service 比較
如上面提到的,在同一個測試類中同時使用 @MockBean 和 @Autowired 注入同一個類型的 Bean(例如 UserService),會導致 測試中的行為不一致 或 出現衝突,因為 Spring Boot 測試框架會優先使用 @MockBean 替換掉 Spring Context 中的該類型 Bean。
那我們甚麼時候要用 @MockBean 製作假的 Service,什麼時候要用 @Autowired 來注入真實的 Service 呢?
| **項目** | **@MockBean** | **@Autowired** |
|---------------------|---------------------------------------------------|-------------------------------------------------|
| **適用情境** | 單元測試,專注於測試單一層,如 Controller 或 Service | 整合測試,驗證多層邏輯(如 Controller → Service → Repository) |
| **特性與效果** | 使用 Mockito 模擬對象替換 Spring Context 中的 Bean | 注入真實的 Spring Bean,執行真實的業務邏輯 |
| **注入的對象** | 模擬對象,完全可控 | 真實對象,執行真實邏輯 |
| **適用範圍** | 單一層(如 Controller 或 Service) | 多層協作(如 Controller → Service → Repository) |
| **性能** | 更快(不依賴資料庫或其他外部資源) | 較慢(需要初始化完整的應用上下文和資料庫) |
| **控制能力** | 可以完全控制依賴的行為(如模擬異常或返回值) | 無法控制依賴行為,依賴真實邏輯 |
## 六、資料庫測試的最佳實踐
### 6.1 測試資料的準備與管理
- Flyway 這個我另外再寫一篇來補吧,這邊就先簡單提一下。
- Flyway 是一個專注於資料庫版本控制的工具,提供一種簡單且高效的方式來管理資料庫結構和資料變更。在專案開發中,資料庫結構經常需要隨著功能需求的變化進行更新,而 Flyway 能夠幫助開發團隊:
- 追蹤和管理資料庫結構的變更。
- 保證不同環境之間資料庫結構的一致性。
- 自動化資料庫遷移,減少人為錯誤。
- 在專案測試中,Flyway 也可以用於初始化測試資料庫,以資料庫版本管理、資料的初始化和清理等功能,幫助開發團隊更好地進行資料庫測試。
### 6.2 資料一致性與回滾策略
- 在測試的方法上面,加上 @Transactional,主要目的是為每個測試方法開啟一個新的事務,並在測試結束後,無論結果是否出現異常,都會回滾(rollback),以保證測試環境的資料庫狀態不會受到污染。
- @Transactional 可以加在整個測試類上,也可以加在單個測試方法上。
- 例如:
```java
@SpringBootTest
@Transactional
public class UserServiceIntegrationTest {
// 測試方法
}
```
### 6.3 Testcontainers 的應用
- 這個篇幅也會不小,也是另外再寫一篇來補吧。
<!-- ## 九、Spring Security 測試
### 9.1 認證與授權邏輯測試
- 使用 @WithMockUser 模擬使用者
### 9.2 測試 OAuth2 與 JWT
- 驗證保護資源的訪問權限 -->
## 七、效能測試與自動化
### 7.1 測試覆蓋率分析
- 使用 Jacoco 生成測試覆蓋率報告
- 在 pom.xml 中加入以下設定
```xml
<plugins>
<!-- 其他插件 -->
<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>verify</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.10</version>
<configuration>
<outputDirectory>${project.build.directory}/jacoco-report</outputDirectory>
</configuration>
</plugin>
</plugins>
```
- 執行 `mvn clean verify` 生成測試覆蓋率報告
- 經握以上設定,跑完 `mvn clean verify` 後,會在 target > jacoco-report 資料夾下生成測試覆蓋率報告 index.html

### 7.2 測試自動化配置
- 這個相關內容也是很多,之後再寫一篇補上吧。
<!-- ## 十一、測試最佳實踐
### 11.1 測試代碼風格與命名慣例
- 提高代碼可讀性的方法
### 11.2 測試覆蓋率監控
- 常見工具與監控策略
### 11.3 測試代碼的重構與維護
- 減少技術負債的實踐
## 十二、實戰範例與優化
### 12.1 完整測試專案範例
- 真實業務場景的測試案例
### 12.2 測試報告的生成與分析
- 提供可操作的改進建議
### 12.3 測試流程的持續優化
- 持續集成與部署中的測試改進 -->
## 結語
- 相關的測試技術有有很多,之後有遇到,我再補上吧,以上就是 Spring Boot 測試常用到的東西,希望有幫助。
###### tags: `Spring Boot` `測試` `JUnit 5` `Mockito` `Spring Security` `RESTful API` `資料庫測試` `效能測試` `CI/CD`