SKILL.md
只读
名称
quarkus-tdd
描述
基于 JUnit 5、Mockito、REST Assured、Camel 测试套件与 JaCoCo,针对 Quarkus 3.x LTS 实施测试驱动开发(TDD)。适用于新增功能、修复 Bug 或重构事件驱动型服务等场景。
Quarkus TDD 工作流
针对 Quarkus 3.x 服务的 TDD 指南,目标覆盖率达到 80%+(单元测试 + 集成测试)。特别针对基于 Apache Camel 的事件驱动架构进行了优化。
适用场景
- 开发新功能或 REST API 接口
- 修复 Bug 或重构代码
- 编写数据访问逻辑、安全规则或响应式流(reactive streams)
- 测试 Apache Camel 路由与事件处理器
- 测试基于 RabbitMQ 的事件驱动服务
- 测试条件分支控制逻辑
- 校验
CompletableFuture异步操作 - 测试
LogContext上下文传播
工作流
- 先写测试(预期测试失败)
- 编写最少实现代码使测试通过
- 在测试保持绿色(通过)的前提下进行重构
- 通过 JaCoCo 确保测试覆盖率(目标 80%+)
使用 @Nested 结构化组织单元测试
遵循以下结构化规范,编写全面且可读性强的测试代码:
@ExtendWith(MockitoExtension.class)
@DisplayName("OrderService 单元测试")
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private EventService eventService;
@Mock
private FulfillmentPublisher fulfillmentPublisher;
@InjectMocks
private OrderService orderService;
private CreateOrderCommand validCommand;
@BeforeEach
void setUp() {
validCommand = new CreateOrderCommand(
"customer-123",
List.of(new OrderLine("sku-123", 2))
);
}
@Nested
@DisplayName("createOrder 方法测试")
class CreateOrder {
@Test
@DisplayName("应当成功持久化订单并发布履约事件")
void givenValidCommand_whenCreateOrder_thenPersistsAndPublishes() {
// ARRANGE
doNothing().when(orderRepository).persist(any(Order.class));
// ACT
OrderReceipt receipt = orderService.createOrder(validCommand);
// ASSERT
assertThat(receipt).isNotNull();
assertThat(receipt.customerId()).isEqualTo("customer-123");
verify(orderRepository).persist(any(Order.class));
verify(fulfillmentPublisher).publishAsync(receipt);
verify(eventService).createSuccessEvent(receipt, "ORDER_CREATED");
}
@Test
@DisplayName("当客户 ID 缺失时应当拒绝创建")
void givenMissingCustomerId_whenCreateOrder_thenThrowsBadRequest() {
// ARRANGE
CreateOrderCommand invalid = new CreateOrderCommand("", validCommand.lines());
// ACT & ASSERT
WebApplicationException exception = assertThrows(
WebApplicationException.class,
() -> orderService.createOrder(invalid)
);
assertThat(exception.getResponse().getStatus()).isEqualTo(400);
verify(orderRepository, never()).persist(any(Order.class));
verify(fulfillmentPublisher, never()).publishAsync(any());
}
@Test
@DisplayName("当持久化失败时应当记录错误事件")
void givenPersistenceFailure_whenCreateOrder_thenRecordsErrorEvent() {
// ARRANGE
doThrow(new PersistenceException("database unavailable"))
.when(orderRepository).persist(any(Order.class));
// ACT & ASSERT
PersistenceException exception = assertThrows(
PersistenceException.class,
() -> orderService.createOrder(validCommand)
);
assertThat(exception.getMessage()).contains("database unavailable");
verify(eventService).createErrorEvent(
eq(validCommand),
eq("ORDER_CREATE_FAILED"),
contains("database unavailable")
);
verify(fulfillmentPublisher, never()).publishAsync(any());
}
@Test
@DisplayName("当 Command 为 null 时应当拒绝处理")
void givenNullCommand_whenCreateOrder_thenThrowsNullPointerException() {
// ACT & ASSERT
assertThrows(
NullPointerException.class,
() -> orderService.createOrder(null)
);
verify(orderRepository, never()).persist(any(Order.class));
}
}
}
核心测试模式
@Nested类:按被测方法对测试用例进行分组@DisplayName:为测试报告提供高可读性的描述信息- 命名规范:采用
givenX_whenY_thenZ结构,表达清晰 - AAA 模式:显式添加
// ARRANGE、// ACT、// ASSERT注释划分步骤 @BeforeEach:统一初始化公共测试数据,减少重复代码assertDoesNotThrow:测试正常成功场景,无需手动捕获异常assertThrows:结合 AssertJ 校验异常类型及错误信息- 全面覆盖:涵盖主流程(Happy Path)、Null 输入、边界条件及异常场景
- 交互校验:使用 Mockito 的
verify()确认方法被正确调用 - 防误唤起校验:使用
never()确保异常场景下特定方法绝不会被调用
测试 Camel 路由
@QuarkusTest
@DisplayName("业务规则 Camel 路由测试")
class BusinessRulesRouteTest {
@Inject
CamelContext camelContext;
@Inject
ProducerTemplate producerTemplate;
@InjectMock
EventService eventService;
@InjectMock
DocumentValidator documentValidator;
private BusinessRulesPayload testPayload;
@BeforeEach
void setUp() {
// ARRANGE - 测试数据准备
testPayload = new BusinessRulesPayload();
testPayload.setDocumentId(1L);
testPayload.setFlowProfile(FlowProfile.BASIC);
}
@Nested
@DisplayName("business-rules-publisher 路由测试")
class BusinessRulesPublisher {
@Test
@DisplayName("应当成功将消息发送至 RabbitMQ")
void givenValidPayload_whenPublish_thenMessageSentToQueue() throws Exception {
// ARRANGE
MockEndpoint mockRabbitMQ = camelContext.getEndpoint("mock:rabbitmq", MockEndpoint.class);
mockRabbitMQ.expectedMessageCount(1);
// 用 Mock Endpoint 替换真实 Endpoint 进行测试
camelContext.getRouteController().stopRoute("business-rules-publisher");
AdviceWith.adviceWith(camelContext, "business-rules-publisher", advice -> {
advice.replaceFromWith("direct:business-rules-publisher");
advice.weaveByToString(".*spring-rabbitmq.*").replace().to("mock:rabbitmq");
});
camelContext.getRouteController().startRoute("business-rules-publisher");
// ACT
producerTemplate.sendBody("direct:business-rules-publisher", testPayload);
// ASSERT — 经过 .marshal().json(JsonLibrary.Jackson) 序列化后 body 应为 JSON 字符串
mockRabbitMQ.assertIsSatisfied(5000);
assertThat(mockRabbitMQ.getExchanges()).hasSize(1);
String body = mockRabbitMQ.getExchanges().get(0).getIn().getBody(String.class);
assertThat(body).contains("\"documentId\":1");
}
@Test
@DisplayName("应当正确序列化(Marshalling)为 JSON")
void givenPayload_whenPublish_thenMarshalledToJson() throws Exception {
// ARRANGE
MockEndpoint mockMarshal = new MockEndpoint("mock:marshal");
camelContext.addEndpoint("mock:marshal", mockMarshal);
mockMarshal.expectedMessageCount(1);
camelContext.getRouteController().stopRoute("business-rules-publisher");
AdviceWith.adviceWith(camelContext, "business-rules-publisher", advice -> {
advice.weaveAddLast().to("mock:marshal");
});
camelContext.getRouteController().startRoute("business-rules-publisher");
// ACT
producerTemplate.sendBody("direct:business-rules-publisher", testPayload);
// ASSERT
mockMarshal.assertIsSatisfied(5000);
String body = mockMarshal.getExchanges().get(0).getIn().getBody(String.class);
assertThat(body).contains("\"documentId\":1");
assertThat(body).contains("\"flowProfile\":\"BASIC\"");
}
}
@Nested
@DisplayName("document-processing 路由测试")
class DocumentProcessing {
@Test
@DisplayName("应当将发票文档路由至对应的处理程序")
void givenInvoiceType_whenProcess_thenRoutesToInvoiceProcessor() throws Exception {
// ARRANGE
MockEndpoint mockInvoice = camelContext.getEndpoint("mock:invoice", MockEndpoint.class);
mockInvoice.expectedMessageCount(1);
camelContext.getRouteController().stopRoute("document-processing");
AdviceWith.adviceWith(camelContext, "document-processing", advice -> {
advice.weaveByToString(".*direct:process-invoice.*").replace().to("mock:invoice");
});
camelContext.getRouteController().startRoute("document-processing");
// ACT
producerTemplate.sendBodyAndHeader("direct:process-document",
testPayload, "documentType", "INVOICE");
// ASSERT
mockInvoice.assertIsSatisfied(5000);
}
@Test
@DisplayName("应当优雅处理校验错误")
void givenValidationError_whenProcess_thenRoutesToErrorHandler() throws Exception {
// ARRANGE
MockEndpoint mockError = camelContext.getEndpoint("mock:error", MockEndpoint.class);
mockError.expectedMessageCount(1);
camelContext.getRouteController().stopRoute("document-processing");
AdviceWith.adviceWith(camelContext, "document-processing", advice -> {
advice.weaveByToString(".*direct:validation-error-handler.*")
.replace().to("mock:error");
});
camelContext.getRouteController().startRoute("document-processing");
// Mock validator Bean 使其抛出异常
when(documentValidator.validate(any())).thenThrow(new ValidationException("Invalid document"));
// ACT
producerTemplate.sendBody("direct:process-document", testPayload);
// ASSERT
mockError.assertIsSatisfied(5000);
Exception exception = mockError.getExchanges().get(0).getException();
assertThat(exception).isInstanceOf(ValidationException.class);
assertThat(exception.getMessage()).contains("Invalid document");
}
}
}
测试事件服务
@ExtendWith(MockitoExtension.class)
@DisplayName("EventService 单元测试")
class EventServiceTest {
@Mock
private EventRepository eventRepository;
@Mock
private ObjectMapper objectMapper;
@InjectMocks
private EventService eventService;
private BusinessRulesPayload testPayload;
@BeforeEach
void setUp() {
// ARRANGE
testPayload = new BusinessRulesPayload();
testPayload.setDocumentId(1L);
}
@Nested
@DisplayName("createSuccessEvent 方法测试")
class CreateSuccessEvent {
@Test
@DisplayName("应当创建带有正确属性的成功事件")
void givenValidPayload_whenCreateSuccessEvent_thenEventPersisted() throws Exception {
// ARRANGE
when(objectMapper.writeValueAsString(testPayload)).thenReturn("{\"documentId\":1}");
// ACT
assertDoesNotThrow(() ->
eventService.createSuccessEvent(testPayload, "DOCUMENT_PROCESSED"));
// ASSERT
verify(eventRepository).persist(argThat(event ->
event.getType().equals("DOCUMENT_PROCESSED") &&
event.getStatus() == EventStatus.SUCCESS &&
event.getPayload().equals("{\"documentId\":1}") &&
event.getTimestamp() != null
));
}
@Test
@DisplayName("当 Payload 为 null 时应当抛出异常")
void givenNullPayload_whenCreateSuccessEvent_thenThrowsException() {
// ARRANGE
Object nullPayload = null;
// ACT & ASSERT
NullPointerException exception = assertThrows(
NullPointerException.class,
() -> eventService.createSuccessEvent(nullPayload, "EVENT_TYPE")
);
assertThat(exception.getMessage()).isEqualTo("Payload cannot be null");
verify(eventRepository, never()).persist(any());
}
}
@Nested
@DisplayName("createErrorEvent 方法测试")
class CreateErrorEvent {
@Test
@DisplayName("应当创建包含错误信息的异常事件")
void givenError_whenCreateErrorEvent_thenEventPersistedWithMessage() throws Exception {
// ARRANGE
String errorMessage = "Processing failed";
when(objectMapper.writeValueAsString(testPayload)).thenReturn("{\"documentId\":1}");
<!-- truncated for translation batch; full body continues in source -->






