테스트: TestScheduler와 마블 다이어그램으로 시간 기반 스트림 검증하기
TestScheduler는 가상 시간(virtual time)을 사용해 Observable의 값, 오류, 완료 시점을 동기적으로 검증하는 테스트 도구다. 마블 다이어그램(marble diagram)은 시간 흐름과 알림(notification)을 문자열로 표현해 복잡한 연산자 동작을 읽기 쉽게 만든다. 실제 타이머를 기다리지 않으므로 시간 기반 테스트를 빠르
By 송민성
1. 개념
TestScheduler는 RxJS의 시간 기반 동작을 가상 시간(virtual time) 위에서 실행하고 검증하는 스케줄러(scheduler)다. rxjs/testing에서 가져온다.
마블 다이어그램(marble diagram)은 Observable의 시간 흐름을 문자열로 표현하는 표기법이다.
-: 가상 시간 한 프레임(frame)의 경과a,b,c: 방출되는 값|: 완료(complete)#: 오류(error)^: 구독 시작 시점!: 구독 해제 시점(): 같은 프레임에서 여러 알림 발생
예를 들어 --a-b-|는 두 프레임 뒤 a, 두 프레임 뒤 b, 한 프레임 뒤 완료되는 스트림을 뜻한다.
마블 문자열의 문자는 실제 값이 아니다. 값 매핑(value mapping)을 통해 a를 객체나 숫자에 연결한다. const values = { a: 10, b: 20 }처럼 정의하면 a는 10, b는 20을 나타낸다.
2. 왜 사용하는가
delay, debounceTime, throttleTime, timeout, interval처럼 시간에 의존하는 연산자는 실제 시간으로 테스트하면 느리고 불안정해진다. 실행 환경의 부하나 타이머 정밀도 때문에 간헐적 실패(flaky test)가 발생할 수도 있다.
TestScheduler는 실제 시간을 기다리지 않고 가상 시간을 즉시 진행한다. 10초 지연을 검증해도 테스트 실행에 실제 10초가 필요하지 않다.
마블 테스트는 다음 항목을 한눈에 검증한다.
- 어떤 값이 방출되는가
- 값이 언제 방출되는가
- 스트림이 언제 완료되거나 실패하는가
- 내부 Observable을 언제 구독하고 해제하는가
특히 switchMap, mergeMap, concatMap, exhaustMap처럼 구독 수명과 동시성이 결과를 바꾸는 고차 Observable(higher-order Observable) 테스트에 적합하다.
3. 동작 원리
TestScheduler는 기대 결과와 실제 결과를 알림(notification) 배열로 변환한 뒤 비교한다. 각 알림에는 가상 프레임, 알림 종류, 방출 값 또는 오류가 기록된다.
기본 생성 형태는 new TestScheduler((actual, expected) => { expect(actual).toEqual(expected); })다. 비교 함수(assertion function)는 테스트 프레임워크의 동등성 검증 기능에 연결한다.
테스트는 보통 testScheduler.run(({ cold, hot, expectObservable, expectSubscriptions }) => { ... }) 형태로 작성한다. run 안에서는 마블 문자열의 시간 단위를 일관된 가상 시간으로 해석하고 테스트가 끝날 때 가상 시간을 자동으로 진행한다.
cold는 콜드 Observable(cold Observable)을 만든다. 각 구독자는 구독 시점부터 마블 문자열을 독립적으로 실행한다.
hot은 핫 Observable(hot Observable)을 만든다. 값은 구독 여부와 관계없이 마블 문자열의 절대 시점에 발생한다. ^로 테스트 대상의 구독 시작점을 표시할 수 있다.
expectObservable은 Observable의 출력 알림을 검증한다. expectSubscriptions는 구독 시작과 해제 시점을 검증한다. 출력값이 같아도 불필요한 내부 구독이 남을 수 있으므로, 취소 동작이 핵심인 연산자에서는 둘을 함께 사용한다.
4. 예제 (인라인 코드로만, fenced 코드블록 금지)
기본 변환 테스트
테스트 스케줄러를 const scheduler = new TestScheduler((actual, expected) => expect(actual).toEqual(expected))로 만든다.
그다음 scheduler.run(({ cold, expectObservable }) => { const source$ = cold('-a-b-|', { a: 1, b: 2 }); const result$ = source$.pipe(map(value => value * 10)); expectObservable(result$).toBe('-a-b-|', { a: 10, b: 20 }); })로 검증한다.
입력과 출력의 방출 시점은 같고 값만 바뀐다. 이 테스트는 map이 구독 시점이나 완료 시점을 변경하지 않는다는 사실까지 검증한다.
오류 테스트
const error = new Error('실패')를 준비하고, const source$ = cold('--a-#', { a: 1 }, error)로 값 하나 뒤 오류가 발생하는 스트림을 만든다.
검증은 expectObservable(source$).toBe('--a-#', { a: 1 }, error)로 작성한다. toBe의 세 번째 인자는 #에 대응하는 오류 값이다.
시간 기반 연산자 테스트
debounceTime을 검증하려면 TestScheduler가 관리하는 스케줄러를 사용해야 한다. scheduler.run(({ cold, expectObservable }) => { const source$ = cold('a--b----c---|'); const result$ = source$.pipe(debounceTime(3)); expectObservable(result$).toBe('------b---c-|'); })처럼 작성할 수 있다.
a는 대기 시간 안에 b가 들어와 취소된다. b는 뒤에 충분한 간격이 있어 방출된다. c도 대기 시간이 지난 뒤 방출되고, 이후 원본 스트림이 완료된다.
시간 표현이 길어지면 시간 진행 구문(time progression syntax)을 사용할 수 있다. 예를 들어 a 999ms b 999ms (c|)는 a 뒤 1,000밀리초 시점에 b, 다시 1,000밀리초 시점에 c와 완료가 같은 프레임에서 발생하는 흐름을 나타낸다. 값 문자 자체도 한 프레임을 차지하므로 원하는 프레임을 계산할 때 이 점을 반영해야 한다.
switchMap 구독 취소 테스트
외부 스트림을 const source$ = hot('-a---b------|')로 만들고, 내부 스트림을 const inner$ = cold('--x--y--|')로 만든다. 결과는 const result$ = source$.pipe(switchMap(() => inner$))다.
출력 검증만으로 부족하면 내부 구독 기록을 확인한다. expectSubscriptions(inner$.subscriptions).toBe(['-^---!', '-----^-------!'])처럼 첫 내부 구독이 새 외부 값 b 때문에 해제되고 두 번째 내부 구독이 끝까지 유지되는지 검증한다.
정확한 마블 위치는 외부 스트림과 내부 스트림의 프레임을 합산해 작성한다. 괄호, 구독 지점, 완료 시점이 포함되면 각 프레임을 표로 적어 계산하는 편이 안전하다.
5. 실무 사용 사례
검색어 자동 완성
입력 스트림에 debounceTime과 switchMap을 적용한 로직을 검증한다. 빠르게 이어진 입력 중 마지막 값만 서버 요청으로 연결되는지, 이전 요청 구독이 해제되는지 확인한다.
재시도와 지연
retry, retryWhen, delay, timer를 조합한 재시도 정책을 실제 대기 없이 검증한다. 재시도 횟수, 요청 간격, 최종 오류 전달 시점을 마블 문자열로 표현한다.
폴링(polling)
interval이나 반복(repeat) 기반 폴링이 지정된 간격으로 실행되는지 확인한다. 종료 신호를 takeUntil로 연결한 경우 구독 해제 시점도 함께 검증한다.
상태 스트림 결합
combineLatest, withLatestFrom, zip처럼 여러 Observable을 결합할 때 어떤 값 조합이 언제 방출되는지 검증한다. 초기값 부재 때문에 첫 방출이 늦어지는 문제도 마블 테스트로 드러난다.
경합 조건(race condition)
여러 비동기 흐름의 순서를 다르게 배치해 최신 응답만 반영되는지 검증한다. 실제 네트워크 지연을 재현하지 않고도 빠른 응답과 느린 응답의 순서를 결정적으로 구성할 수 있다.
6. 주의할 점
TestScheduler가 모든 비동기 작업을 자동으로 가상화하지는 않는다. Promise, fetch, DOM 이벤트처럼 RxJS 스케줄러 밖에서 실행되는 비동기 작업은 별도 대역(test double)이나 테스트 전략이 필요하다.
마블 테스트를 구현 세부 사항에 과도하게 맞추면 연산자 하나만 바뀌어도 테스트가 깨진다. 핵심 요구가 값의 순서라면 출력만 검증하고, 취소나 자원 정리가 요구사항일 때만 구독 시점을 검증한다.
핫 Observable과 콜드 Observable을 혼동하면 예상 프레임이 달라진다. API 요청처럼 구독할 때 실행되는 흐름은 보통 콜드 Observable이고, 사용자 이벤트나 공유된 메시지 스트림은 보통 핫 Observable로 모델링한다.
run 밖에서 사용하는 마블 API와 run 안의 시간 해석을 섞지 않는다. 한 테스트에서는 run 모드로 통일하는 편이 계산 오류를 줄인다.
같은 프레임에서 값과 완료가 발생하면 (a|)처럼 그룹화한다. a|는 값과 완료가 서로 다른 프레임에 발생한다.
실제 오류 객체를 비교할 때 테스트 프레임워크의 동등성 규칙을 확인한다. 메시지만 중요하다면 오류를 값으로 변환한 뒤 검증하는 방법도 있다.
마블 문자열이 지나치게 길어지면 테스트 의도가 흐려진다. 입력, 예상 출력, 값 매핑을 가까이 배치하고 각 스트림의 역할을 짧은 이름으로 구분한다.
7. 핵심 정리
- TestScheduler는 가상 시간으로 Observable의 값과 발생 시점을 검증한다.
- 마블 다이어그램은 방출, 완료, 오류, 구독 수명을 문자열로 표현한다.
cold와hot은 구독 기준 시간이 다르다.expectObservable은 출력 흐름,expectSubscriptions는 구독과 해제 시점을 검증한다.- 시간 기반 연산자와 고차 Observable은 실제 타이머 대신 TestScheduler로 테스트하면 빠르고 결정적이다.
- Promise나 네트워크 요청처럼 RxJS 스케줄러 밖의 비동기 작업은 별도 테스트 전략이 필요하다.