後端測試
本指南說明如何在花店系統中測試後端程式碼。
測試工具
- JUnit 5 - 測試框架
- Mockito - Mock 工具
- AssertJ - 斷言庫
- Spring Boot Test - 整合測試
- TestContainers - 資料庫整合測試
單元測試
Service 測試
// domain/product/ProductServiceTest.java
@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
@Mock
private ProductRepository repository;
@Mock
private CategoryRepository categoryRepository;
@InjectMocks
private ProductService service;
@Test
void findById_shouldReturnProduct_whenExists() {
// Given
Long id = 1L;
Product product = new Product("tenant1", "Rose", BigDecimal.valueOf(1500));
when(repository.findById(id)).thenReturn(Optional.of(product));
// When
Product result = service.findById(id);
// Then
assertThat(result).isNotNull();
assertThat(result.getName()).isEqualTo("Rose");
verify(repository).findById(id);
}
@Test
void findById_shouldThrowException_whenNotExists() {
// Given
Long id = 999L;
when(repository.findById(id)).thenReturn(Optional.empty());
// When & Then
assertThatThrownBy(() -> service.findById(id))
.isInstanceOf(ProductNotFoundException.class)
.hasMessage("Product not found: 999");
}
@Test
void create_shouldSaveProduct() {
// Given
String name = "Rose Bouquet";
BigDecimal price = BigDecimal.valueOf(1500);
Long categoryId = 1L;
Category category = new Category("tenant1", "Flowers");
when(categoryRepository.findById(categoryId)).thenReturn(Optional.of(category));
Product savedProduct = new Product("tenant1", name, price);
when(repository.save(any(Product.class))).thenReturn(savedProduct);
// When
Product result = service.create(name, price, categoryId);
// Then
assertThat(result.getName()).isEqualTo(name);
assertThat(result.getPrice()).isEqualTo(price);
verify(repository).save(any(Product.class));
}
}
Repository 測試
// domain/product/ProductRepositoryTest.java
@DataJpaTest
class ProductRepositoryTest {
@Autowired
private ProductRepository repository;
@Autowired
private TestEntityManager entityManager;
@Test
void findByTenantId_shouldReturnProductsForTenant() {
// Given
Product product1 = new Product("tenant1", "Rose", BigDecimal.valueOf(1500));
Product product2 = new Product("tenant1", "Tulip", BigDecimal.valueOf(1200));
Product product3 = new Product("tenant2", "Lily", BigDecimal.valueOf(1300));
entityManager.persist(product1);
entityManager.persist(product2);
entityManager.persist(product3);
entityManager.flush();
// When
List<Product> results = repository.findByTenantId("tenant1");
// Then
assertThat(results).hasSize(2);
assertThat(results).extracting(Product::getName)
.containsExactlyInAnyOrder("Rose", "Tulip");
}
}
使用 TestContainers
@DataJpaTest
@Testcontainers
class ProductRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("test")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private ProductRepository repository;
@Test
void shouldPersistProduct() {
Product product = new Product("tenant1", "Rose", BigDecimal.valueOf(1500));
Product saved = repository.save(product);
assertThat(saved.getId()).isNotNull();
assertThat(saved.getName()).isEqualTo("Rose");
}
}
Controller 測試
使用 @WebMvcTest
// api/product/ProductControllerTest.java
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ProductService service;
@Test
void findAll_shouldReturnProducts() throws Exception {
// Given
List<Product> products = List.of(
new Product("tenant1", "Rose", BigDecimal.valueOf(1500)),
new Product("tenant1", "Tulip", BigDecimal.valueOf(1200))
);
when(service.findAll()).thenReturn(products);
// When & Then
mockMvc.perform(get("/api/products"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$[0].name").value("Rose"))
.andExpect(jsonPath("$[1].name").value("Tulip"));
}
@Test
void create_shouldReturnCreated() throws Exception {
// Given
Product product = new Product("tenant1", "Rose", BigDecimal.valueOf(1500));
when(service.create(anyString(), any(BigDecimal.class), anyLong()))
.thenReturn(product);
String requestBody = """
{
"name": "Rose",
"price": 1500,
"categoryId": 1
}
""";
// When & Then
mockMvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("Rose"));
}
@Test
void create_shouldReturnBadRequest_whenInvalidData() throws Exception {
String requestBody = """
{
"name": "",
"price": -1
}
""";
mockMvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isBadRequest());
}
}
整合測試
使用 @SpringBootTest
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class ProductIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ProductRepository repository;
@BeforeEach
void setUp() {
repository.deleteAll();
}
@Test
void createProduct_shouldPersistToDatabase() {
// Given
ProductCreateRequest request = new ProductCreateRequest(
"Rose Bouquet",
BigDecimal.valueOf(1500),
1L,
10
);
// When
ResponseEntity<ProductResponse> response = restTemplate.postForEntity(
"/api/products",
request,
ProductResponse.class
);
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().name()).isEqualTo("Rose Bouquet");
// 驗證資料庫
List<Product> products = repository.findAll();
assertThat(products).hasSize(1);
assertThat(products.get(0).getName()).isEqualTo("Rose Bouquet");
}
}
API 手動測試:Bruno
除了自動化測試外,app-server 使用 Bruno 作為 API 手動測試與規格維護工具。
什麼是 Bruno
Bruno 是開源的 REST API 客戶端,特點:
- 純文字格式(
.bru):易於 Git 版本控制 - 內嵌文檔:每個請求可包含 Markdown 說明
- 環境變數:支援多環境切換
- 腳本支援:自動處理 Token 等動態資料
專案結構
app-server/bruno/
├── bruno.json # 專案配置
├── collection.bru # 集合設定與整體文檔
├── environments/
│ └── local.bru # 本地環境變數
├── auth/ # 認證 API
│ ├── 1-login.bru
│ ├── 2-me.bru
│ ├── 3-refresh.bru
│ └── 4-logout.bru
├── customers/ # 客戶管理 API
├── orders/ # 訂單管理 API
├── products/ # 產品管理 API
└── references/ # 參照資料 API
檔案格式範例
meta {
name: 1. Login
type: http
seq: 1
}
post {
url: {{baseUrl}}/api/v1/auth/login
body: json
auth: none
}
headers {
Content-Type: application/json
}
body:json {
{
"username": "manager",
"password": "Password123!",
"remember": false
}
}
script:post-response {
if (res.status === 200) {
const body = res.getBody();
bru.setVar("accessToken", body.accessToken);
bru.setVar("refreshToken", body.refreshToken);
}
}
docs {
# Login API
用戶登入,取得 JWT Token。
## 測試帳號
- manager / Password123!
- staff / Password123!
}
使用方式
- 安裝 Bruno:從 官網 下載桌面版
- 開啟專案:File → Open Collection → 選擇
app-server/bruno/ - 選擇環境:右上角選擇
local環境 - 執行請求:先執行
auth/1-login.bru取得 Token,後續請求會自動帶入
與自動化測試的分工
| 工具 | 用途 | 時機 |
|---|---|---|
| Bruno | API 探索、手動驗證、規格文檔 | 開發中、除錯 |
| JUnit + MockMvc | 自動化回歸測試 | CI/CD、重構 |
維護 Bruno 規格
當新增或修改 API 時:
- 在對應資料夾新增/修改
.bru檔案 - 填寫
docs區塊說明 API 用途與參數 - 確保請求可正常執行
- 提交至 Git 版本控制
測試最佳實踐
1. AAA 模式
@Test
void testName() {
// Arrange - 準備測試資料
Product product = new Product("tenant1", "Rose", BigDecimal.valueOf(1500));
when(repository.save(any())).thenReturn(product);
// Act - 執行待測試的方法
Product result = service.create("Rose", BigDecimal.valueOf(1500), 1L);
// Assert - 驗證結果
assertThat(result.getName()).isEqualTo("Rose");
}
2. 使用 AssertJ
✅ 推薦:
assertThat(result).isNotNull();
assertThat(result.getName()).isEqualTo("Rose");
assertThat(result.getPrice()).isEqualByComparingTo(BigDecimal.valueOf(1500));
❌ 避免:
assertTrue(result != null);
assertEquals("Rose", result.getName());
3. 參數化測試
@ParameterizedTest
@CsvSource({
"Rose, 1500",
"Tulip, 1200",
"Lily, 1300"
})
void create_shouldAcceptVariousProducts(String name, int price) {
Product product = service.create(name, BigDecimal.valueOf(price), 1L);
assertThat(product.getName()).isEqualTo(name);
assertThat(product.getPrice()).isEqualByComparingTo(BigDecimal.valueOf(price));
}
執行測試
# 執行所有測試
./gradlew test
# 執行單一測試類別
./gradlew test --tests ProductServiceTest
# 執行單一測試方法
./gradlew test --tests ProductServiceTest.findById_shouldReturnProduct_whenExists
# 涵蓋率報告
./gradlew jacocoTestReport