리액티브 어플리케이션 테스트 하기

49057 단어 JavaJava

리액티브 스트림을 테스트하기 어려운 이유


  • 테스트 피라미드 제안을 따라야 모든 것을 제대로 검증할 수 있다.
    • 테스트 피라미드?
      • Google Test Automation Conference 에서 제안된 테스트 피라미드
      • 전체 테스트 비중을 아래와 같은 수치로 구현하는 것이 권장됨
        1. End-To-End Testing (UI Testing) - 10%
        2. Integrating Testing - 20%
        3. Unit Testing - 70%
  • 그런데 리액티브 프로그래밍 기법으로 작성한 코드는 테스트 하기가 쉽지가 않다.
    • 우선 코드가 비동기식이라 반환된 값이 올바른지 확인하는 간단한 방법이 없다.
    • 다행히 이에 대한 솔루션을 제공하고 있다.

StepVerifier를 이용한 리액티브 스트림 테스트


  • 테스트 목적으로 리액터는 StepVerifier가 포함된 reactor-test 모듈을 제공한다.
    testImplementation 'io.projectreactor:reactor-test'
  • StepVerifier 가 제공하는 연쇄형 API를 사용하면 어떤 종류의 Publisher라도 스트림 검증을 위한 프로우를 만들 수 있다.
  • **StepVerifier 의 핵심 요소**
    • Publisher를 검증하는 기본적인 방법
      • create() 메소드가 중요하다.
        StepVerifier
          .create(Flux.just("A", "B"))    // 플로 생성
          .expectSubscription()
          .expectNext("A")
          .expectNext("B")
          .expectComplete()   // 종료 시그널 존재 여부 검증 (ex, onComplete 등.. )
          .verify();  // 검증을 실행하려면 (즉, 다른 말로 플로 생성을 구독하기 위해서) // 블로킹 호출 -> 실행 차단
    • 엄청난 규모의 스트림 검증하기
      • 위 방법 대로 하면 간단하지만 엄청난 규모의 스트림을 검증하는 것은 매우 어려울 것이다.
      • 특정 값이 아닌 특정 양의 원소를 생성했는지 양의 개수를 검증하는 방법이 있다. → expectNextCount()
        • 하지만 count를 검증하는 것 만으로는 충분하지 않은 경우가 많다.
        • 그리서 필터링 규칙과 일치하는지 확인하는 방법을 사용해도 된다.
          StepVerifier
            .create(users)
            .expectSubscription()
            .recordWith(ArrayList::new) // 기록이 저장될 컬렉션 클래스를 정의 // recordWith 를 먼저 사용해야만 consumeNextWith가 작동할 수 있다. 
            .expectNextCount(1)
            .consumeNextWith(   // 지정된 Publisher가 게시한 모든 원소를 검증할 수 있다.
                user -> assertThat(
                    user,
                    everyItem(hasProperty("name", equalTo("jongin")))
                )
            )
            .expectComplete()
            .verify();
          • 다만 주의해야할 점은 멀티 스레드 Publisher의 경우에는 이벤트를 기록할 때 사용하는 컬렉션이 동시 액세스를 지원해야 하므로 ArrayList 대신 ConcurrentLinkedQueue 를 사용하는게 더 스레드 세이프 하다. (동시성 이슈 방지)
    • 조금더 유연하게 검증하기
      • expectNextMatches() 메소드를 사용해 matcher를 사용자가 직접 정의해 더 유연하게 테스트 코드를 작성할 수 도 있다.
      • assertNext() 도 마찬가지로 사용자가 직접 assertion을 직접 작성할 수 있다.
      • expectNextMatches()assertNext() 의 차이점은 전자는 참 또는 거짓을 반환해야 하는 조건이라면, 후자는 예외를 발생시키는 Consumer를 허용하고, 해당 Consumer에서 발생한 모든 AssertionError는 verify() 메소드에 의해 캡쳐되어 다시 예외를 발생시킨다.
    • 오류에 대한 검증
      • .expectError()를 사용하기
        StepVerifier
          .create(Flux.just("A", "B"))
          .expectSubscription()
          .expectError(RuntimeException.class)  // Error 타입 까지 테스트 가능
          .verify();

StepVerifier를 이용한 고급 테스트


  • Publisher를 테스트 할 때 가장 중요한 것.

    1. 그것이 무한한지 확인한다.
    2. 배압을 확인한다.
  • 무한한 스트림 테스트

    • onComplete() 메서드를 호출하지 않는다는 것을 의미한다.
    • 위에서 배웠던 테스트 기법을 더이상 사용하지 못함
    • 문제는 StepVerifier가 완료신호를 무한정 기다릴 것이라는데 있다.
    • 해결책
      • 소스에서 구독을 취소해버린다. .thenCanel()
  • 배압을 확인한다.

    • 가장 단순한 방법은 Flux의 .onBackpressureBuffer() 메소드를 통해서 다운스트림을 보호하는 것
    • 위 배압 전략으로 시스템이 동작하는지 보려면 → 구독자의 요청 수량을 직접 제어 해야함.
      • 이때 사용하는 메소드는 .thenRequest() 이다.
        StepVerifier
          .create(websocketPublisher.onBackpressureBuffer(5), 0)  // 5 : 최대 개수 (다운 스트림 보호), 0 : 초기 구독 요청 개수 
          .expectSubscription()
          .thenRequest(1) // 1개 요청
          .expectNext("Connected")
          .thenRequest(1) // 1개 요청
          .expectNext("Price : 12.00")
          .expectError(RuntimeException.class)
          .verify();
  • 검증 후에 추가 작업을 실행 할 수 있는 기능이 필요할 때?
    • ex) 프로세스를 생성하는 원소가 추가적인 외부 상호 작용을 필요로 하는 경우 → .then() 메소드 사용
      StepVerifier
        .create(userRepository.findAllById(idsPublisher))
        /**
         * ID들을 스트림에 게시를 하고 기 후에 ID가 userRepository.findAllById(idsPublisher)를
         * 통해 구독이 된 후 userRepository가 예상대로 동작했는지 확인할 수 있다.
         */
        .expectSubscription()
        .then(() -> idsPublisher.next("1"))
        .assertNext(user -> assertThat(user, hasProperty("id", equalTo("1"))))  // 바로 윗 라인 검증
        .then(() -> idsPublisher.next("2"))
        .assertNext(user -> assertThat(user, hasProperty("id", equalTo("2"))))  // 바로 윗 라인 검증
        .then(idsPublisher::complete)
        .expectComplete()  // 바로 윗 라인 검증
        .verify();
      • 이 방법은 구독이 실제로 발생한 이후의 이벤트를 테스트할 수 있어서 매우 의미가 있다.

가상 시간 다루기


  • 코드를 작성하다 보면 오랜 지연시간을 가지는 비즈니스 로직들이 있다

  • 그런데 단순히 위에 처럼 테스트 코드를 작성하다 보면 테스트 하는 시간이 엄청 오래 걸린다

  • 최근 CI/CD 트렌드에 적합하지 않는다.

  • 이 문제를 해결하기 위해 리액터 테스트 모듈은 실제 시간을 가상 시간으로 대체하는 기능을 제공한다.

    StepVerifier.withVirtualTime(() -> sendWithInterval()) // 시나리오 검증 로직 생략
    • withVirtualTime() 메서드를 사용하면 VirtualTimeScheduler 를 통해 리액터의 모든 스케줄러를 명시적으로 대체가 가능하다.
    • 이런 대체 방법은 Flux.interval 이 해당 스케줄러에서 실행됨을 의미한다.
    • 예시
      StepVerifier
          .withVirtualTime(() -> sendWithInterval())
          .expectSubscription()
          .then(
            () -> {
                VirtualTimeScheduler    // VirtualTimeScheduler이 스케줄러를 사용해야함
                        .get()
                        .advanceTimeBy(Duration.ofMinutes(3))   // 3분 후로 이동
            }
          )
          .expectNext("a", "b", "c")
          .expectComplete()
          .verify();
      .then(
        () -> {
            VirtualTimeScheduler    // VirtualTimeScheduler이 스케줄러를 사용해야함
                    .get()
                    .advanceTimeBy(Duration.ofMinutes(3))   // 3분 후로 이동
        }
      )
      이 부분을
      
      .thenAwait(Duration.ofMillis(3))
      
      이렇게 간소화 할 수 있다.
      • 이런 경우 .verify() 메소드는 실제로 검증 프로세스가 실행된 시간을 반환하고, 첫번째 파라미터로는 검증에 소요되는 시간을 제한하는 값을 넣을 수 도 있다.
        • 시간 내에 완료하지 못하면 AssertionError가 발생한다.
  • 주의점

    • StepVerifier 가 시간을 충분히 앞당기지 못한다면 테스트가 영원히 중단될 수 있다.
  • 지정된 대기 시간 동안 이벤트가 없을을 확인하는 것이 중요하다면

    • expectNoEvents() 라는 메서드를 사용하면 된다.

리액티브 컨텍스트 검증하기


  • 리액터 컨덱스트를 검증하는 일도 중요하다.
  • 일단 접근 가능한 Context 인스턴스가 있는지 검증하는게 중요하다. .expectAccessibleContext() 이 메소드로 검증이 가능하다.
  • 그 후 hasKey() 와 같은 메서드로 현재 컨텍스트에 대한 자세한 검증을 해야하고
  • 컨텍스트 검증을 종료하려면 빌더에 .then() 메서드를 추가하면 된다.
    StepVerifier
      .create(securityService.login("admin", "admin"))
      .expectSubscription()
      .expectAccessibleContext()
      .hasKey("security")
      .then()
      .expectComplete()
      .verify()

웹플럭스 테스트


  • 이제 부터는 단위 테스트가 아니라 컴포넌트 테스트 혹은 통합 테스트가 된다.

WebTestClient를 이용해 컨트롤러 테스트 하기


  • 구현은 다음과 같다.

  • 결제에 대한 컨트롤러가 아래와 같이 있다고 가정하고

    @RestController
    @RequestMapping("/payments")
    public class PaymentController {
        private final PaymentService paymentService;
    
        public PaymentService getPaymentService(PaymentService paymentService) {
            this.paymentService = paymentService;
        }
    
        @GetMapping("/")
        public Flux<Payment> list() {
            return paymentService.list();
        }
    
        @PostMapping("/")
        public Mono<String> send(Mono<Payment> payment) {
            return paymentService.send(payment);
        }
    }
    • 검증의 첫번째 단계는 서비스의 결과로 웹 엔드포인트에서 발생하는 모든 기댓값을 다 작성하는 것
    • spring-test 모듈에는 웹플럭스 엔드포인트와의 상호 작용을 위한 WebTestClient 클래스가 추가됨
      • 우리가 자주 쓰는 MockMvc와 유사함
  • 그럼 다음은 검증하는 코드이다.

    PaymentService paymentService = Mockito.mock(PaymentService.class);
    PaymentController paymentController = new PaymentController(paymentService);
    
    prepareMockResponse(paymentService);
    
    WebTestClient
      .bindToController(paymentController)
      .build()
      .get()
      .uri("/payments/")
      .exchange()
      .expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)
      .expectStatus().is2xxSuccessful()
      .returnResult(Payment.class)
      .getResponseBody()  // 여기서 부터 Flux 가 생겼으니
      .as(StepVerifier::create)   // 이전에 StepVerifier로 검증한게 똑같다
      .expectNextCount(5)
      .expectComplete()
      .verify()
    • 헤더 및 상태를 검증했고 getResponseBody()를 통해 Flux를 얻어 StepVerifier로 검증이 가능하다
    • paymentService 를 목킹했고, paymentController를 테스트할 때 실제로 외부 서비와 통신하지 않는다.
  • 그러나 시스템 무결성을 확인하려면 컨트롤러 레이어만이 아니라 전체 컴포넌트를 실행해봐야 한다.

    • 또한 이러한 통합 테스트를 실행하려면 전체 어플리케이션을 시작해야한다.
    • 즉 서비스 로직도 테스트 해봐야한다는 뜻 (외부 통신도 테스트 해봐야한다)
  • 그래서 이러한 용도로 @AutoConfigureWebTestClient@SpringBootTest 를 사용해야한다.

  • 결제 서비스의 PaymentService 비즈니스 로직을 살펴보고 테스트 해보자

    서비스 로직

    @Service
    public class DefaultPaymentService implements PaymentService {
    
        private final PaymentRepository paymentRepository;
        private final WebClient webClient;
    
        public PaymentRepository getPaymentRepository(
                PaymentRepository paymentRepository,
                WebClient webClient
        ) {
            this.paymentRepository = paymentRepository;
            this.webClient = webClient;
        }
    
        @Override
        public Mono<String> send(Mono<Payment> payment) {
            return payment
                    .zipWith(
                        ReactiveSecurityContextHolder.getContext(),
                        (p, c) -> p.withUser(c.getAuthentication().getName())
                    )
                    .flatMap(
                        p -> webClient
                            .post()
                            .syncBody(p)
                            .retrieve()
                            .bodyToMono(String.class)
                            .then(paymentRepository.save(p))
                    )
                    .map(Payment::getId);
        }
        
        @Override
        public Flux<Payment> list() {
            return ReactiveSecurityContextHolder
                    .getConext()
                    .map(SecurityContext::getAuthentication)
                    .map(Principal::getName)
                    .flatMapMany(paymentRepository::findAllByUser);
        }
    }
    • 중요한건 리스트를 가져오는 메소드는 DB와만 상호작용한다. 다만 결제를 처리하는 로직은 DB 뿐 아니라 WebClient를 통해 외부 시스템과의 통신이 필요하다.
      • DB는 테스트를 위한 임베디드 모드를 지원하는 리액티브 스프링 데이터 MongoDB 모듈을 사용해서 괜찮은데
      • 외부 연동은 WireMock과 같은 도구로 외부 서비스를 모킹하거나 발신 HTTP 요청을 모킹해야한다.
        • WireMock를 이용한 목 서비스는 WebMVC와 웹플럭스 모두에서 사용가능하다.
  • HTTP를 이용한 외부 호출에 대한 응답을 모킹해보자

    • 개발자가 WebClient.Builder를 통해 외부요청 코드를 작성했다면 요청 처리에 필수적인 역할을 하는 ExchangeFunction을 모킹하면 된다.
      public interface ExchangeFunction {
          Mono<ClientRequest> exchange(ClientRequest request);
      		...
      }
      • 다음 코드와 같은 테스트 설정을 사용하면 WebClient.Builder를 커스터마이즈할 수 있을 뿐 아니라 목을 만들거나 ExchangeFunction에 대한 스텁 객체를 만들 수도 있다.
        - 종종 헷갈리는 Stub과 Mock의 차이?

            ```java
            Mock
            - 가짜
            - 실제와와 동일한 기능을 하진 않지만 대략 이렇게 생겼고 크기는 대충 이렇다, 대충 이런 기능이
            이렇게 동작할 것이라고 알려주는 용도
            - 테스트에서는 호출시 동작이 잘 되었는지를 확인하는데 쓰인다.
            
            Stub
            - 전체 중 일부라는 뜻
            - 모든 기능 대신 일부 기능에 집중해 임의로 구현한다.
            - 일부 기능이라 하면 내가 지금 테스트하고자 하는 기능을 의미
            
            Stub 기반의 코드는 상태기반 테스트
            Mock 기반의 테스트는 행위 기반 테스트
            ```
            
        @TestConfiguration
        public class TestWebClientBuilderConfiguration {
            @Bean
            public WebClientCustomizer testWebClientCustomizer(
                    ExchangeFunction exchangeFunction
            ) {
                return webClientBuilder -> webClientBuilder.exchangeFunction(exchangeFunction);
            }
        }
      • 이렇게 하면 ClientRequest의 정확성을 검증할 수 있게 된다.

      • 아울러 ClientResponse를 적절히 구현함으로써 네트워크 활동 및 외부 서비스와 상호 작용을 테스트 할 수 있다.

      • 코드는 다음과 같다.

        @ImportAutoConfiguration({
                TestWebClientBuilderConfiguration.class
        })
        @RunWuth(SpringRunner.class)
        @WebFluxTest
        @AutoConfigureWebTestClient
        public class PaymentControllerTests {
        
            @Autowired
            WebTestClient client;
        
            @MockBean
            ExchangeFunction exchangeFunction;  // 모킹
        
            @Test
            @WithMockUser
            public void verifyPaymentsWasSendAndStored() {
                // stub
                Mockito
                    .when(exchangeFunction.exchange(Mockito.any()))
                    .thenReturn(Mono.just(MockClientResponse.create(201, Mono.empty())));
        
                client.post()
                        .uri("/payments/")
                        .syncBody(new Payment())
                        .exchange() // stub 동작
                        .expectStatus().is2xxSuccessful()
                        .returnResult(String.class)
                        .getResponseBody()
                        .as(StepVerifier::create) // 여기서 부터는 원래 했던 테스트
                        .expectNextCount(1)
                        .expectComplete()
                        .verify();
                
                Mockito.verify(exchangeFunction).exchange(Mockito.any());
            }
        }
      • 위 코드의 한계 WebClient를 통해서 외부연동한다는 전제하에 테스트 코드를 작성한것 Http Client를 바꾸면 테스트 코드가 동작 안할 수 있음

        • 그러므로 WireMock 같은 모듈을 사용해 외부 서비스를 모킹하는 것이 바람직하다고 함
      • 이 방식은 실제 Http 클라리언트로 통신을 시도하고 요청-응답 값들을 테스트할 수 있다.

실전! 스프링 5를 활용한 리액티브 프로그래밍 책을 보고 정리한 내용입니다.

좋은 웹페이지 즐겨찾기