티스토리 뷰

반응형

 

RxTest 활용하기

이번 글에서는 RxSwift로 구현된 기능을 테스트해보기 위해 RxTest를 사용해보려고 해요!

RxSwift는 비동기로 동작하기 때문에 단순히 코드를 작업하면 원하지 않는 결과물이 나올 수 있어요. 

테스트 시 비동기 상황을 만드는 방법은 여러 가지가 있겠지만, 이번에는 RxTest를 익혀보기로 했어요 :]

(거기에 더해 Nimble / RxNimble을 추가로 사용했어요!)

 

상황 이해하기

우선 테스트하려는 객체를 정의해볼게요.

저는 SearchBar에 글자를 입력하고 검색 버튼을 눌렀을 경우 그 글자가 정상적으로 잘 전달되는지 테스트하려고 해요.

그 코드는 아래와 같아요.

 

import RxCocoa
import RxSwift

struct SearchBarViewModel {
  let inputText = PublishRelay<String>()
  let searchButtonTapped = PublishRelay<Void>()
  let queryText: Observable<String>
  
  init() {
    self.queryText = self.searchButtonTapped
      .withLatestFrom(self.inputText)
      .filter { !$0.isEmpty }
      .distinctUntilChanged()
  }
}

 

간략하게 설명하면 SearchBar에 작성된 문자열이 inputText로 bind 될 것이고, 버튼을 누르는 액션이 발생하면 그 액션이 searchButtonTapped에 전달될 거예요.

그럼 inputText에서 최근에 발생한 이벤트의 값, 즉 검색 문자열이 들어올 것이고 빈 값을 검색한 뒤 넘겨줄 거예요.

 

테스트 정의하기

위의 객체에서 시행해야 할 테스트는 아래와 같이 정의할 수 있어요.

 

1. 버튼이 눌렸는데 입력된 문자열이 없을 때 queryText에 이벤트가 전달되지 않는 것을 확인
2. 버튼이 눌렸을 때 입력된 문자열이 있어서 정상적으로 queryText에 전달되는지
3. 버튼이 눌렸을 때 입력된 문자열이 있지만 기존의 문자열과 변화가 없을 때 이벤트가 방출되지 않는지 확인

 

한 개씩 진행해보도록 할게요!

 

테스트 기본 세팅하기

우선 XCTest 하나를 생성해주고 프로퍼티를 정의했어요.

그리고 setUp에서 프로퍼티를 설정해줬어요.

 

var searchBarViewModel: SearchBarViewModel!
var scheduler: TestScheduler!
var disposeBag: DisposeBag!

override func setUp() {
    self.searchBarViewModel = SearchBarViewModel()
    self.scheduler = TestScheduler(initialClock: 0)
    self.disposeBag = DisposeBag()
  }

 

여기서 TestScheduler라는 게 나오는데요, 가상 시계라고 생각하면 될 것 같아요.

비동기 프로그래밍을 테스트할 때 가장 큰 문제는 실행 시점을 확정할 수 없다는 점이죠. 그래서 가상의 시계를 두어 이걸 조절하려고 해요.

자세한 이야기는 사용하면서 해보도록 할게요!

 

버튼이 눌렸는데 입력된 문자열이 없을 때 queryText에 이벤트가 전달되지 않는 것을 확인하는 테스트

우선 스케쥴러에 이벤트를 설정해요.

아래는 버튼이 눌릴 경우를 가정한 코드예요.

 

func test_검색_버튼을_눌렀을_때_입력된_문자열이_없는_경우() throws {
    scheduler.createColdObservable(
      [
        .next(5, ())
      ]
    ).bind(to: searchBarViewModel.searchButtonTapped)
      .disposed(by: disposeBag)
  }

 

Cold Observable이라는 개념이 나오는데요, 이 개념을 이해하기 위해서는 Hot Observable과 대비해보면 좋을 것 같아요.

 

When the data is produced by the Observable itself, we call it a cold Observable. When the data is produced outside the Observable, we call it a hot Observable.
출처

 

해석해보면, Observable 그 자체에서 데이터가 생성되면 Cold Observable이라고 부르고, 데이터가 옵저버를 바깥에서 생성될 경우 Hot Observable이라고 부른다고 하네요 :]

 

저는 지금 이벤트를 직접 생성하는 옵저버블을 만들 계획이라 cold로 선언했어요.(아직 이 개념에 대한 학습이 부족해서 혹시 잘못된 정보가 있다면 댓글 부탁드려요!)

 

위의 코드로 돌아와서 생각해보면 "Observable 이벤트가 있는데 가상 시계상 '5'라는 시간에 next 이벤트가 발생하고, 이걸 searchViewModel에 bind 할 거야"라는 의미가 돼요.

 

여기서 next 이벤트는 빈 값으로 전달이 되는데, 이게 버튼이 눌렸다는 의미가 되는 거죠!

searchButtonTapped가 애초에 PublishRelay<Void>()로 정의가 돼있는 이유이기도 한데, 버튼이 눌렸다는 "사실"만 전달되면 되는 것이지 그 안에 담을 내용은 없기 때문에 Void로 전달이 돼요.

 

그럼 이제 버튼이 눌렸기 때문에, inputText가 비어있는 상황 역시 가정이 되어야 해요.

위의 두 상황을 함께 코드로 구현해볼게요.

 

//given
scheduler.createColdObservable(
  [
    .next(5, "")
  ]
).bind(to: searchBarViewModel.inputText)
  .disposed(by: disposeBag)
    
//when
scheduler.createColdObservable(
  [
    .next(10, ())
  ]
).bind(to: searchBarViewModel.searchButtonTapped)
  .disposed(by: disposeBag)

 

inputText를 가상 시계 5분(?)에 빈 문자열로 입력하고, 10분에 버튼이 눌렸다고 가정했어요.

그럼 이제 이벤트가 발생할 때 확인해야 되는 것은 아무 이벤트가 발생하지 않는다는 것이에요.

최종 테스트 코드는 아래와 같아요!

 

func test_검색_버튼을_눌렀을_때_입력된_문자열이_없는_경우() throws {
    //given
    scheduler.createColdObservable(
      [
        .next(5, "")
      ]
    ).bind(to: searchBarViewModel.inputText)
      .disposed(by: disposeBag)
    
    //when
    scheduler.createColdObservable(
      [
        .next(10, ())
      ]
    ).bind(to: searchBarViewModel.searchButtonTapped)
      .disposed(by: disposeBag)
    
    //then
    expect(self.searchBarViewModel.queryText).events(scheduler: scheduler, disposeBag: disposeBag).to(equal([]))
  }

 

버튼이 눌렸을 때 입력된 문자열이 있어서 정상적으로 queryText에 전달되는지

위의 상황과 다른 점은 당연히 입력된 문자열이 있다는 사실이겠죠?

테스트 코드는 아래와 같아요!

 

func test_검색_버튼을_눌렀을_때_입력된_문자열이_있는_경우() throws {
    //given
    let keyword = "테스트 검색어"
    scheduler.createColdObservable(
      [
        .next(5, keyword)
      ]
    ).bind(to: searchBarViewModel.inputText)
      .disposed(by: disposeBag)
    
    //when
    scheduler.createColdObservable(
      [
        .next(10, ())
      ]
    ).bind(to: searchBarViewModel.searchButtonTapped)
      .disposed(by: disposeBag)
    
    //then
    expect(self.searchBarViewModel.queryText).events(scheduler: scheduler, disposeBag: disposeBag).to(equal([
      .next(10, keyword)
    ]))
  }

 

시간 5에서 문자열의 입력이 전달되었고, 10에서 터치 이벤트가 발생했기 때문에 시간 10에 keyword가 queryText에 전달되어야 해요!

queryText에 전달되는 시간을 11로 설정하여 테스트를 실패시킨 후, 정상적으로 테스트가 통과하는 것을 확인했어요.

 

버튼이 눌렸을 때 입력된 문자열이 있지만 기존의 문자열과 변화가 없을 때 이벤트가 방출되지 않는지 확인

마지막으로 버튼이 눌렸을 때 문자열이 있지만 기존에 입력된 문자열과 차이가 없을 경우를 가정했어요. 시간 5에서 문자열이 입력되고 시간 6에서 검색 버튼 터치가 동작한 후, 시간 10에 같은 문자열을 다시 넣었어요.

사실 여기서 시간 10에 같은 문자열을 넣는 이벤트를 발생시키지 않더라도 테스트 환경은 같지만 저는 보다 명시적으로 작성하기 위해 한 번 더 같은 문자열을 넣었어요! 

그리고 이번엔 시간 11에 검색 버튼을 터치하더라도 이벤트가 전달되면 안 되는 상황이에요.

테스트 코드는 아래와 같아요.

 

func test_검색_버튼을_다시_눌렀을_때_입력된_문자열이_기존의_입력과_같은_경우() throws {
    //given
    let keyword = "테스트 검색어"
    scheduler.createColdObservable(
      [
        .next(5, keyword),
        .next(10, keyword)
      ]
    ).bind(to: searchBarViewModel.inputText)
      .disposed(by: disposeBag)
    
    //when
    scheduler.createColdObservable(
      [
        .next(6, ()),
        .next(11, ())
      ]
    ).bind(to: searchBarViewModel.searchButtonTapped)
      .disposed(by: disposeBag)
    
    //then
    expect(self.searchBarViewModel.queryText).events(scheduler: scheduler, disposeBag: disposeBag).to(equal([
      .next(6, keyword)
    ]))
  }

 

결론

비동기 환경과 Stream 이벤트 전달을 보다 쉽게 테스트하기 위해 RxTest, RxNimble 등을 사용해봤어요. 

테스트의 목적 자체는 결국 프로젝트 코드의 로직에 신뢰성을 주기 위한 것이기 때문에, 유용한 도구를 활용하여 더 명시적으로 표현할 수 있다면 더 좋은 테스트 코드가 될 수 있을 거라고 생각해요 :]

반응형
댓글