프로젝트 리액터 코어 공식 레퍼런스를 한글로 번역한 문서입니다.
전체 목차는 여기에 있습니다.
목차
- 6.1. Testing a Scenario with
StepVerifier
- 6.2. Manipulating Time
- 6.3. Performing Post-execution Assertions with
StepVerifier
- 6.4. Testing the
Context
- 6.5. Manually Emitting with
TestPublisher
- 6.6. Checking the Execution Path with
PublisherProbe
리액터 연산자든 다른 연산자든 간단한 체인을 만들었다면, 항상 자동화된 테스트를 하는 게 좋다.
리액터 테스트에 필요한 몇 가지 전용 요소들은 테스트를 위한 아티팩트 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
는 주로 다음과 같이 활용한다:
StepVerifier
로 시퀀스가 주어진 시나리오를 따르는지 단계별로 테스트한다.TestPublisher
로 다운스트림 연산자를(리액터 외의 연산자도 포함) 테스트하기 위한 데이터를 생산한다.- 선택 가능한
Publisher
가 여럿 있는 시퀀스를 검증한다 (예를 들어switchIfEmpty
를 사용하는 체인이라면,Publisher
가 사용됐는지, 즉 구독됐는지를 검사한다).
6.1. Testing a Scenario with StepVerifier
리액터 시퀀스를 테스트하는 가장 대표적인 케이스는 코드에서 Flux
나 Mono
를 정의해 놓고 (예를 들어 메소드에서 리턴하는 식으로), 이를 구독하면 어떻게 동작하는지 테스트하는 것이다.
이런 상황은 단계별로 이벤트마다의 기대치를 정의하는 “테스트 시나리오”를 만들기 쉽다. 다음과 같은 질문에 답해보면 된다:
- 다음에 기대하는 이벤트는 무엇인가?
Flux
가 특정 값을 생산하기를 기대하는가?- 아니면 다음 300ms 동안은 아무것도 하지 않기를 기대하는가?
이 모든 것은 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
를 생성해서 테스트할 시퀀스를 넘기는 것으로 시작한다. 그다음엔 다양한 메소드를 선택할 수 있다:
- 다음에 발생할 신호에 대한 기대값을 표현한다. 다른 신호를 받으면 (또는 신호에 있는 컨텐츠가 기대와 다르면) 전체 테스트는 유의미한
AssertionError
와 함께 실패로 끝난다. 예를 들어expectNext(T…)
와expectNextCount(long)
을 사용하는 경우가 그렇다. - 다음 신호를 컨슘한다. 일부 시퀀스를 넘어가고 싶거나 신호 컨텐츠에 커스텀
assertion
을 적용하고 싶을 때 사용한다 (5개짜리 리스트 아이템을 방출하는onNext
이벤트가 있는지 확인하는 등). 예를 들어consumeNextWith(Consumer<T>)
를 사용한다. - 임의의 코드를 중단하거나 실행하는 등 다양한 조치를 취한다. 예를 들어 테스트 환경에 필요한 상태나 컨텍스트를 조작한다. 이 땐
thenAwait(Duration)
과then(Runnable)
을 사용한다.
종료 이벤트에 해당하는 expectation 메소드는 (다양한 expectComplete()
, expectError()
메소드) 더 이상 expectation을 표현할 수 없는 API로 전환된다. 마지막 단계에서 해야 할 일은 StepVerifier
에 설정을 추가하고 verify()
메소드 중 하나로 검증을 트리거하는 것이다.
이 시점에 StepVerifier
는 테스트하는 Flux
나 Mono
를 구독하고 시퀀스를 시작해서, 새 신호를 시나리오 상의 다음 스텝과 비교한다. 모두 일치하면 테스트는 성공한 것으로 간주한다. 일치하지 않는 스텝이 있으면 즉시 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 스텝을 찾아내기 위한 두 가지 옵션을 제공한다:
as(String)
:expect*
메소드 뒤에 사용하면 이전 expectation에 대한 설명을 추가해 준다. 해당 expectation이 실패하면 이 문자열을 포함하는 에러 메세지를 건네준다. 마지막 종료 expectation과verify
에는 사용할 수 없다.StepVerifierOptions.create().scenarioName(String)
:StepVerifierOptions
으로StepVerifier
를 만들면scenarioName
메소드로 전체 시나리오의 이름을 지정할 수 있으며, 이 이름은 assertion 에러 메세지에 사용된다.
두 케이스 모두 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 방식으로 사용할 수 있도록 특별히 주의해야 한다. 그렇지 않으면 가상 시간을 보장할 수 없다. 특히, 테스트 코드에서 먼저 초기화한Flux
를Supplier
에서 리턴해선 안된다. 대신에 항상 람다 안에서Flux
를 초기화해야 한다.
이를 위한 expectation 메소드는 두 가지가 있으며, 두 가지 모두 가상 시간이 있든 없든 유효하다.
thenAwait(Duration)
: 잠시 스텝 검증을 멈춘다 (몇 가지 신호가 발생하거나 지연되도록).expectNoEvent(Duration)
: 주어진 시간 동안 시퀀스를 재생하지만 그 시간 동안 신호가 하나라도 발생하면 테스트는 실패한다.
두 메소드 모두 classic 모드에선 주어진 시간 동안 스레드를 중지시키고, virtual 모드에선 대신 가상 시계를 사용한다.
tip
expectNoEvent
는subscription
도 하나의 이벤트로 간주한다. 이 메소드를 첫 번째 스텝에 사용하면 구독 신호가 감지되기 때문에 보통 실패한다. 그 대신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를 참고하라.
StepVerifier
엔 Context
를 전파하는 두 가지 expectation이 있다:
expectAccessibleContext
: 전파한Context
관련 expectation을 세팅할 수 있는ContextExpectations
객체를 반환한다. 시퀀스 expectation 셋으로 돌아가려면then()
을 호출해야 한다.expectNoAccessibleContext
: 테스트하는 동안 연산자 체인에서Context
가 전파되지 않는다는 expectation을 세팅한다. 테스트에서 사용하는Publisher
가 리액터의 publisher가 아니거나Context
를 전파할 연산자가 없는 경우 (예를 들어 generator 소스) 주로 사용한다.
추가로 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-test
는 TestPublisher
클래스를 제공한다. 이 클래스는 다양한 신호를 프로그래밍 방식으로 트리거할 수 있는 Publisher<T>
다:
next(T)
와next(T, T…)
는 1~nonNext
신호를 트리거한다.emit(T…)
1~nonNext
신호를 트리거하고complete()
한다.complete()
은onComplete
신호와 함께 종료한다.error(Throwable)
은onError
신호와 함께 종료한다.
잘 동작하는 TestPublisher
는 create
팩토리 메소드로 얻을 수 있다. 또한, 제대로 동작하지 않는 TestPublisher
는 createNonCompliant
팩토리 메소드로 생성할 수 있다. 후자는 TestPublisher.Violation
열거형 값을 한 개 이상 받는다. 이 값으로 publisher가 따르지 않을 스펙을 정의한다. 사용할 수 있는 열거형 값은 다음과 같다:
REQUEST_OVERFLOW
: 요청이 충분하지 않을 때도IllegalStateException
을 트리거하는 대신next
호출을 허용한다.ALLOW_NULL
:null
값이 들어오면NullPointerException
을 트리거 하는 대신next
호출을 허용한다.CLEANUP_ON_TERMINATE
: 로(row) 하나에서 종료 신호를 여러 번 허용한다.complete()
,error()
,emit()
이 해당한다.DEFER_CANCELLATION
:TestPublisher
가 취소 신호를 무시하고, 이전 신호한테 밀린 것처럼 계속해서 신호를 방출하도록 허용한다.
마지막으로 TestPublisher
는 구독 이후 내부 상태 값을 가지고 있으며, 다양한 assert*
메소드로 이를 검증할 수 있다.
flux()
, mono()
메소드를 사용하면 Flux
나 Mono
로 전환할 수 있다.
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)
로 감싸면 된다.
Next :Debugging Reactor
리액터 디버깅 한글 번역
전체 목차는 여기에 있습니다.