# 3, 4장 - 스프링 부트 개발자 도구 & 테스트

김정욱·2021년 10월 21일
0
post-thumbnail

해당 글은 스프링 부트 실전 활용 마스터 도서를 읽으며 정리한 내용입니다

3장 : 스프링 부트 개발자 도구

[ 리액터 플로우 디버깅 ]

[ 개요 ]

  • 리액터 플로우 중 발생한 오류를 확인하기 위해서는 보통 스택 트레이스(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);
    }
}

4장 : 스프링 부트 테스트

[ 단위 테스트 ]

[ 개요 ]

  • 단위 테스트는 테스트 중 가장 단순하고 빠르며 쉬운 테스트
  • 단위는 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\""));
                });
    }
}

  
profile
Developer & PhotoGrapher

0개의 댓글