리액티브 스트림을 테스트하기 어려운 이유
- 테스트 피라미드 제안을 따라야 모든 것을 제대로 검증할 수 있다.
- 그런데 리액티브 프로그래밍 기법으로 작성한 코드는 테스트 하기가 쉽지가 않다.
- 우선 코드가 비동기식이라 반환된 값이 올바른지 확인하는 간단한 방법이 없다.
- 다행히 이에 대한 솔루션을 제공하고 있다.
StepVerifier를 이용한 리액티브 스트림 테스트
테스트 목적으로 리액터는
StepVerifier
가 포함된 reactor-test 모듈을 제공한다.1
testImplementation 'io.projectreactor:reactor-test'
StepVerifier
가 제공하는 연쇄형 API를 사용하면 어떤 종류의 Publisher라도 스트림 검증을 위한 프로우를 만들 수 있다.**StepVerifier
의 핵심 요소**- Publisher를 검증하는 기본적인 방법
create()
메소드가 중요하다.1 2 3 4 5 6 7
StepVerifier .create(Flux.just("A", "B")) // 플로 생성 .expectSubscription() .expectNext("A") .expectNext("B") .expectComplete() // 종료 시그널 존재 여부 검증 (ex, onComplete 등.. ) .verify(); // 검증을 실행하려면 (즉, 다른 말로 플로 생성을 구독하기 위해서) // 블로킹 호출 -> 실행 차단
- 엄청난 규모의 스트림 검증하기
- 위 방법 대로 하면 간단하지만 엄청난 규모의 스트림을 검증하는 것은 매우 어려울 것이다.
- 특정 값이 아닌 특정 양의 원소를 생성했는지 양의 개수를 검증하는 방법이 있다. →
expectNextCount()
- 하지만 count를 검증하는 것 만으로는 충분하지 않은 경우가 많다.
그리서 필터링 규칙과 일치하는지 확인하는 방법을 사용해도 된다.
1 2 3 4 5 6 7 8 9 10 11 12 13
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
를 사용하는게 더 스레드 세이프 하다. (동시성 이슈 방지)
- 다만 주의해야할 점은 멀티 스레드 Publisher의 경우에는 이벤트를 기록할 때 사용하는 컬렉션이 동시 액세스를 지원해야 하므로
- 조금더 유연하게 검증하기
expectNextMatches()
메소드를 사용해 matcher를 사용자가 직접 정의해 더 유연하게 테스트 코드를 작성할 수 도 있다.assertNext()
도 마찬가지로 사용자가 직접 assertion을 직접 작성할 수 있다.expectNextMatches()
과assertNext()
의 차이점은 전자는 참 또는 거짓을 반환해야 하는 조건이라면, 후자는 예외를 발생시키는 Consumer를 허용하고, 해당 Consumer에서 발생한 모든 AssertionError는 verify() 메소드에 의해 캡쳐되어 다시 예외를 발생시킨다.
- 오류에 대한 검증
.expectError()
를 사용하기1 2 3 4 5
StepVerifier .create(Flux.just("A", "B")) .expectSubscription() .expectError(RuntimeException.class) // Error 타입 까지 테스트 가능 .verify();
- Publisher를 검증하는 기본적인 방법
StepVerifier를 이용한 고급 테스트
- Publisher를 테스트 할 때 가장 중요한 것.
- 그것이 무한한지 확인한다.
- 배압을 확인한다.
- 무한한 스트림 테스트
- onComplete() 메서드를 호출하지 않는다는 것을 의미한다.
- 위에서 배웠던 테스트 기법을 더이상 사용하지 못함
- 문제는 StepVerifier가 완료신호를 무한정 기다릴 것이라는데 있다.
- 해결책
- 소스에서 구독을 취소해버린다.
.thenCanel()
- 소스에서 구독을 취소해버린다.
- 배압을 확인한다.
- 가장 단순한 방법은 Flux의
.onBackpressureBuffer()
메소드를 통해서 다운스트림을 보호하는 것 - 위 배압 전략으로 시스템이 동작하는지 보려면 → 구독자의 요청 수량을 직접 제어 해야함.
이때 사용하는 메소드는
.thenRequest()
이다.1 2 3 4 5 6 7 8 9
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();
- 가장 단순한 방법은 Flux의
- 검증 후에 추가 작업을 실행 할 수 있는 기능이 필요할 때?
ex) 프로세스를 생성하는 원소가 추가적인 외부 상호 작용을 필요로 하는 경우 →
.then()
메소드 사용1 2 3 4 5 6 7 8 9 10 11 12 13 14
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 트렌드에 적합하지 않는다.
이 문제를 해결하기 위해 리액터 테스트 모듈은 실제 시간을 가상 시간으로 대체하는 기능을 제공한다.
1
StepVerifier.withVirtualTime(() -> sendWithInterval()) // 시나리오 검증 로직 생략
withVirtualTime()
메서드를 사용하면VirtualTimeScheduler
를 통해 리액터의 모든 스케줄러를 명시적으로 대체가 가능하다.- 이런 대체 방법은 Flux.interval 이 해당 스케줄러에서 실행됨을 의미한다.
예시
1 2 3 4 5 6 7 8 9 10 11 12 13
StepVerifier .withVirtualTime(() -> sendWithInterval()) .expectSubscription() .then( () -> { VirtualTimeScheduler // VirtualTimeScheduler이 스케줄러를 사용해야함 .get() .advanceTimeBy(Duration.ofMinutes(3)) // 3분 후로 이동 } ) .expectNext("a", "b", "c") .expectComplete() .verify();
1 2 3 4 5 6 7 8 9 10 11 12
.then( () -> { VirtualTimeScheduler // VirtualTimeScheduler이 스케줄러를 사용해야함 .get() .advanceTimeBy(Duration.ofMinutes(3)) // 3분 후로 이동 } ) 이 부분을 .thenAwait(Duration.ofMillis(3)) 이렇게 간소화 할 수 있다.
- 이런 경우
.verify()
메소드는 실제로 검증 프로세스가 실행된 시간을 반환하고, 첫번째 파라미터로는 검증에 소요되는 시간을 제한하는 값을 넣을 수 도 있다.- 시간 내에 완료하지 못하면 AssertionError가 발생한다.
- 이런 경우
- 주의점
StepVerifier
가 시간을 충분히 앞당기지 못한다면 테스트가 영원히 중단될 수 있다.
- 지정된 대기 시간 동안 이벤트가 없을을 확인하는 것이 중요하다면
expectNoEvents()
라는 메서드를 사용하면 된다.
리액티브 컨텍스트 검증하기
- 리액터 컨덱스트를 검증하는 일도 중요하다.
- 일단 접근 가능한 Context 인스턴스가 있는지 검증하는게 중요하다.
.expectAccessibleContext()
이 메소드로 검증이 가능하다. - 그 후
hasKey()
와 같은 메서드로 현재 컨텍스트에 대한 자세한 검증을 해야하고 컨텍스트 검증을 종료하려면 빌더에
.then()
메서드를 추가하면 된다.1 2 3 4 5 6 7 8
StepVerifier .create(securityService.login("admin", "admin")) .expectSubscription() .expectAccessibleContext() .hasKey("security") .then() .expectComplete() .verify()
웹플럭스 테스트
- 이제 부터는 단위 테스트가 아니라 컴포넌트 테스트 혹은 통합 테스트가 된다.
WebTestClient를 이용해 컨트롤러 테스트 하기
- 구현은 다음과 같다.
결제에 대한 컨트롤러가 아래와 같이 있다고 가정하고
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
@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
와 유사함
- 우리가 자주 쓰는
그럼 다음은 검증하는 코드이다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
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
비즈니스 로직을 살펴보고 테스트 해보자서비스 로직
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
@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와 웹플럭스 모두에서 사용가능하다.
- 중요한건 리스트를 가져오는 메소드는 DB와만 상호작용한다. 다만 결제를 처리하는 로직은 DB 뿐 아니라 WebClient를 통해 외부 시스템과의 통신이 필요하다.
- HTTP를 이용한 외부 호출에 대한 응답을 모킹해보자
개발자가 WebClient.Builder를 통해 외부요청 코드를 작성했다면 요청 처리에 필수적인 역할을 하는
ExchangeFunction
을 모킹하면 된다.1 2 3 4
public interface ExchangeFunction { Mono<ClientRequest> exchange(ClientRequest request); ... }
- 다음 코드와 같은 테스트 설정을 사용하면
WebClient.Builder
를 커스터마이즈할 수 있을 뿐 아니라 목을 만들거나 ExchangeFunction에 대한 스텁 객체를 만들 수도 있다.종종 헷갈리는 Stub과 Mock의 차이?
1 2 3 4 5 6 7 8 9 10 11 12 13
Mock - 가짜 - 실제와와 동일한 기능을 하진 않지만 대략 이렇게 생겼고 크기는 대충 이렇다, 대충 이런 기능이 이렇게 동작할 것이라고 알려주는 용도 - 테스트에서는 호출시 동작이 잘 되었는지를 확인하는데 쓰인다. Stub - 전체 중 일부라는 뜻 - 모든 기능 대신 일부 기능에 집중해 임의로 구현한다. - 일부 기능이라 하면 내가 지금 테스트하고자 하는 기능을 의미 Stub 기반의 코드는 상태기반 테스트 Mock 기반의 테스트는 행위 기반 테스트
1 2 3 4 5 6 7 8 9
@TestConfiguration public class TestWebClientBuilderConfiguration { @Bean public WebClientCustomizer testWebClientCustomizer( ExchangeFunction exchangeFunction ) { return webClientBuilder -> webClientBuilder.exchangeFunction(exchangeFunction); } }
- 이렇게 하면 ClientRequest의 정확성을 검증할 수 있게 된다.
- 아울러 ClientResponse를 적절히 구현함으로써 네트워크 활동 및 외부 서비스와 상호 작용을 테스트 할 수 있다.
- 코드는 다음과 같다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
@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를 활용한 리액티브 프로그래밍 책을 보고 정리한 내용입니다.
Comments powered by Disqus.