跳至主要内容

後端測試

本指南說明如何在花店系統中測試後端程式碼。

測試工具

  • 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!
}

使用方式

  1. 安裝 Bruno:從 官網 下載桌面版
  2. 開啟專案:File → Open Collection → 選擇 app-server/bruno/
  3. 選擇環境:右上角選擇 local 環境
  4. 執行請求:先執行 auth/1-login.bru 取得 Token,後續請求會自動帶入

與自動化測試的分工

工具用途時機
BrunoAPI 探索、手動驗證、規格文檔開發中、除錯
JUnit + MockMvc自動化回歸測試CI/CD、重構

維護 Bruno 規格

當新增或修改 API 時:

  1. 在對應資料夾新增/修改 .bru 檔案
  2. 填寫 docs 區塊說明 API 用途與參數
  3. 確保請求可正常執行
  4. 提交至 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

下一步