はじめに
Quarkus テスト エコシステムには、統合テスト (フル CDI コンテナ) 用の @QuarkusTest、データベース/メッセージ ブローカーを自動的に提供する Dev Services、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);
}
}
Dev Services を使用したデータベースのテスト
@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)));
}
}
演習
- ドメイン モデルの単体テストを作成します。
Product、Order、Money、OrderStatus - 統合テストを作成する
ProductResourceCRUDエンドポイント - 使用する
@InjectMockOrder Service をテストするときに Product Service を模擬する - テスト検証エラーは正しい問題の詳細形式を返します。
- セキュリティをテストします。
@TestSecurity認証済み/禁止されたシナリオの場合 6.作成@TestTransactionリポジトリ層のテスト
概要
- ドメイン ロジック (エンティティ、値オブジェクト) の 単体テスト - コンテナは必要ありません
@QuarkusTest統合テスト用 — フル CDI コンテナ + Dev Services- REST 保証 — 流暢な HTTP テスト (
given().when().then()) @InjectMock— モック CDI Bean (REST クライアント、外部サービス)@TestSecurity— モック OIDC 認証/認可@TestTransaction— 各テスト後のデータベース変更の自動ロールバック- Dev Services は PostgreSQL、Kafka、Redis、Keycloak コンテナを自動的にプロビジョニングします
次の記事: コントラクト テスト — サービス間の API 互換性の確保。