Chuyển đến nội dung chính

第 19 課:@QuarkusTest 和 Testcontainers

單元測試、使用@QuarkusTest進行整合測試,開發服務自動提供PostgreSQL/Kafka/Keycloak容器、REST-assured、@InjectMock、@QuarkusTestResource。

💻 程式設計 — 第 18 課 第 19 課:@QuarkusTest 和 Testcontainers

Quarkus 微服務:從基礎知識到生產

第 6 部分:測試

亞洲開發網

簡介

Quarkus 測試生態系統包括:用於整合測試的@QuarkusTest(完整 CDI 容器)、自動提供資料庫/訊息代理的開發服務、用於 HTTP 測試的 REST-assured 以及用於模擬的@InjectMock。本文指導微服務的測試策略。

測試微服務金字塔

          ┌─────────┐
          │  E2E    │  ← Ít nhất (slow, flaky)
         ┌┴─────────┴┐
         │ Contract  │  ← Consumer-Driven (Bài 20)
        ┌┴───────────┴┐
        │ Integration │  ← @QuarkusTest (DB, Kafka, REST)
       ┌┴─────────────┴┐
       │   Unit Tests   │  ← Nhiều nhất (fast, isolated)
       └────────────────┘

依賴關係

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-junit5</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-junit5-mockito</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.rest-assured</groupId>
    <artifactId>rest-assured</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-test-security</artifactId>
    <scope>test</scope>
</dependency>

單元測試 — 普通 JUnit

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class ProductTest {

    @Test
    void testActivateProduct() {
        Product product = new Product();
        product.name = "Test";
        product.price = Money.vnd(new BigDecimal("100000"));
        product.stock = new StockInfo(10, 0);

        product.activate();

        assertEquals("ACTIVE", product.status);
    }

    @Test
    void testActivateProductWithoutPrice() {
        Product product = new Product();
        product.name = "Test";
        product.price = null;
        product.stock = new StockInfo(10, 0);

        BusinessException ex = assertThrows(
            BusinessException.class,
            () -> product.activate());
        assertTrue(ex.getMessage().contains("giá > 0"));
    }

    @Test
    void testReduceStock() {
        Product product = new Product();
        product.stock = new StockInfo(10, 0);

        product.reduceStock(3);

        assertEquals(7, product.stock.available());
    }

    @Test
    void testReduceStockInsufficient() {
        Product product = new Product();
        product.stock = new StockInfo(2, 0);

        assertThrows(BusinessException.class,
            () -> product.reduceStock(5));
    }
}

class MoneyTest {

    @Test
    void testAdd() {
        Money a = Money.vnd(new BigDecimal("100000"));
        Money b = Money.vnd(new BigDecimal("50000"));

        Money result = a.add(b);

        assertEquals(new BigDecimal("150000"), result.amount());
    }

    @Test
    void testDifferentCurrency() {
        Money vnd = Money.vnd(new BigDecimal("100000"));
        Money usd = new Money(new BigDecimal("5"), "USD");

        assertThrows(IllegalArgumentException.class,
            () -> vnd.add(usd));
    }
}

class OrderStatusTest {

    @Test
    void testValidTransitions() {
        assertTrue(OrderStatus.CREATED
            .canTransitionTo(OrderStatus.CONFIRMED));
        assertTrue(OrderStatus.CONFIRMED
            .canTransitionTo(OrderStatus.PAID));
        assertTrue(OrderStatus.PAID
            .canTransitionTo(OrderStatus.SHIPPED));
    }

    @Test
    void testInvalidTransitions() {
        assertFalse(OrderStatus.DELIVERED
            .canTransitionTo(OrderStatus.CANCELLED));
        assertFalse(OrderStatus.CREATED
            .canTransitionTo(OrderStatus.PAID));
    }
}

整合測試 — @QuarkusTest

import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;

@QuarkusTest
class ProductResourceTest {

    @Test
    void testListProducts() {
        given()
            .when().get("/api/v1/products")
            .then()
                .statusCode(200)
                .body("$.size()", greaterThanOrEqualTo(0));
    }

    @Test
    void testGetProductNotFound() {
        given()
            .when().get("/api/v1/products/99999")
            .then()
                .statusCode(404)
                .contentType("application/problem+json")
                .body("title", equalTo("Resource Not Found"));
    }

    @Test
    @TestSecurity(user = "seller1", roles = "seller")
    void testCreateProduct() {
        given()
            .contentType("application/json")
            .body("""
                {
                  "name": "Integration Test Product",
                  "description": "Test description",
                  "price": 250000,
                  "categoryId": 1,
                  "stockQuantity": 100
                }
                """)
            .when().post("/api/v1/products")
            .then()
                .statusCode(201)
                .header("Location", containsString("/api/v1/products/"))
                .body("name", equalTo("Integration Test Product"))
                .body("price", equalTo(250000));
    }

    @Test
    @TestSecurity(user = "customer1", roles = "customer")
    void testCreateProductForbidden() {
        given()
            .contentType("application/json")
            .body("""
                {"name": "Test", "price": 100,
                 "stockQuantity": 1, "categoryId": 1}
                """)
            .when().post("/api/v1/products")
            .then()
                .statusCode(403);
    }

    @Test
    void testValidation() {
        given()
            .contentType("application/json")
            .body("""
                {
                  "name": "",
                  "price": -100,
                  "stockQuantity": -1
                }
                """)
            .when().post("/api/v1/products")
            .then()
                .statusCode(400)
                .body("errors.size()", greaterThan(0))
                .body("errors.field",
                    hasItems("name", "price"));
    }
}

@InjectMock — 模擬 CDI Bean

@QuarkusTest
class OrderServiceTest {

    @InjectMock
    @RestClient
    ProductServiceClient productClient;

    @Inject
    OrderService orderService;

    @Test
    @TestSecurity(user = "customer1", roles = "customer")
    @Transactional
    void testCreateOrder() {
        // Mock Product Service response
        when(productClient.getById(1L))
            .thenReturn(new ProductInfo(
                1L, "Laptop",
                new BigDecimal("25000000"), "VND"));

        when(productClient.checkStock(1L, 2))
            .thenReturn(new StockResult(true, 10));

        // Create order
        CreateOrderRequest request = new CreateOrderRequest(
            "[email protected]",
            List.of(new OrderItemRequest(1L, 2)));

        OrderDTO order = orderService.createOrder(
            "user-123", request);

        assertNotNull(order);
        assertEquals("CREATED", order.status());
        assertEquals(new BigDecimal("50000000"),
            order.totalAmount());

        verify(productClient, times(1)).getById(1L);
    }
}

使用開發服務進行資料庫測試

@QuarkusTest
@TestTransaction  // Auto-rollback sau mỗi test
class ProductRepositoryTest {

    @Inject
    ProductRepository productRepo;

    @Test
    void testPersistAndFind() {
        Product product = new Product();
        product.name = "Test Product";
        product.price = Money.vnd(new BigDecimal("100000"));
        product.stock = new StockInfo(50, 0);
        product.status = "ACTIVE";
        productRepo.persist(product);

        assertNotNull(product.id);

        Optional<Product> found =
            productRepo.findByIdOptional(product.id);
        assertTrue(found.isPresent());
        assertEquals("Test Product", found.get().name);
    }

    @Test
    void testFindActive() {
        // Seed test data
        createProduct("Active 1", "ACTIVE");
        createProduct("Active 2", "ACTIVE");
        createProduct("Inactive", "INACTIVE");

        PanacheQuery<Product> query =
            productRepo.findActive(null, null,
                Sort.by("name"));
        List<Product> results = query.list();

        assertTrue(results.stream()
            .allMatch(p -> "ACTIVE".equals(p.status)));
    }

    private void createProduct(String name, String status) {
        Product p = new Product();
        p.name = name;
        p.price = Money.vnd(BigDecimal.TEN);
        p.stock = new StockInfo(10, 0);
        p.status = status;
        productRepo.persist(p);
    }
}

@QuarkusTestProfile — 自訂測試設定檔

public class MockExternalServicesProfile
        implements QuarkusTestProfile {

    @Override
    public Map<String, String> getConfigOverrides() {
        return Map.of(
            "quarkus.rest-client.product-service.url",
            "http://localhost:${quarkus.http.test-port}",
            "quarkus.log.level", "DEBUG"
        );
    }

    @Override
    public Set<Class<?>> getEnabledAlternatives() {
        return Set.of(MockProductServiceClient.class);
    }
}

@QuarkusTest
@TestProfile(MockExternalServicesProfile.class)
class OrderServiceWithMockTest {
    // Tests using mock profile...
}

測試資料產生器模式

public class TestDataBuilder {

    public static Product.ProductBuilder aProduct() {
        return Product.ProductBuilder.builder()
            .name("Test Product " + UUID.randomUUID()
                .toString().substring(0, 8))
            .price(Money.vnd(new BigDecimal("100000")))
            .stock(new StockInfo(50, 0))
            .status("ACTIVE");
    }

    public static CreateOrderRequest anOrderRequest() {
        return new CreateOrderRequest(
            "[email protected]",
            List.of(new OrderItemRequest(1L, 1)));
    }
}

練習

  1. 為領域模型撰寫單元測試: Product, Order, Money, OrderStatus
  2. 編寫整合測試 ProductResource 增刪改查端點
  3. 使用 @InjectMock 測試訂單服務時模擬產品服務
  4. 測試驗證錯誤回傳正確的問題詳細資料格式 5.測試安全性: @TestSecurity 對於經過身份驗證/禁止的場景
  5. 創建 @TestTransaction 儲存庫層的測試

總結

  • 域邏輯(實體、值物件)的 單元測試 — 不需要容器
  • @QuarkusTest 用於整合測試 — 完整的 CDI 容器 + 開發服務
  • REST-assured — 流暢的 HTTP 測試(given().when().then())
  • @InjectMock — 模擬 CDI beans(REST 用戶端、外部服務)
  • @TestSecurity — 模擬 OIDC 身份驗證/授權
  • @TestTransaction — 每次測試後自動回滾資料庫更改
  • 開發服務自動設定 PostgreSQL、Kafka、Redis、Keycloak 容器

下一篇文章:合約測試-確保服務之間的 API 相容性。