1편: https://saysimple.tistory.com/197
서론
저번 글에선 Axon을 이용한 CQRS구조를 Postman을 이용해 테스트 해보았습니다. 이번 글에선 유닛 테스트, 통합 테스트를 통해 테스트의 시나리오를 구성하는 방법과 테스트 코드를 리뷰해 보겠습니다.
폴더 구조
테스트 파일의 구조는 아래와 같습니다.
- commandmodel
- OrderAggregateUnitTest.java
- gui
- OrderRestEndpointManualTest.java
- querymodel
- AbstractOrdersEventHandlerUnitTest.java
- InMemoryOrdersEventHandlerUnitTest.java
- MongoOrdersEventHandlerLiveTest.java
- OrderQueryServiceIntergrationTest.java
OrderAggregateUnitTest
커맨드 모델인 주문 Aggregate안의 유닛에서 발생할 수 있는 시나리오를 테스트합니다.
class OrderAggregateUnitTest {
private static final String ORDER_ID = UUID.randomUUID()
.toString();
private static final String PRODUCT_ID = UUID.randomUUID()
.toString();
private FixtureConfiguration<OrderAggregate> fixture;
@BeforeEach
void setUp() {
fixture = new AggregateTestFixture<>(OrderAggregate.class);
}
주문, 상품 아이디와 fixture를 정의합니다. fixture는 테스트 코드에서 미리 정의하고 재사용하는 변수, 객체 등을 의미합니다.
테스트 코드의 이름은 전반적으로 given, when, then으로 이뤄집니다. Axon에서 제공하는 테스트 fixture에서 이벤트 기반으로 정의한 given, when, expect 함수를 따라가는 모습입니다.
- given: ~한 상황일 때
- when: ~가 발생하면
- then: ~가 기대된다.
이렇게 정리할 수 있어 보입니다. 유닛 테스트는 코드만 빠르게 검토하고 넘어가겠습니다. 설명은 @DisplayName을 참고해주세요. 제가 따로 작성한 것이어서 틀린 내용이 있을 수 있습니다.
@Test
@DisplayName("주문 생성 커맨드를 받으면 주문 생성 이벤트를 발행한다.")
void giveNoPriorActivity_whenCreateOrderCommand_thenShouldPublishOrderCreatedEvent() {
fixture.givenNoPriorActivity()
.when(new CreateOrderCommand(ORDER_ID))
.expectEvents(new OrderCreatedEvent(ORDER_ID));
}
테스트 코드를 고의로 틀리게 바꿔서 작성해 보겠습니다.
@Test
@DisplayName("주문 생성 커맨드를 받으면 주문 생성 이벤트를 발행한다.")
void giveNoPriorActivity_whenCreateOrderCommand_thenShouldPublishOrderCreatedEvent() {
fixture.givenNoPriorActivity()
.when(new CreateOrderCommand(ORDER_ID))
.expectEvents(new ProductAddedEvent(ORDER_ID, PRODUCT_ID));
}
기대되는 이벤트를 ProductAddedEvent로 변경했습니다.
org.axonframework.test.AxonAssertionError: The published events do not match the expected events
Expected | Actual
-----------------------------------------------------|-----------------------------------------------------
com.saysimple.axon.coreapi.events.ProductAddedEvent <|> com.saysimple.axon.coreapi.events.OrderCreatedEvent
다음과 같이 기대되는 이벤트는 OrderCreatedEvent인데 ProductAddedEvent가 발생해 테스트 통과에 실패한 것을 확인할 수 있습니다. 아래부터는 나머지 테스트의 코드를 둘러보겠습니다.
@Test
@DisplayName("주문 생성 이벤트를 받고 상품 추가 커맨드를 받으면 상품 추가 이벤트를 발행한다.")
void givenOrderCreatedEvent_whenAddProductCommand_thenShouldPublishProductAddedEvent() {
fixture.given(new OrderCreatedEvent(ORDER_ID))
.when(new AddProductCommand(ORDER_ID, PRODUCT_ID))
.expectEvents(new ProductAddedEvent(ORDER_ID, PRODUCT_ID));
}
@Test
@DisplayName("주문 생성 이벤트와 상품 추가 이벤트를 받고 동일한 상품 추가 커맨드를 받으면 중복 주문 라인 예외를 발생시킨다.")
void givenOrderCreatedEventAndProductAddedEvent_whenAddProductCommandForSameProductId_thenShouldThrowDuplicateOrderLineException() {
fixture.given(new OrderCreatedEvent(ORDER_ID), new ProductAddedEvent(ORDER_ID, PRODUCT_ID))
.when(new AddProductCommand(ORDER_ID, PRODUCT_ID))
.expectException(DuplicateOrderLineException.class)
.expectExceptionMessage(Matchers.predicate(message -> ((String) message).contains(PRODUCT_ID)));
}
@Test
@DisplayName("주문 생성 이벤트와 상품 추가 이벤트를 받고 상품 추가 커맨드를 받으면 상품 추가 이벤트를 발행한다.")
void givenOrderCreatedEventAndProductAddedEvent_whenIncrementProductCountCommand_thenShouldPublishProductCountIncrementedEvent() {
fixture.given(new OrderCreatedEvent(ORDER_ID), new ProductAddedEvent(ORDER_ID, PRODUCT_ID))
.when(new IncrementProductCountCommand(ORDER_ID, PRODUCT_ID))
.expectEvents(new ProductCountIncrementedEvent(ORDER_ID, PRODUCT_ID));
}
@Test
@DisplayName("주문 생성 이벤트와 상품 추가 이벤트와 상품 수량 증가 이벤트를 받고 상품 수량 감소 커맨드를 받으면 상품 수량 감소 이벤트를 발행한다.")
void givenOrderCreatedEventProductAddedEventAndProductCountIncrementedEvent_whenDecrementProductCountCommand_thenShouldPublishProductCountDecrementedEvent() {
fixture.given(new OrderCreatedEvent(ORDER_ID), new ProductAddedEvent(ORDER_ID, PRODUCT_ID), new ProductCountIncrementedEvent(ORDER_ID, PRODUCT_ID))
.when(new DecrementProductCountCommand(ORDER_ID, PRODUCT_ID))
.expectEvents(new ProductCountDecrementedEvent(ORDER_ID, PRODUCT_ID));
}
@Test
@DisplayName("주문 생성 이벤트와 상품 추가 이벤트를 받고 상품 수량 감소 커맨드를 받으면 상품 제거 이벤트를 발행한다.")
void givenOrderCreatedEventAndProductAddedEvent_whenDecrementProductCountCommand_thenShouldPublishProductRemovedEvent() {
fixture.given(new OrderCreatedEvent(ORDER_ID), new ProductAddedEvent(ORDER_ID, PRODUCT_ID))
.when(new DecrementProductCountCommand(ORDER_ID, PRODUCT_ID))
.expectEvents(new ProductRemovedEvent(ORDER_ID, PRODUCT_ID));
}
@Test
@DisplayName("주문 생성 이벤트를 받고 주문 확인 커맨드를 받으면 주문 확인 이벤트를 발행한다.")
void givenOrderCreatedEvent_whenConfirmOrderCommand_thenShouldPublishOrderConfirmedEvent() {
fixture.given(new OrderCreatedEvent(ORDER_ID))
.when(new ConfirmOrderCommand(ORDER_ID))
.expectEvents(new OrderConfirmedEvent(ORDER_ID));
}
@Test
@DisplayName("주문 생성 이벤트와 주문 확인 이벤트를 받고 주문 확인 커맨드를 받으면 이벤트를 발행하지 않는다.")
void givenOrderCreatedEventAndOrderConfirmedEvent_whenConfirmOrderCommand_thenExpectNoEvents() {
fixture.given(new OrderCreatedEvent(ORDER_ID), new OrderConfirmedEvent(ORDER_ID))
.when(new ConfirmOrderCommand(ORDER_ID))
.expectNoEvents();
}
@Test
@DisplayName("주문 생성 이벤트를 받고 주문 배송 커맨드를 받으면 미확인 주문 예외를 발생시킨다.")
void givenOrderCreatedEvent_whenShipOrderCommand_thenShouldThrowUnconfirmedOrderException() {
fixture.given(new OrderCreatedEvent(ORDER_ID))
.when(new ShipOrderCommand(ORDER_ID))
.expectException(UnconfirmedOrderException.class);
}
@Test
@DisplayName("주문 생성 이벤트와 주문 확인 이벤트를 받고 주문 배송 커맨드를 받으면 주문 배송 이벤트를 발행한다.")
void givenOrderCreatedEventAndOrderConfirmedEvent_whenShipOrderCommand_thenShouldPublishOrderShippedEvent() {
fixture.given(new OrderCreatedEvent(ORDER_ID), new OrderConfirmedEvent(ORDER_ID))
.when(new ShipOrderCommand(ORDER_ID))
.expectEvents(new OrderShippedEvent(ORDER_ID));
}
@Test
@DisplayName("주문 생성 이벤트와 주문 확인 이벤트를 받고 상품 추가 커맨드를 받으면 주문 확인 예외를 발생시키고 주문 확인 예외 메시지에 주문 아이디를 포함한다.")
void givenOrderCreatedEventProductAndOrderConfirmedEvent_whenAddProductCommand_thenShouldThrowOrderAlreadyConfirmedException() {
fixture.given(new OrderCreatedEvent(ORDER_ID), new OrderConfirmedEvent(ORDER_ID))
.when(new AddProductCommand(ORDER_ID, PRODUCT_ID))
.expectException(OrderAlreadyConfirmedException.class)
.expectExceptionMessage(Matchers.predicate(message -> ((String) message).contains(ORDER_ID)));
}
@Test
@DisplayName("주문 생성 이벤트와 상품 추가 이벤트와 주문 확인 이벤트를 받고 상품 수량 증가 커맨드를 받으면 주문 확인 예외를 발생시키고 주문 확인 예외 메시지에 주문 아이디를 포함한다.")
void givenOrderCreatedEventProductAddedEventAndOrderConfirmedEvent_whenIncrementProductCountCommand_thenShouldThrowOrderAlreadyConfirmedException() {
fixture.given(new OrderCreatedEvent(ORDER_ID), new ProductAddedEvent(ORDER_ID, PRODUCT_ID), new OrderConfirmedEvent(ORDER_ID))
.when(new IncrementProductCountCommand(ORDER_ID, PRODUCT_ID))
.expectException(OrderAlreadyConfirmedException.class)
.expectExceptionMessage(Matchers.predicate(message -> ((String) message).contains(ORDER_ID)));
}
@Test
@DisplayName("주문 생성 이벤트와 상품 추가 이벤트와 주문 확인 이벤트를 받고 상품 수량 감소 커맨드를 받으면 이미 확인된 주문 예외를 발생시키고 주문 확인 예외 메시지에 주문 아이디를 포함한다.")
void givenOrderCreatedEventProductAddedEventAndOrderConfirmedEvent_whenDecrementProductCountCommand_thenShouldThrowOrderAlreadyConfirmedException() {
fixture.given(new OrderCreatedEvent(ORDER_ID), new ProductAddedEvent(ORDER_ID, PRODUCT_ID), new OrderConfirmedEvent(ORDER_ID))
.when(new DecrementProductCountCommand(ORDER_ID, PRODUCT_ID))
.expectException(OrderAlreadyConfirmedException.class)
.expectExceptionMessage(Matchers.predicate(message -> ((String) message).contains(ORDER_ID)));
}
대부분 주문 Aggregate에서 발생할 수 있는 시나리오와, 기대되는 상황이 올바르게 발생 하였는가 테스트 하는 내용을 볼 수 있습니다.
InMemoryOrdersEventHandlerUnitTest
메모리 객체에서 이벤트를 테스트하는 유닛 테스트입니다. 추상 주문 이벤트 핸들러 유닛 테스트를 상속 받아 테스트를 실행합니다. handler에 대한 리뷰는 다음 글에서 진행할 예정입니다.
public class InMemoryOrdersEventHandlerUnitTest extends AbstractOrdersEventHandlerUnitTest {
@Override
protected OrdersEventHandler getHandler() {
return new InMemoryOrdersEventHandler(emitter);
}
}
MongoOrdersEventHandlerLiveTest
몽고 디비에서 이벤트를 테스트하는 라이브 테스트입니다. 실제 가상 컨테이너를 띄워 마찬가지로 추상 주문 이벤트 핸들러 유닛 테스트를 상속 받아 테스트를 실행합니다. @DynamicPropertySource는 동적으로 스프링 부트의 환경 변수를 초기화합니다.
몽고 디비 컨테이너를 생성하고 uri를 가져와 초기화 한 뒤 핸들러를 초기화 하는 함수를 오버라이드 합니다.
@DataMongoTest
public class MongoOrdersEventHandlerLiveTest extends AbstractOrdersEventHandlerUnitTest {
@Autowired
MongoClient mongoClient;
@Container
static MongoDBContainer mongoDBContainer = new MongoDBContainer("mongo:6.0").withExposedPorts(27017);
@DynamicPropertySource
static void mongoDbProperties(DynamicPropertyRegistry registry) {
mongoDBContainer.start();
registry.add("spring.data.mongodb.uri", mongoDBContainer::getReplicaSetUrl);
}
@Override
protected OrdersEventHandler getHandler() {
mongoClient.getDatabase("axonframework")
.drop();
return new MongoOrdersEventHandler(mongoClient, emitter);
}
}
AbstractOrdersEventhandlerUnitTest
추상 주문 이벤트 핸들러의 유닛 테스트입니다.
public abstract class AbstractOrdersEventHandlerUnitTest {
private static final String ORDER_ID_1 = UUID.randomUUID()
.toString();
private static final String ORDER_ID_2 = UUID.randomUUID()
.toString();
private static final String PRODUCT_ID_1 = UUID.randomUUID()
.toString();
private static final String PRODUCT_ID_2 = UUID.randomUUID()
.toString();
private OrdersEventHandler handler;
private static Order orderOne;
private static Order orderTwo;
QueryUpdateEmitter emitter = mock(QueryUpdateEmitter.class);
@BeforeAll
static void createOrders() {
orderOne = new Order(ORDER_ID_1);
orderOne.getProducts()
.put(PRODUCT_ID_1, 3);
orderOne.setOrderShipped();
orderTwo = new Order(ORDER_ID_2);
orderTwo.getProducts()
.put(PRODUCT_ID_1, 1);
orderTwo.getProducts()
.put(PRODUCT_ID_2, 1);
orderTwo.setOrderConfirmed();
}
@BeforeEach
void setUp() {
handler = getHandler();
}
protected abstract OrdersEventHandler getHandler();
각 2개의 주문과 상품 아이디를 선언하고, 주문 이벤트 핸들러, 에미터를 선언합니다. 에미터는 QueryUpdateEmitter를 모킹하며 QueryUpdateEmitter는 Axon에서 제공합니다. @BeforeAll은 모든 테스트가 실행되기 전에 한 번만 실행됩니다. @BeforeEach는 각 테스트가 실행되기 전에 실행됩니다. createOrders는 주문 생성, 상품 추가를 한 후, 각각 배송 상태, 확인 상태로 변경합니다.
@Test
@DisplayName("두 개의 주문을 초기화 한 후 모든 주문을 찾는 쿼리를 수행하면 두 개의 주문이 반환된다.")
void givenTwoOrdersPlacedOfWhichOneNotShipped_whenFindAllOrderedProductsQuery_thenCorrectOrdersAreReturned() {
resetWithTwoOrders();
List<Order> result = handler.handle(new FindAllOrderedProductsQuery());
assertNotNull(result);
assertEquals(2, result.size());
Order order_1 = result.stream()
.filter(o -> o.getOrderId()
.equals(ORDER_ID_1))
.findFirst()
.orElse(null);
assertEquals(orderOne, order_1);
Order order_2 = result.stream()
.filter(o -> o.getOrderId()
.equals(ORDER_ID_2))
.findFirst()
.orElse(null);
assertEquals(orderTwo, order_2);
}
아래와 같이 테스트가 정상적으로 실행되는 것을 확인할 수 있습니다.
BUILD SUCCESSFUL in 1s
7 actionable tasks: 2 executed, 5 up-to-date
6:30:55 PM: Execution finished ':domains:cqrs:test --tests "com.saysimple.axon.querymodel.InMemoryOrdersEventHandlerUnitTest.givenTwoOrdersPlacedOfWhichOneNotShipped_whenFindAllOrderedProductsQuery_thenCorrectOrdersAreReturned"'.
해당 테스트의 Order order_1 = result.stream() 의 하위에서 오더 아이디를 2로 바꿔서 테스트를 진행해 보겠습니다.
Expected :Order{orderId='2c0f5a7f-f0e9-4159-a35c-9b3862cdc1c5', products={8543a718-0691-43fc-b5e7-2549ec29ee4b=3}, orderStatus=SHIPPED}
Actual :Order{orderId='3666b6aa-6585-4775-b551-33a0616bb895', products={8543a718-0691-43fc-b5e7-2549ec29ee4b=1, 8e874d45-025c-4dd6-a794-c83c7ca27680=1}, orderStatus=CONFIRMED}
<Click to see difference>
org.opentest4j.AssertionFailedError: expected: <Order{orderId='2c0f5a7f-f0e9-4159-a35c-9b3862cdc1c5', products={8543a718-0691-43fc-b5e7-2549ec29ee4b=3}, orderStatus=SHIPPED}> but was: <Order{orderId='3666b6aa-6585-4775-b551-33a0616bb895', products={8543a718-0691-43fc-b5e7-2549ec29ee4b=1, 8e874d45-025c-4dd6-a794-c83c7ca27680=1}, orderStatus=CONFIRMED}>
...
Failed to map supported failure 'org.opentest4j.AssertionFailedError: expected: <Order{orderId='2c0f5a7f-f0e9-4159-a35c-9b3862cdc1c5', products={8543a718-0691-43fc-b5e7-2549ec29ee4b=3}, orderStatus=SHIPPED}> but was: <Order{orderId='3666b6aa-6585-4775-b551-33a0616bb895', products={8543a718-0691-43fc-b5e7-2549ec29ee4b=1, 8e874d45-025c-4dd6-a794-c83c7ca27680=1}, orderStatus=CONFIRMED}>' with mapper 'org.gradle.api.internal.tasks.testing.failure.mappers.OpenTestAssertionFailedMapper@630d1b2f': Cannot invoke "Object.getClass()" because "expectedValue" is null
> Task :domains:cqrs:test FAILED
InMemoryOrdersEventHandlerUnitTest > 두 개의 주문을 초기화 한 후 모든 주문을 찾는 쿼리를 수행하면 두 개의 주문이 반환된다. FAILED
org.opentest4j.AssertionFailedError at AssertionFailureBuilder.java:151
1 test completed, 1 failed
Trace 부분은 ...으로 생략 하였습니다. 보시는 것과 같이 오더 아이디가 달라 테스트가 실패하는 것을 알 수 있습니다.
StepVerifier는 reactor-test에서 제공하는 Streaming 용 테스트 라이브러리입니다. create에서 Flux로 부터 Publisher 등을 받으면 assertNext에 Consumer를 파라미터로 제공해 다음에 발행되는 데이터를 검증할 수 있습니다.
@Test
@DisplayName("두 개의 주문을 초기화 한 후 모든 주문을 찾는 쿼리를 스트리밍으로 수행하면 두 개의 주문이 반환된다.")
void givenTwoOrdersPlacedOfWhichOneNotShipped_whenFindAllOrderedProductsQueryStreaming_thenCorrectOrdersAreReturned() {
resetWithTwoOrders();
final Consumer<Order> orderVerifier = order -> {
if (order.getOrderId()
.equals(orderOne.getOrderId())) {
assertEquals(orderOne, order);
} else if (order.getOrderId()
.equals(orderTwo.getOrderId())) {
assertEquals(orderTwo, order);
} else {
throw new RuntimeException("Would expect either order one or order two");
}
};
StepVerifier.create(Flux.from(handler.handleStreaming(new FindAllOrderedProductsQuery())))
.assertNext(orderVerifier)
.assertNext(orderVerifier)
.expectComplete()
.verify();
}
테스트에 대한 설명은 DisplayName을 참고 해주시기 바랍니다. 위에서 테스트를 성공 및 실패 시켜 보았으니 이제부턴 실제 테스트는 생략하겠습니다.
@Test
@DisplayName("두 개의 주문을 초기화 한 후 모든 주문을 찾는 쿼리를 스트리밍으로 수행하면 두 개의 주문이 반환된다.")
void givenTwoOrdersPlacedOfWhichOneNotShipped_whenFindAllOrderedProductsQueryStreaming_thenCorrectOrdersAreReturned() {
resetWithTwoOrders();
final Consumer<Order> orderVerifier = order -> {
if (order.getOrderId()
.equals(orderOne.getOrderId())) {
assertEquals(orderOne, order);
} else if (order.getOrderId()
.equals(orderTwo.getOrderId())) {
assertEquals(orderTwo, order);
} else {
throw new RuntimeException("Would expect either order one or order two");
}
};
StepVerifier.create(Flux.from(handler.handleStreaming(new FindAllOrderedProductsQuery())))
.assertNext(orderVerifier)
.assertNext(orderVerifier)
.expectComplete()
.verify();
}
@Test
@DisplayName("주문이 없는 경우 총 배송된 제품 쿼리를 수행하면 0이 반환된다.")
void givenNoOrdersPlaced_whenTotalProductsShippedQuery_thenZeroReturned() {
assertEquals(0, handler.handle(new TotalProductsShippedQuery(PRODUCT_ID_1)));
}
@Test
@DisplayName("두 개의 주문을 초기화 한 후 각 주문의 총 배송된 제품 쿼리를 수행하면 각 주문의 제품 수가 반환된다.")
void givenTwoOrdersPlacedOfWhichOneNotShipped_whenTotalProductsShippedQuery_thenOnlyCountProductsFirstOrder() {
resetWithTwoOrders();
assertEquals(3, handler.handle(new TotalProductsShippedQuery(PRODUCT_ID_1)));
assertEquals(0, handler.handle(new TotalProductsShippedQuery(PRODUCT_ID_2)));
}
@Test
@DisplayName("두 개의 주문을 초기화 한 후 두 번째 주문에 대한 주문 배송 이벤트를 수행하면 두 번째 주문의 제품 수가 결과에 반영되어야 한다.")
void givenTwoOrdersPlacedAndShipped_whenTotalProductsShippedQuery_thenCountBothOrders() {
resetWithTwoOrders();
handler.on(new OrderShippedEvent(ORDER_ID_2));
assertEquals(4, handler.handle(new TotalProductsShippedQuery(PRODUCT_ID_1)));
assertEquals(1, handler.handle(new TotalProductsShippedQuery(PRODUCT_ID_2)));
}
@Test
@DisplayName("1번 주문에 대한 주문 갱신 쿼리를 수행하면 1번 주문이 반환되고 3개의 제품이 포함되어야 한다.")
void givenOrderExist_whenOrderUpdatesQuery_thenOrderReturned() {
resetWithTwoOrders();
Order result = handler.handle(new OrderUpdatesQuery(ORDER_ID_1));
assertNotNull(result);
assertEquals(ORDER_ID_1, result.getOrderId());
assertEquals(3, result.getProducts()
.get(PRODUCT_ID_1));
assertEquals(OrderStatus.SHIPPED, result.getOrderStatus());
}
@Test
@DisplayName("주문 생성 이벤트가 발생하고 상품 추가 이벤트가 발생하면 주문 갱신 쿼리가 한 번 발생해야 한다.")
void givenOrderExist_whenProductAddedEvent_thenUpdateEmittedOnce() {
handler.on(new OrderCreatedEvent(ORDER_ID_1));
handler.on(new ProductAddedEvent(ORDER_ID_1, PRODUCT_ID_1));
verify(emitter, times(1)).emit(eq(OrderUpdatesQuery.class), any(), any(Order.class));
}
@Test
@DisplayName("주문 생성 이벤트가 발생하고 상품 추가 이벤트가 발생하고 상품 감소 이벤트가 발생하면 주문 갱신 쿼리가 한 번 발생해야 한다.")
void givenOrderWithProductExist_whenProductCountDecrementedEvent_thenUpdateEmittedOnce() {
handler.on(new OrderCreatedEvent(ORDER_ID_1));
handler.on(new ProductAddedEvent(ORDER_ID_1, PRODUCT_ID_1));
reset(emitter);
handler.on(new ProductCountDecrementedEvent(ORDER_ID_1, PRODUCT_ID_1));
verify(emitter, times(1)).emit(eq(OrderUpdatesQuery.class), any(), any(Order.class));
}
@Test
@DisplayName("주문 생성 이벤트가 발생하고 상품 추가 이벤트가 발생하고 상품 제거 이벤트가 발생하면 주문 갱신 쿼리가 한 번 발생해야 한다.")
void givenOrderWithProductExist_whenProductRemovedEvent_thenUpdateEmittedOnce() {
handler.on(new OrderCreatedEvent(ORDER_ID_1));
handler.on(new ProductAddedEvent(ORDER_ID_1, PRODUCT_ID_1));
reset(emitter);
handler.on(new ProductRemovedEvent(ORDER_ID_1, PRODUCT_ID_1));
verify(emitter, times(1)).emit(eq(OrderUpdatesQuery.class), any(), any(Order.class));
}
@Test
@DisplayName("주문 생성 이벤트가 발생하고 상품 추가 이벤트가 발생하고 상품 증가 이벤트가 발생하면 주문 갱신 쿼리가 한 번 발생해야 한다.")
void givenOrderWithProductExist_whenProductCountIncrementedEvent_thenUpdateEmittedOnce() {
handler.on(new OrderCreatedEvent(ORDER_ID_1));
handler.on(new ProductAddedEvent(ORDER_ID_1, PRODUCT_ID_1));
reset(emitter);
handler.on(new ProductCountIncrementedEvent(ORDER_ID_1, PRODUCT_ID_1));
verify(emitter, times(1)).emit(eq(OrderUpdatesQuery.class), any(), any(Order.class));
}
@Test
@DisplayName("주문 생성 이벤트가 발생하고 상품 추가 이벤트가 발생하고 주문 확인 이벤트가 발생하면 주문 갱신 쿼리가 한 번 발생해야 한다.")
void givenOrderWithProductExist_whenOrderConfirmedEvent_thenUpdateEmittedOnce() {
handler.on(new OrderCreatedEvent(ORDER_ID_1));
handler.on(new ProductAddedEvent(ORDER_ID_1, PRODUCT_ID_1));
reset(emitter);
handler.on(new OrderConfirmedEvent(ORDER_ID_1));
verify(emitter, times(1)).emit(eq(OrderUpdatesQuery.class), any(), any(Order.class));
}
@Test
@DisplayName("주문 생성 이벤트가 발생하고 상품 추가 이벤트가 발생하고 주문 배송 이벤트가 발생하면 주문 갱신 쿼리가 한 번 발생해야 한다.")
void givenOrderWithProductAndConfirmationExist_whenOrderShippedEvent_thenUpdateEmittedOnce() {
handler.on(new OrderCreatedEvent(ORDER_ID_1));
handler.on(new ProductAddedEvent(ORDER_ID_1, PRODUCT_ID_1));
reset(emitter);
handler.on(new OrderShippedEvent(ORDER_ID_1));
verify(emitter, times(1)).emit(eq(OrderUpdatesQuery.class), any(), any(Order.class));
}
테스트를 위해서 두 개의 주문을 초기화합니다. reset은 handler에서 확인할 수 있습니다.
private void resetWithTwoOrders() {
handler.reset(Arrays.asList(orderOne, orderTwo));
}
OrderQueryServiceIntergrationTest
주문 쿼리 서비스 통합 테스트입니다. 주문 비즈니스 로직에 대한 시나리오들을 테스트 합니다.
@SpringBootTest(classes = OrderApplication.class)
class OrderQueryServiceIntegrationTest {
@Autowired
OrderQueryService queryService;
@Autowired
EventGateway eventGateway;
@Autowired
OrdersEventHandler handler;
private String orderId;
private final String productId = "Deluxe Chair";
@BeforeEach
void setUp() {
orderId = UUID.randomUUID()
.toString();
Order order = new Order(orderId);
handler.reset(Collections.singletonList(order));
}
쿼리 서비스, 이벤트 게이트웨이, 이벤트 핸들러를 선언하고 setUp 메소드에서 주문을 정의한 후 핸들러의 reset 메소드로 주문을 초기화 합니다.
@Test
@DisplayName("주문 생성 이벤트를 보내고 모든 주문을 조회하면 주문이 하나 반환된다.")
void givenOrderCreatedEventSend_whenCallingAllOrders_thenOneCreatedOrderIsReturned() throws ExecutionException, InterruptedException {
List<OrderResponse> result = queryService.findAllOrders().get();
assertEquals(1, result.size());
OrderResponse response = result.get(0);
assertEquals(orderId, response.getOrderId());
assertEquals(OrderStatusResponse.CREATED, response.getOrderStatus());
assertTrue(response.getProducts().isEmpty());
}
모든 주문을 가져오면 이전에 생성된 주문 1개와 해당 주문이 이전에 생성된 주문 아이디, 생성 상태를 갖고, 상품 목록은 비어있어야 합니다.
BUILD SUCCESSFUL in 8s
7 actionable tasks: 2 executed, 5 up-to-date
오후 10:44:04: Execution finished ':domains:cqrs:test --tests "com.saysimple.axon.querymodel.OrderQueryServiceIntegrationTest.givenOrderCreatedEventSend_whenCallingAllOrders_thenOneCreatedOrderIsReturned"'.
빌드가 성공되어 테스트가 잘 진행된 것을 확인할 수 있습니다.
@Test
@DisplayName("주문 생성 이벤트를 보내고 모든 주문을 조회하면 주문이 하나 반환된다.")
void givenOrderCreatedEventSend_whenCallingAllOrders_thenOneCreatedOrderIsReturned() throws ExecutionException, InterruptedException {
List<OrderResponse> result = queryService.findAllOrders().get();
assertEquals(1, result.size());
OrderResponse response = result.get(0);
assertEquals(orderId, response.getOrderId());
assertEquals(OrderStatusResponse.SHIPPED, response.getOrderStatus());
assertTrue(response.getProducts().isEmpty());
}
기대되는 주문 상태를 CREATED에서 SHIPPED로 변경해 보겠습니다.
22:41:35.322 [Test worker] INFO org.mongodb.driver.client - MongoClient with metadata {"driver": {"name": "mongo-java-driver|sync|spring-boot", "version": "4.11.1"}, "os": {"type": "Windows", "name": "Windows 11", "architecture": "amd64", "version": "10.0"}, "platform": "Java/Oracle Corporation/19.0.2+7-44"} created with settings MongoClientSettings{readPreference=primary, writeConcern=WriteConcern{w=null, wTimeout=null ms, journal=null}, retryWrites=true, retryReads=true, readConcern=ReadConcern{level=null}, credential=null, transportSettings=null, streamFactoryFactory=null, commandListeners=[], codecRegistry=ProvidersCodecRegistry{codecProviders=[ValueCodecProvider{}, BsonValueCodecProvider{}, DBRefCodecProvider{}, DBObjectCodecProvider{}, DocumentCodecProvider{}, CollectionCodecProvider{}, IterableCodecProvider{}, MapCodecProvider{}, GeoJsonCodecProvider{}, GridFSFileCodecProvider{}, Jsr310CodecProvider{}, JsonObjectCodecProvider{}, BsonCodecProvider{}, EnumCodecProvider{}, com.mongodb.client.model.mql.ExpressionCodecProvider@7d2ccd7f, com.mongodb.Jep395RecordCodecProvider@7ae49d52, com.mongodb.KotlinCodecProvider@40ef1198]}, loggerSettings=LoggerSettings{maxDocumentLength=1000}, clusterSettings={hosts=[localhost:27017], srvServiceName=mongodb, mode=SINGLE, requiredClusterType=UNKNOWN, requiredReplicaSetName='null', serverSelector='null', clusterListeners='[]', serverSelectionTimeout='30000 ms', localThreshold='15 ms'}, socketSettings=SocketSettings{connectTimeoutMS=10000, readTimeoutMS=0, receiveBufferSize=0, proxySettings=ProxySettings{host=null, port=null, username=null, password=null}}, heartbeatSocketSettings=SocketSettings{connectTimeoutMS=10000, readTimeoutMS=10000, receiveBufferSize=0, proxySettings=ProxySettings{host=null, port=null, username=null, password=null}}, connectionPoolSettings=ConnectionPoolSettings{maxSize=100, minSize=0, maxWaitTimeMS=120000, maxConnectionLifeTimeMS=0, maxConnectionIdleTimeMS=0, maintenanceInitialDelayMS=0, maintenanceFrequencyMS=60000, connectionPoolListeners=[], maxConnecting=2}, serverSettings=ServerSettings{heartbeatFrequencyMS=10000, minHeartbeatFrequencyMS=500, serverListeners='[]', serverMonitorListeners='[]'}, sslSettings=SslSettings{enabled=false, invalidHostNameAllowed=false, context=null}, applicationName='null', compressorList=[], uuidRepresentation=JAVA_LEGACY, serverApi=null, autoEncryptionSettings=null, dnsClient=null, inetAddressResolver=null, contextProvider=null}
22:41:35.483 [Test worker] INFO c.s.a.q.OrderQueryServiceIntegrationTest - Started OrderQueryServiceIntegrationTest in 4.556 seconds (process running for 5.697)
22:41:36.034 [EventProcessor[orders]-0] INFO o.a.e.TrackingEventProcessor - Worker assigned to segment Segment[0/0] for processing
22:41:36.052 [EventProcessor[orders]-0] INFO o.a.e.TrackingEventProcessor - Using current Thread for last segment worker: TrackingSegmentWorker{processor=orders, segment=Segment[0/0]}
22:41:36.057 [EventProcessor[orders]-0] INFO o.a.e.TrackingEventProcessor - Fetched token: null for segment: Segment[0/0]
Expected :SHIPPED
Actual :CREATED
기대되는 주문은 CREATED인데 SHIPPED가 반환되어 테스트가 실패하는 것을 확인할 수 있습니다.
나머지 테스트입니다.
@Test
@DisplayName("주문 생성 이벤트를 보내고 모든 주문을 스트리밍하면 주문이 하나 반환되고 스트리밍이 완료된다.")
void givenOrderCreatedEventSend_whenCallingAllOrdersStreaming_thenOneOrderIsReturned() {
Flux<OrderResponse> result = queryService.allOrdersStreaming();
StepVerifier.create(result)
.assertNext(order -> assertEquals(orderId, order.getOrderId()))
.expectComplete()
.verify();
}
@Test
@DisplayName("3개의 Deluxe Chair가 발송된 주문을 보내고 해당 상품의 총 발송량을 조회하면 3이 반환된다.")
void givenThreeDeluxeChairsShipped_whenCallingAllShippedChairs_then234PlusTreeIsReturned() {
Order order = new Order(orderId);
order.getProducts()
.put(productId, 3);
order.setOrderShipped();
handler.reset(Collections.singletonList(order));
assertEquals(3, queryService.totalShipped(productId));
}
@Test
@DisplayName("주문 갱신 쿼리를 구독하고 상품 추가, 상품 수량 증가, 상품 수량 감소, 주문 확인, 주문 발송 이벤트를 보내면 갱신된 주문을 반환한다.")
void givenOrdersAreUpdated_whenCallingOrderUpdates_thenUpdatesReturned() {
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
executor.schedule(this::addIncrementDecrementConfirmAndShip, 100L, TimeUnit.MILLISECONDS);
try {
StepVerifier.create(queryService.orderUpdates(orderId))
.assertNext(order -> assertTrue(order.getProducts()
.isEmpty()))
.assertNext(order -> assertEquals(1, order.getProducts()
.get(productId)))
.assertNext(order -> assertEquals(2, order.getProducts()
.get(productId)))
.assertNext(order -> assertEquals(1, order.getProducts()
.get(productId)))
.assertNext(order -> assertEquals(OrderStatusResponse.CONFIRMED, order.getOrderStatus()))
.assertNext(order -> assertEquals(OrderStatusResponse.SHIPPED, order.getOrderStatus()))
.thenCancel()
.verify();
} finally {
executor.shutdown();
}
}
꼭 각 테스트에서 값을 바꿔서 테스트를 실패하는 상태를 만들어 보시길 권장드립니다.
private void addIncrementDecrementConfirmAndShip() {
eventGateway.publish(new ProductAddedEvent(orderId, productId));
eventGateway.publish(new ProductCountIncrementedEvent(orderId, productId));
eventGateway.publish(new ProductCountDecrementedEvent(orderId, productId));
eventGateway.publish(new OrderConfirmedEvent(orderId));
eventGateway.publish(new OrderShippedEvent(orderId));
}
테스트에 사용되는 상품 추가, 상품 수량 증가, 수량 감소, 주문 확인, 주문 배송 이벤트 발행 메소드입니다.
참고) 메소드가 재활용되지 않아 제가 하나의 메소드로 합쳤습니다.
OrderRestEndpointManualTest
API 메뉴얼 테스트입니다. User Acceptance Test로 봐도 될 것 같습니다. WebClient를 이용해 실제로 API에 요청을 보내 각 시나리오를 테스트합니다.
@SpringBootTest(classes = OrderApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
//marked as manual as the test is unstable on Jenkins due to low resources
class OrderRestEndpointManualTest {
@LocalServerPort
private int port;
스프링부트 애플리케이션의 클래스를 명시해주고 랜덤 포트를 사용합니다. 그리고 로컬 서버 포트를 선언해줍니다.
@DirtiesContext는 테스트를 실행할 때 Context를 재생성해 각 테스트 간의 Context 공유로 발생하는 문제를 방지합니다.
@Test
@DirtiesContext
@DisplayName("주문 생성 API를 호출하고 모든 주문을 조회하면 주문이 하나 반환된다.")
void givenCreateOrderCalled_whenCallingAllOrders_thenOneCreatedOrderIsReturned() {
WebClient client = WebClient.builder()
.clientConnector(httpConnector())
.build();
createRandomNewOrder(client);
StepVerifier.create(retrieveListResponse(client.get()
.uri("http://localhost:" + port + "/all-orders")))
.expectNextMatches(list -> 1 == list.size() && list.get(0)
.getOrderStatus() == OrderStatusResponse.CREATED)
.verifyComplete();
}
아래와 같이 테스트가 정상적으로 실행되는 것을 확인할 수 있습니다.
BUILD SUCCESSFUL in 12s
7 actionable tasks: 2 executed, 5 up-to-date
오후 11:28:44: Execution finished ':domains:cqrs:test --tests "com.saysimple.axon.gui.OrderRestEndpointManualTest.givenCreateOrderCalled_whenCallingAllOrders_thenOneCreatedOrderIsReturned"'.
이제 기대되는 주문 목록 갯수를 2로 변경해서 테스트 해보겠습니다.
java.lang.AssertionError: expectation "expectNextMatches" failed (predicate failed on value: [com.saysimple.axon.querymodel.OrderResponse@7486f41e])
...
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
> Task :domains:cqrs:test
OrderRestEndpointManualTest > �ֹ� ���� API�� ȣ���ϰ� ��� �ֹ��� ��ȸ�ϸ� �ֹ��� �ϳ� ��ȯ�ȴ�. FAILED
java.lang.AssertionError at MessageFormatter.java:115
1 test completed, 1 failed
> Task :domains:cqrs:test FAILED
FAILURE: Build failed with an exception.
Trace는 ...으로 생략하였습니다. 다음과 같이 expectNextMatches가 실패했으며 OrderResponse에서 실패를 발견했다는 로그를 확인할 수 있습니다. 위의 깨진 문자는 DisplayName이 한글이라 깨진 것 같습니다.
나머지 테스트를 둘러보겠습니다.
@Test
@DirtiesContext
@DisplayName("주문 생성 API를 세 번 호출하고 모든 주문을 스트리밍하면 세 개의 주문이 반환된다.")
void givenCreateOrderCalledThreeTimesAnd_whenCallingAllOrdersStreaming_thenTwoCreatedOrdersAreReturned() {
WebClient client = WebClient.builder()
.clientConnector(httpConnector())
.build();
for (int i = 0; i < 3; i++) {
createRandomNewOrder(client);
}
StepVerifier.create(retrieveStreamingResponse(client.get()
.uri("http://localhost:" + port + "/all-orders-streaming")))
.expectNextMatches(o -> o.getOrderStatus() == OrderStatusResponse.CREATED)
.expectNextMatches(o -> o.getOrderStatus() == OrderStatusResponse.CREATED)
.expectNextMatches(o -> o.getOrderStatus() == OrderStatusResponse.CREATED)
.verifyComplete();
}
@Test
@DirtiesContext
@DisplayName("배송 전 확인이 필요한 규칙이 있는 경우 미확인 상태 주문에 주문 배송을 요청하면 오류가 발생한다.")
void givenRuleExistThatNeedConfirmationBeforeShipping_whenCallingShipUnconfirmed_thenErrorReturned() {
WebClient client = WebClient.builder()
.clientConnector(httpConnector())
.build();
StepVerifier.create(retrieveResponse(client.post()
.uri("http://localhost:" + port + "/ship-unconfirmed-order")))
.verifyError(WebClientResponseException.class);
}
@Test
@DirtiesContext
@DisplayName("주문상태를 배송으로 갱신하면 총 배송된 제품 수량이 1 증가한다.")
void givenShipOrderCalled_whenCallingAllShippedChairs_thenPlusOneIsReturned() {
WebClient client = WebClient.builder()
.clientConnector(httpConnector())
.build();
verifyVoidPost(client, "http://localhost:" + port + "/ship-order");
StepVerifier.create(retrieveIntegerResponse(client.get()
.uri("http://localhost:" + port + "/total-shipped/Deluxe Chair")))
.assertNext(r -> assertEquals(1, r))
.verifyComplete();
}
@Test
@DirtiesContext
void givenOrdersAreUpdated_whenCallingOrderUpdates_thenUpdatesReturned() {
WebClient updaterClient = WebClient.builder()
.clientConnector(httpConnector())
.build();
WebClient receiverClient = WebClient.builder()
.clientConnector(httpConnector())
.build();
String orderId = UUID.randomUUID().toString();
String productId = UUID.randomUUID().toString();
StepVerifier.create(retrieveResponse(updaterClient.post()
.uri("http://localhost:" + port + "/order/" + orderId)))
.assertNext(Assertions::assertNotNull)
.verifyComplete();
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
executor.schedule(() -> addIncrementDecrementConfirmAndShipProduct(orderId, productId), 1L, TimeUnit.SECONDS);
try {
StepVerifier.create(retrieveStreamingResponse(receiverClient.get()
.uri("http://localhost:" + port + "/order-updates/" + orderId)))
.assertNext(p -> assertTrue(p.getProducts().isEmpty()))
.assertNext(p -> assertEquals(1, p.getProducts().get(productId)))
.assertNext(p -> assertEquals(2, p.getProducts().get(productId)))
.assertNext(p -> assertEquals(1, p.getProducts().get(productId)))
.assertNext(p -> assertEquals(OrderStatusResponse.CONFIRMED, p.getOrderStatus()))
.assertNext(p -> assertEquals(OrderStatusResponse.SHIPPED, p.getOrderStatus()))
.thenCancel()
.verify();
} finally {
executor.shutdown();
}
}
테스트 메소드와 클래스입니다.
private void addIncrementDecrementConfirmAndShipProduct(String orderId, String productId) {
WebClient client = WebClient.builder()
.clientConnector(httpConnector())
.build();
String base = "http://localhost:" + port + "/order/" + orderId;
verifyVoidPost(client, base + "/product/" + productId);
verifyVoidPost(client, base + "/product/" + productId + "/increment");
verifyVoidPost(client, base + "/product/" + productId + "/decrement");
verifyVoidPost(client, base + "/confirm");
verifyVoidPost(client, base + "/ship");
}
private void createRandomNewOrder(WebClient client) {
StepVerifier.create(retrieveResponse(client.post()
.uri("http://localhost:" + port + "/order")))
.assertNext(Assertions::assertNotNull)
.verifyComplete();
}
private void verifyVoidPost(WebClient client, String uri) {
StepVerifier.create(retrieveResponse(client.post().uri(uri)))
.verifyComplete();
}
private Mono<String> retrieveResponse(WebClient.RequestBodySpec spec) {
return spec.retrieve().bodyToMono(String.class);
}
private Mono<ResponseList> retrieveListResponse(WebClient.RequestHeadersSpec<?> spec) {
return spec.accept(MediaType.APPLICATION_JSON)
.retrieve()
.bodyToMono(ResponseList.class);
}
private Mono<Integer> retrieveIntegerResponse(WebClient.RequestHeadersSpec<?> spec) {
return spec.retrieve()
.bodyToMono(Integer.class);
}
private Flux<OrderResponse> retrieveStreamingResponse(WebClient.RequestHeadersSpec<?> spec) {
return spec.retrieve()
.bodyToFlux(OrderResponse.class);
}
private static class ResponseList extends ArrayList<OrderResponse> {
private ResponseList() {
super();
}
}
주문 생성, 상품 수량 증가, 감소, 주문 확인, 배송 시나리오 로직과 WebClient를 적절한 Response로 변환해주는 메소드입니다.
결론
Command, Query, API에 대한 모든 테스트를 둘러봤습니다. 각 테스트 코드는 EventHandler, QueryService, Aggregate에서 발생할 수 있는 시나리오를 중심으로 유닛, 라이브, 통합 테스트로 구성됩니다. 각 테스트 코드는 꼭 실행 시켜봐야 하며 실패할 수 있는 케이스도 실행해 보는 것이 좋습니다. 다음 글에선 EventHandler와 Aggregate의 코드를 리뷰해 보겠습니다.
'Backend > SpringBoot' 카테고리의 다른 글
[SpringBoot] Axon을 사용해 CQRS와 이벤트 소싱이 적용된 Order 서비스 만들기 - 4 (0) | 2024.04.24 |
---|---|
[SpringBoot] Axon을 사용해 CQRS와 이벤트 소싱이 적용된 Order 서비스 만들기 - 3 (0) | 2024.04.23 |
[SpringBoot] Axon을 사용해 CQRS와 이벤트 소싱이 적용된 Order 서비스 만들기 - 1 (1) | 2024.04.20 |
[SpringBoot] ColumnDefault와 jpa 생명주기 (0) | 2024.04.18 |
[SpringBoot] ModelMapper Matching Strategy 정리 (0) | 2024.04.08 |