토리맘의 한글라이즈 프로젝트 logo 토리맘의 한글라이즈 프로젝트

프로젝트 리액터 코어 공식 레퍼런스를 한글로 번역한 문서입니다.

전체 목차는 여기에 있습니다.

목차


리액터 연산자든 다른 연산자든 간단한 체인을 만들었다면, 항상 자동화된 테스트를 하는 게 좋다.

리액터 테스트에 필요한 몇 가지 전용 요소들은 테스트를 위한 아티팩트 reactor-test에 모여 있다. 이 프로젝트는 깃허브 reactor-core 레포지토리 내에서 확인할 수 있다.

이걸 사용해서 테스트하려면 test 디펜던시를 추가해야 한다. 다음은 메이븐으로 reactor-test 의존성을 추가하는 예제다:

Example 18. reactor-test in Maven, in <dependencies>

<dependency>
    <groupId>io.projectreactor</groupId>
    <artifactId>reactor-test</artifactId>
    <scope>test</scope>
    <!-- (1) -->
</dependency>

(1) BOM을 사용한다면, <version>은 명시하지 않아도 된다.

다음은 그래들로 reactor-test 의존성을 추가하는 예제다:

Example 19. reactor-test in Gradle, amend the dependencies block

dependencies {
   testCompile 'io.projectreactor:reactor-test'
}

reactor-test는 주로 다음과 같이 활용한다:


6.1. Testing a Scenario with StepVerifier

리액터 시퀀스를 테스트하는 가장 대표적인 케이스는 코드에서 FluxMono를 정의해 놓고 (예를 들어 메소드에서 리턴하는 식으로), 이를 구독하면 어떻게 동작하는지 테스트하는 것이다.

이런 상황은 단계별로 이벤트마다의 기대치를 정의하는 “테스트 시나리오”를 만들기 쉽다. 다음과 같은 질문에 답해보면 된다:

이 모든 것은 StepVerifier API로 표현할 수 있다.

예를 들어 코드에 다음과 같이 Flux를 장식하는 유틸리티 메소드가 있을 수 있다:

public <T> Flux<T> appendBoomError(Flux<T> source) {
  return source.concatWith(Mono.error(new IllegalArgumentException("boom")));
}

이를 테스트하기 위해 다음 시나리오를 검증하려 한다:

Flux에서 첫 번째로 thing1을 방출하고, 그다음 thing2, 그다음은 boom이라는 에러 메세지와 함께 에러를 생산하길 기대한다. 구독한 다음 이 expectation을 검증한다.

이를 StepVerifier API로 옮기면 다음과 같다:

@Test
public void testAppendBoomError() {
  Flux<String> source = Flux.just("thing1", "thing2"); // (1)

  StepVerifier.create( // (2)
    appendBoomError(source)) // (3) 
    .expectNext("thing1") // (4)
    .expectNext("thing2")
    .expectErrorMessage("boom") // (5) 
    .verify(); // (6)
}

(1) 테스트할 메소드는 데이터 소스로 Flux가 필요하기 때문에 테스트용으로 간단히 정의한다.
(2) Flux를 감싸서 검증할 StepVerifier 빌더를 생성한다.
(3) 테스트할 Flux를 넘긴다 (테스트할 유틸리티 메소드를 호출한 결과).
(4) 구독해서 받을 첫 번째 신호는 thing1을 가지고 있는 onNext일 것이라고 기대한다.
(5) 기대하는 마지막 신호는 onError와 함께 시퀀스를 종료하는 것이다. 이 신호는 메세지로 boom을 가지고 있어야 한다.
(6) verify()를 호출해서 테스트를 트리거해야 한다는 것을 잊지 말자.

이 API는 빌더다. 먼저 StepVerifier를 생성해서 테스트할 시퀀스를 넘기는 것으로 시작한다. 그다음엔 다양한 메소드를 선택할 수 있다:

종료 이벤트에 해당하는 expectation 메소드는 (다양한 expectComplete(), expectError() 메소드) 더 이상 expectation을 표현할 수 없는 API로 전환된다. 마지막 단계에서 해야 할 일은 StepVerifier에 설정을 추가하고 verify() 메소드 중 하나로 검증을 트리거하는 것이다.

이 시점에 StepVerifier는 테스트하는 FluxMono를 구독하고 시퀀스를 시작해서, 새 신호를 시나리오 상의 다음 스텝과 비교한다. 모두 일치하면 테스트는 성공한 것으로 간주한다. 일치하지 않는 스텝이 있으면 즉시 AssertionError를 던진다.

검증을 트리거하는 것은 verify() 스텝이라는 것을 기억하라. 편의를 위해 verify()와 마지막 expectation을 하나로 조합할 수 있는 API도 제공한다: verifyComplete(), verifyError(), verifyErrorMessage(String) 등.

람다 기반으로 표현한 expectation 중 하나라도 AssertionError를 던지면 테스트는 실패한다. 커스텀 assertion을 사용한다면 유용할 것이다.

verify() 메소드와 여기서 파생한 편의 메소드는 (verifyThenAssertThat, verifyComplete() 등) 기본적으로 타임아웃이 없다. 이들은 무한정 블로킹하는 메소드다. 전역에 타임아웃을 지정하고 싶으면 StepVerifier.setDefaultTimeout(Duration)을, 호출할 때마다 따로 지정하려면 verify(Duration)을 사용해라.

6.1.1. Better Identifying Test Failures

StepVerifier는 테스트를 실패하게 만든 expectation 스텝을 찾아내기 위한 두 가지 옵션을 제공한다:

두 케이스 모두 AssertionError를 직접 생산하는 StepVerifier 메소드에서만 에러 메세지에 해당 값을 사용한다는 점에 주의하라 (예를 들어 수동으로 예외를 던지거나 assertNext에서 다른 assertion 라이브러리를 사용하면 에러 메세지에 설명 또는 이름을 추가하지 않는다).


6.2. Manipulating Time

StepVerifier를 시간 기반 연산자와 사용하면 오랜 시간이 걸리는 코드를 실제로 기다리지 않고 테스트할 수 있다. 이때는 StepVerifier.withVirtualTime 빌더를 사용한다.

이는 다음과 같이 사용할 수 있다:

StepVerifier.withVirtualTime(() -> Mono.delay(Duration.ofDays(1)))
//... continue expectations here

이 가상 시간 기능은 리액터의 Schedulers 팩토리에 있는 커스텀 Scheduler를 연결한다. 시간 기반 연산자가 디폴트 Schedulers.parallel() 스케줄러를 VirtualTimeScheduler로 변경하기 때문에 가능한 트릭이다. 단, 반드시 가상 시간 스케줄러를 활성화한 뒤에 연산자를 초기화해야 한다.

올바르게 사용할 수 있도록 StepVerifier는 입력으로 단순히 Flux를 받지 않는다. withVirtualTime은 스케줄러를 세팅한 이후에 테스트할 Flux 인스턴스를 뒤늦게 생성할 수 있도록 Supplier를 받는다.

Supplier<Publisher<T>>를 lazy 방식으로 사용할 수 있도록 특별히 주의해야 한다. 그렇지 않으면 가상 시간을 보장할 수 없다. 특히, 테스트 코드에서 먼저 초기화한 FluxSupplier에서 리턴해선 안된다. 대신에 항상 람다 안에서 Flux를 초기화해야 한다.

이를 위한 expectation 메소드는 두 가지가 있으며, 두 가지 모두 가상 시간이 있든 없든 유효하다.

두 메소드 모두 classic 모드에선 주어진 시간 동안 스레드를 중지시키고, virtual 모드에선 대신 가상 시계를 사용한다.

tip

expectNoEventsubscription도 하나의 이벤트로 간주한다. 이 메소드를 첫 번째 스텝에 사용하면 구독 신호가 감지되기 때문에 보통 실패한다. 그 대신 expectSubscription().expectNoEvent(duration)을 사용해라.

Mono.delay 동작을 빠르게 검증해보려면 아래처럼 코드를 작성하면 된다:

StepVerifier.withVirtualTime(() -> Mono.delay(Duration.ofDays(1)))
    .expectSubscription() // (1)
    .expectNoEvent(Duration.ofDays(1)) // (2)
    .expectNext(0L) // (3)
    .verifyComplete(); // (4)

(1) 이전 을 참고하라.
(2) 하루 동안 아무 일도 일어나지 않기를 기대한다.
(3) 지연 이후 0을 방출하길 기대한다.
(4) 그다음 완료되길 기대한다 (그리고 검증을 트리거한다).

위에선 thenAwait(Duration.ofDays(1))을 사용해도 되지만, expectNoEvent를 사용함으로써 그전까지 아무 일도 일어나지 않음을 보장할 수 있다.

verify()Duration을 반환한다는 것도 기억해 두면 좋다. 이 값은 전체 테스트를 하는 동안 실제로 걸린 시간을 나타낸다.

가상 시간이 만능 해결책은 아니다. 모든 Scheduler를 동일한 VirtualTimeScheduler로 대체하기 때문에, 어떨 때는 가상 시계가 expectation을 만나기 전까지 동작하지 않아서 검증 프로세스가 멈출 수도 있다. 결과적으로 expectation은 시간이 지나야 생성할 수 있는 데이터를 기다리고 있다. 일반적으로는 시퀀스를 방출하려면 가상 시계를 동작시켜야 한다. 가상 시간은 무한 시퀀스를 사용하면 매우 제한적이기 때문에, 시퀀스와 검증을 실행할 스레드를 모두 독차지해 버릴 수도 있다.


6.3. Performing Post-execution Assertions with StepVerifier

원한다면 시나리오 상의 마지막 expectation 다음에 verify()를 트리거하는 대신 다른 assertion API로 전환할 수 있다. 이렇게 하려면 verifyThenAssertThat()을 사용해라.

verifyThenAssertThat()StepVerifier.Assertions 객체를 반환한다. 전체 시나리오를 성공적으로 끝낸 이후 이 객체로 몇 가지 상태 요소를 검증할 수 있다 (verify()도 호출하기 때문). 전형적으로는 (고급 방식이긴 하지만) 연산자에서 드랍한 요소를 수집해서 검증하는 식으로 활용한다 (Hooks 참고).


6.4. Testing the Context

Context에 관한 자세한 정보는 Adding a Context to a Reactive Sequence를 참고하라.

StepVerifierContext를 전파하는 두 가지 expectation이 있다:

추가로 verifier를 만들 때 StepVerifierOptions를 사용하면 StepVerifier에 테스트 환경에서 필요한 초기 Context를 주입할 수 있다.

아래 코드로 확인해 보자:

StepVerifier.create(Mono.just(1).map(i -> i + 10),
				StepVerifierOptions.create().withInitialContext(Context.of("thing1", "thing2"))) // (1)
		            .expectAccessibleContext() // (2)
		            .contains("foo", "bar") // (3)
		            .then() // (4)
		            .expectNext(11)
		            .verifyComplete(); // (5)

(1) StepVerifierOptions를 사용해 StepVerifier를 만들고 초기 Context를 넘긴다.
(2) Context 전파 관련 expectation을 설정하는 것으로 시작한다. 이것만으로 Context가 전파된 것을 검증할 수 있다.
(3) Context를 위한 expectation의 한 가지 예시이다. “thing1”이라는 키에는 “thing2”라는 값이 있어야 한다.
(4) then()으로 데이터를 검증하는 일반 expectation으로 되돌아간다.
(5) 전체 expectation을 verify()하는 것을 잊지 마라.


6.5. Manually Emitting with TestPublisher

한 단계 더 넘어가서, 데이터 소스를 완전히 통제해서, 직접 테스트하고 싶은 상황과 거의 근접한 신호를 트리거해야 할 때도 있다.

아니면 연산자를 직접 구현해서 리액티브 스트림 스펙을 잘 따르는지, 특히 데이터 소스가 잘 동작하지 않는 상황을 검증해보고 싶을 수도 있다.

이런 경우를 위해 reactor-testTestPublisher 클래스를 제공한다. 이 클래스는 다양한 신호를 프로그래밍 방식으로 트리거할 수 있는 Publisher<T>다:

잘 동작하는 TestPublishercreate 팩토리 메소드로 얻을 수 있다. 또한, 제대로 동작하지 않는 TestPublishercreateNonCompliant 팩토리 메소드로 생성할 수 있다. 후자는 TestPublisher.Violation 열거형 값을 한 개 이상 받는다. 이 값으로 publisher가 따르지 않을 스펙을 정의한다. 사용할 수 있는 열거형 값은 다음과 같다:

마지막으로 TestPublisher는 구독 이후 내부 상태 값을 가지고 있으며, 다양한 assert* 메소드로 이를 검증할 수 있다.

flux(), mono() 메소드를 사용하면 FluxMono로 전환할 수 있다.


6.6. Checking the Execution Path with PublisherProbe

복잡한 연산자 체인을 만들다 보면, 실행 경로가 여러 가지 별도 하위 시퀀스로 나눠지는 상황을 만나게 된다.

하위 시퀀스의 onNext 신호는 대부분 마지막 결과를 보면 해당 코드가 실행된 것을 알 수 있다.

예를 들어 다음과 같이 데이터 소스로부터 연산자 체인을 형성하고, 소스가 비었다면 switchIfEmpty로 fallback하는 메소드를 생각해 보자:

public Flux<String> processOrFallback(Mono<String> source, Publisher<String> fallback) {
    return source
            .flatMapMany(phrase -> Flux.fromArray(phrase.split("\\s+")))
            .switchIfEmpty(fallback);
}

상황별로 switchIfEmpty가 사용되었는지는 다음과 같이 테스트할 수 있다:

@Test
public void testSplitPathIsUsed() {
    StepVerifier.create(processOrFallback(Mono.just("just a  phrase with    tabs!"),
            Mono.just("EMPTY_PHRASE")))
                .expectNext("just", "a", "phrase", "with", "tabs!")
                .verifyComplete();
}

@Test
public void testEmptyPathIsUsed() {
    StepVerifier.create(processOrFallback(Mono.empty(), Mono.just("EMPTY_PHRASE")))
                .expectNext("EMPTY_PHRASE")
                .verifyComplete();
}

반면에 메소드에서 Mono<Void>를 생산하는 경우를 생각해 보자. 이는 소스가 완료되길 기다렸다가 추가 작업을 수행한 뒤 완료한다. 소스가 비어있다면 대신에 Runnable 같은 fallback 태스크를 실행해야 한다. 다음은 그 예시이다:

private Mono<String> executeCommand(String command) {
    return Mono.just(command + " DONE");
}

public Mono<Void> processOrFallback(Mono<String> commandSource, Mono<Void> doWhenEmpty) {
    return commandSource
            .flatMap(command -> executeCommand(command).then()) // (1)
            .switchIfEmpty(doWhenEmpty); // (2)
}

(1) then()은 command 결과값을 잊어버린다. 오직 성공했는지만 생각한다.
(2) 두 케이스 다 빈 시퀀스라면 어떻게 구분해야 할까?

processOrFallback 메소드가 실제로 doWhenEmpty 경로를 실행했는지를 검증하려면, 약간의 보일러플레이트가 필요하다. 다시 말해 아래 요구사항을 만족하는 Mono<Void>가 필요하다:

3.1 버전 이전에서는 검증하고 싶은 상태마다 수동으로 AtomicBoolean을 하나씩 유지하고, 검증하려는 publisher에 적절한 doOn* 콜백을 붙였어야 했다. 이 패턴을 자주 쓰다 보면 보일러플레이트가 많아진다. 다행히도 3.1.0 버전에서 PublisherProbe라는 것이 등장했다. 다음은 사용 예시이다:

@Test
public void testCommandEmptyPathIsUsed() {
    PublisherProbe<Void> probe = PublisherProbe.empty(); // (1)

    StepVerifier.create(processOrFallback(Mono.empty(), probe.mono())) // (2)
                .verifyComplete();

    probe.assertWasSubscribed(); // (3)
    probe.assertWasRequested(); // (4)
    probe.assertWasNotCancelled(); // (5)
}

(1) 빈 시퀀스로 전환되는 프로브를 생성한다.
(2) probe.mono()를 호출해서 Mono<Void> 자리에 프로브를 사용한다.
(3) 시퀀스를 완료하고 나서 프로브로 사용 여부를 검증할 수 있다. 이 시퀀스가 구독되었는지를 확인할 수 있으며…
(4) …실제로 데이터를 요청했는지와…
(5) …취소 여부도 검증할 수 있다.

.mono() 대신 .flux()를 호출하면 Flux<T> 자리에도 프로브를 사용할 수 있다. 실행 경로 외에 방출한 데이터도 확인해야 한다면, 어떤 Publisher<T>든지 PublisherProbe.of(Publisher)로 감싸면 된다.

Testing수정 제안하기


전체 목차는 여기에 있습니다.

<< >>