해당 글은
스프링 부트 실전 활용 마스터
도서를 읽으며 정리한 내용입니다
[ 개요 ]
- 리액터 플로우 중 발생한 오류를 확인하기 위해서는 보통 스택 트레이스(
StackTrace
)를 확인한다- Java의 스택 트레이스는
동일한 스레드 내에서만
이어지는 한계가 존재
=>subscribeOn()
/publishOn()
을 통한 별도의 스레드 작업의 경우 추적하지 못함
=>Hooks.onOperatorDebug()
로 해결 가능!
[ 해결책 ]
Hooks.onOperatorDebug()
- main에서 호출하면
오류 관련 핵심 정보
를스레드 경계를 넘어서 전달
할 수 있다- 리액터가
처리 흐름 조립 시점
에서의호출부 세부 정보
를수집
&구독
해서 실행 시점에 세부정보를 넘겨줌- 주의할 점
스레드 경계를 넘어서 전달하는 과정
에는많은 비용
이 들게 된다- 즉,
운영(prod) 환경
에서는잘 고려하고 사용
해야 한다@SpringBootApplication public class ReactorExample { public static void main(String[] args) { Mono<Integer> source; /* 백 트레이싱을 활성화 * => Java의 스택트레이스는 동일한 스레드에서만 유지되는 한계가 존재 * => 다른 스레드를 넘어서 스택트레이스를 유지해서 오류를 확인 가능하게 해줌 * => 다른 스레드에서 실행하지만, 오류가 발생한 지점을 찾을 수 있다 */ Hooks.onOperatorDebug(); } }
log()
log()
operator
- 리액터 플로우
단계별로 로그
를 남길 수 있다- 로깅 라이브러리는
c.g.h.r.ItemService
처럼 패키지 경로를 축약해서 보여주기도 한다- log()에
인자로 남긴 내용
과Reactive Streams의 시그널
이 모두 로그에 출력
onSubscribe
/onNext
/onComplete
등
- 적용
public Mono<Cart> addToCart(String cartId, String id){ return cartRepository.findById(cartId) .log("foundCart") .defaultIfEmpty(Cart.builder().id(cartId).build()) .log("emptyCart") .flatMap(cart -> cart.getCartItems().stream() .filter(cartItem -> cartItem.getItem() .getId().equals(id)) .findAny() .map(cartItem -> { cartItem.increment(); return Mono.just(cart).log("newCartItem"); }) .orElseGet(() -> itemRepository.findById(id) .log("fetchedItem") .map(CartItem::new) .log("cartItem") .doOnNext(cartItem -> cart.getCartItems().add(cartItem)) .map(cartItem -> cart).log("addedCartItem") )) .log("cartWithAnotherItem") .flatMap(cartRepository::save) .log("savedCart"); }
- 개요
- 리액티브 프로그래밍은
하나의 블록킹(Blocking) 코드
만 있어도리액티브 플로우가 깨지고
,성능이 급격히 나빠진다
- 개념
블록하운드
는 이러한 블록킹 코드를 검출하는 도구이다
- 검출 범위
개발자가 직접 작성한 코드
서드파티 라이브러리에 사용된 블로킹 메소드 호출
JDK 자체에서 호출되는 블로킹 코드
- 등
- 적용
@SpringBootApplication public class HackingWithSpringBootCh2ReactiveDataApplication { public static void main(String[] args) { /* BlockHound를 통한 블로킹 코드 검출 * => 아래의 main app 시작 전에 설치해야 바이트 코드를 조작할 수 있음 */ /* thymeleaf 템플릿을 블로킹 방식으로 읽어서 readBytes() 사용시 오류가 나는 것이다 * => 명시적으로 해당 부분을 허락하는 코드를 추가하면 오류를 해결할 수 있다 * => readBytes()을 쓰는 모든 부분을 허용하는 것이 아니라, 템플릿 엔진 관련 부분만 허용하는 것이 안전 */ BlockHound.builder() .allowBlockingCallsInside( TemplateEngine.class.getCanonicalName(), "process" ) .install(); SpringApplication.run(HackingWithSpringBootCh2ReactiveDataApplication.class, args); } }
[ 개요 ]
단위 테스트
는 테스트 중 가장 단순하고 빠르며 쉬운 테스트- 단위는
Java
에서하나의 클래스
라고 볼 수 있다- 테스트 대상 클래스가 의존하는
다른 협력 클래스
는가짜 인스턴스
인스텁(Stub)
을 사용
=> 오직테스트 대상 클래스
에집중
하기 위해- Java 진영에서는
JUnit
과단언(assertion) 라이브러리
를 사용
spring-boot-starter-test
(테스트 관련 스프링 부트 스타터)
- 다양한 테스트 라이브러리 포함
- Spring Boot Test
- JUnit 5
- AssertJ
- Mockito
- Spring Test
- 등
[ domain test ]
도메인 계층
은 다른 계층에 대한 의존 관계가 없어야 한다
=> 그래서 다른 계층보다 테스트하기 쉽다/* domain 테스트 - Unit Test */ class ItemTest { @Test void itemBasicsShouldWork(){ Item sampleItem = Item.builder().id("item1").name("TV tray").description("Alf TV tray").price(19.99).build(); assertThat(sampleItem.getId()).isEqualTo("item1"); assertThat(sampleItem.getName()).isEqualTo("TV tray"); assertThat(sampleItem.getDescription()).isEqualTo("Alf TV tray"); assertThat(sampleItem.getPrice()).isEqualTo(19.99); Item sampleItem2 = Item.builder().id("item1").name("TV tray").description("Alf TV tray").price(19.99).build(); assertThat(sampleItem).isEqualTo(sampleItem2); } }
[ service test ]
Service
는비즈니스 로직
을 포함하며 외부 컬렉션과도 상호작용한다- 서비스의 핵심을 제외한 서비스 외의 것들은 모두
협력자
라는 이름을 붙여서목 객체
를 만들거나스텁(Stub)
으로테스트 대상에서 제외
=>Repository
도 테스트 대상이 아니기 때문에 제외StepVerifier
를 통해서테스트를 진행
하는 것이 핵심
- 기존
스프링MVC
와 다르게리액티브 플로우
에서 테스트는Reactive Streams
의 시그널도 함께 검사
- onSubscribe()
- onNext()
- onError()
- onComplete()
- 결과적으로 onNext() 와 onComplete()가 모두 발생하면 성공 경로(success path)라고 부름
/* Service Test - Unit Test */ // Spring에 특화된 테스트 기능 수행을 위한 '테스트 핸들러'지정 @ExtendWith(SpringExtension.class) @Slf4j class CartServiceTest { CartService cartService; // 테스트 대상이 아니라 필요한 객체인 협력자는 '가짜 객체'로 선언하고 @MockBean을 통해 스프링 빈으로 등록 // 모키토(Mockito)를 사용해서 가짜 객체를 만들고 스프링 컨테이너에 빈으로 추가 @MockBean private ItemRepository itemRepository; @MockBean private CartRepository cartRepository; @BeforeEach void setUp(){ // 테스트 데이터 정의 Item sampleItem = Item.builder().id("item1").name("TV tray").description("Alf TV tray").price(19.99).build(); CartItem sampleCartItem = new CartItem(sampleItem); Cart sampleCart = Cart.builder().id("My Cart").cartItems(Collections.singletonList(sampleCartItem)).build(); // 협력자와의 상호작용 정의 when(cartRepository.findById(anyString())).thenReturn(Mono.empty()); when(itemRepository.findById(anyString())).thenReturn(Mono.just(sampleItem)); when(cartRepository.save(any(Cart.class))).thenReturn(Mono.just(sampleCart)); cartService = new CartService(itemRepository, cartRepository); } // 리액티브 코드의 테스트의 핵심 = 기능검사와 더불어 Reactive Stream 시그널도 검사해야 한다 // onSubscribe, onNext, onError, onComplete // 리액티브 테스트 코드도 결국 구독하기 전까지는 아무런 일이 발생하지 않는다 // => StepVerifier 가 내부 메소드를 통해 구독을 한다 @Test void addItemToCartShouldProduceOneCartItem(){ cartService.addToCart("My Cart", "item1") // StepVerifier(테스트 기능을 전담하는 리액터 타입 핸들러) 생성 // as연산자로 테스트 대상을 변환하는 Top-Level 방식의 테스트 .as(StepVerifier::create) // 결과 검증 .expectNextMatches(cart -> { assertThat(cart.getCartItems()).extracting(CartItem::getQuantity) .containsExactlyInAnyOrder(1); assertThat(cart.getCartItems()).extracting(CartItem::getItem) .containsExactly(Item.builder().id("item1").name("TV tray").description("Alf TV tray").price(19.99).build()); // expectNextMatches는 Boolean을 반환 return true; }) // reactive streams가 complete 시그널을 발생하고 성공적으로 완료됬음을 검증 .verifyComplete(); } }
[ Controller ]
웹 컨트롤러
의 기능을 테스트- 단위 테스트가 아닌,
종단 간 테스트
이기 때문에값비싼 테스트 환경
을 구성
=>내장 컨테이너
를실행
시켜실제 인스턴스 활용
해야 함- 목(
mock
) / 스텁(stub
) 같은 가짜 협력자와 협력할 필요 없이실제 인스턴스를 사용
/* Controller Test - 통합 테스트 */ // 임의의 포트에 내장 컨테이너 생성 @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureWebTestClient // 웹 요청을 날리기 위한, WebTestClient 인스턴스 설정 class HomeControllerTest { @Autowired WebTestClient webTestClient; // 필드 주입 @Test void test(){ webTestClient.get().uri("/").exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.TEXT_HTML) .expectBody(String.class) .consumeWith(exchangeResult -> assertThat(exchangeResult.getResponseBody()) .contains("\"/add") ); } }
- 슬라이스 테스트
단위 테스트
와종단 간 테스트
중간 정도에 해당하는 테스트[ mongoDB ]
@DataMongoTest
를 통해서스프링 데이터 몽고디비
기능을 테스트 할 수 있다
-> 내부에@ExtendWith({SpringExtension.class})
가 포함되어 있다
- 실제 데이터베이스 연산을 사용하지만, 몽고디비 관련 기능을 제외한
@Component
이 붙은빈(Bean)
의 정의를 무시
-> 일반적인종단 간 테스트
보다빠르게 수행
가능// 스프링 데이터 몽고디비 활용에 초점을 둔, 몽고디비 테스트 관련 기능 활성화 // 내부에 ExtendWith({SpringExtension.class}) 를 포함하고 있어서 JUnit5 사용 가능 @DataMongoTest public class MongoDbSliceTest { // '몽고디비 슬라이스 테스트'는 실제 DB를 기반으로 수행 @Autowired ItemRepository itemRepository; @Test void itemRepositorySavesItems(){ Item sampleItem = Item.builder().name("name").description("description").price(1.99).build(); itemRepository.save(sampleItem) .as(StepVerifier::create) .expectNextMatches(item -> { assertThat(item.getId()).isNotNull(); assertThat(item.getName()).isEqualTo("name"); assertThat(item.getDescription()).isEqualTo("description"); assertThat(item.getPrice()).isEqualTo(1.99); return true; }) .verifyComplete(); } }
[ controller ]
Controller
에 초점을 둔슬라이스 테스트
// HomeController에 국한된 스프링 웹플럭스 슬라이스 테스트를 사용하도록 지정 @WebFluxTest(HomeController.class) public class HomeControllerSliceTest { @Autowired WebTestClient webTestClient; // 테스트 대상이 아니므로, 가짜 객체를 이용해서 테스트 대상에 집중하게 함 @MockBean CartService cartService; @Test void homePage(){ when(cartService.getInventory()).thenReturn(Flux.just( Item.builder().id("id1").name("name1").description("description1").price(1.99).build(), Item.builder().id("id2").name("name2").description("description2").price(9.99).build() )); when(cartService.getCart("My Cart")).thenReturn(Mono.just(Cart.builder().id("My Cart").build())); webTestClient.get().uri("/").exchange() .expectStatus().isOk() .expectBody(String.class) .consumeWith(exchangeResult -> { assertThat(exchangeResult.getResponseBody().contains("action=\"/add/id1\"")); assertThat(exchangeResult.getResponseBody().contains("action=\"/add/id2\"")); }); } }