시작하기 앞서, 해당 코드는 번호 찾기 게임: RxSwift로 리팩토링하기(코드)와 번호찾기 게임: Unit test & UI test(코드)를 기반으로 작성되었습니다.

주제: FindNumber_RxSwift 테스트 코드 작성하기


RxTest를 활용해서 테스트 코드 작성하기

  • RxSwift를 사용하여 앱을 개발하는 것은 전통적인 방식으로 앱을 개발하는 것과는 근본적으로 다릅니다. 이 차이는 앱 내부의 요소들이 단순히 하나의 값을 가지기 보다는 시간을 따라 변화하는 이벤트 스트림으로 표현된다는 데에 있습니다. RxSwift 라이브러리에서는 이러한 개념을 'Observable'이라고 부르며, 이는 RxSwift로 작성된 코드를 테스트하는 방법이 전통적인 코드 테스트 방식과 다를 수밖에 없는 이유를 설명해 줍니다.
  • RxTest는 RxSwift와는 별개의 라이브러리입니다. RxSwift 내부에 호스팅되어 있지만, 별도의 pod 설치 및 임포트가 필요합니다. RxTest는 RxSwift 코드 테스팅에 유용한 많은 기능을 재공하는데, 그중에는 다음과 같은 것들이 있습니다.
  1. TestScheduler
    • RxSwift의 테스팅 과정에서 매우 중요한 역할을 합니다. 이는 가상의 시간 스케줄러로, 시간에 따른 연산을 테스트할 때 세밀한 제어를 가능하게 해줍니다.
    • TestScheduler를 사용하면, 가상 시간 단위를 기반으로 하여 특정 시점에 어떤 이벤트가 발생하거나, 어떤 상태가 변화하는지 정밀하게 시뮬레이션 할 수 있습니다. 이를 통해 실제 시간을 기다리지 않고도 시간에 의존하는 연산들을 테스트할 수 있습니다.
  2. Recorded.next(_:_:)
    • 이 메서드는 Observable이 어떤 값을 방출할 때 사용됩니다.
    • 첫 번째 매개변수는 가상 시간 단위를 나타내고, 두 번째 매개변수는 Observable이 방출하는 값을 나타냅니다.
  3. Recorded.completed(_:_:)
    • Observable이 완료 이벤트를 방출할 때 사용됩니다.
    • 매개변수는 Observable이 완료되는 가상 시간을 나타냅니다.
  4. Recorded.error(_:_:_:)
    • Observable에서 오류가 발생했을 때 사용됩니다.
    • 첫 번째 매개변수는 오류가 발생하는 시간을, 두 번째 매개변수는 발생하는 오류 자체를 나타냅니다.
  • RxTest를 사용하기 앞서, 간단하게 HotObservableColdObservable로 구분되고 있는 Observable의 특성부터 알아보겠습니다.

Hot Observable

  • Hot Observable은 구독자의 존재 유무와 상관없이 데이터를 생성하고 방출하는 Observable입니다. Hot Observable의 주요 특성은 아래와 같습니다.
  1. 구독자가 있든 없든 자원을 생성 및 사용
    • Hot Observable은 구독자가 없어도 데이터를 생성하고 이벤트를 방출합니다. 따라서 구독자가 시작할 때 이미 방출된 이벤트는 받을 수 없습니다.
  2. 데이터를 공유
    • Hot Observable은 여러 구독자와 데이터를 공유할 수 있으며, 모든 구독자는 Observable이 생성하는 동일한 데이터 스트림을 관찰합니다.
  • 주로 BehaviorRelay 또는 UI 이벤트 등에서 주로 사용됩니다.
  • 예시 코드에서는 target, record 기록들이 Hot Observable입니다.

Cold Observable

  • Cold Observable은 구독자가 구독을 시작할 때만 데이터를 생성하고 방출하는 Observable입니다.
  1. 구독 시작 시 데이터 생성
    • Cold Observable은 구독자가 구독을 시작하면 그때부터 데이터를 생성하고 이벤트를 방출합니다.
  2. 개별 데이터 스트림
    • 각 구독자는 자신만의 데이터 스트림을 받습니다. 다른 구독자와 데이터를 공유하지 않습니다.
  • 주로 비동기적인 작업에서 사용됩니다.
  • 예시 코드에서는 dataTaskObservable 메서드 내부에서 Observable.createCold Observable를 나타내고 있습니다.

FindNumberTest 리팩토링 - RxTest를 활용한 테스트 코드 작성

초기 설정하기

  • 테스트를 작성하기 전에, TestScheduler의 인스턴스를 생성해야 합니다. 또한 클래스에 DisposeBag을 추가하여 테스트에서 생성되는 Disposables를 관리할 필요가 있습니다.
  • TestScheduler는 스트림의 시작 시간을 정의하는 initialClock 인자를 받습니다.
import XCTest 

import RxSwift
import RxCocoa
import RxTest 

@testable import FindNumber

final class MockUserDefaults: UserDefaults {
    private var storage = [String: Any]()

    override func integer(forKey defaultName: String) -> Int {
        return storage[defaultName] as? Int ?? 0
    }

    override func set(_ value: Int, forKey defaultName: String) {
        storage[defaultName] = value
    }
}

final class FindNumberTests: XCTestCase { 
	var sut: FindNumberViewModel!
	
	var scheduler: TestScheduler!
	var disposeBag: DisposeBag!
	
	override func setUpWithError() throws { 
		try super.setUpWithError() 
		
		let urlSessionStub = URLSessionStub()
		let mockUserDefaults = MockUserDefaults(suiteName: "TestDefaults")
		
		sut = FindNumberViewModel( 
			urlSession: urlSessionStub,
			defaults: mockUserDefaults!
		)
		
		scheduler = TestScheduler(initialClock: 0)
		disposeBag = DisposeBag()
	}
	
	override func tearDownWithError() throws { 		
		scheduler = nil 
		disposeBag = nil 
		
		sut = nil 
		
		try super.tearDownWithError()
	}
}

게임 시작 리팩토링

  • 이 코드는 기존 FindNumber_Test 코드 중 test_타겟번호_받기() 함수를 RxSwift를 사용하여 리팩토링한 버전입니다.
func test_게임시작() {
    // given
    let stubbedData = "[2]".data(using: .utf8)
    let urlString = "http://www.randomnumberapi.com/api/v1.0/random?min=1&max=3&count=1"
    let url = URL(string: urlString)!
    let stubbedResponse = HTTPURLResponse(
        url: url,
        statusCode: 200,
        httpVersion: nil,
        headerFields: nil
    )
    let urlSessionStub = URLSessionStub(
        stubbedData: stubbedData,
        stubbedResponse: stubbedResponse,
        stubbedError: nil
    )
        
    sut.urlSession = urlSessionStub
        
    // 게임 시작 이벤트를 시뮬레이션하기 위한 PublishRelay 생성
    let gameStart = PublishRelay<Void>()
        
    // ViewModel의 Input 구조체 생성
    let input = FindNumberViewModel.Input(
        gameStart: gameStart,
        checkNumber: PublishRelay<Int>(),
        resetStage: PublishRelay<Void>())
        
    // ViewModel의 Output을 받기 위한 변수 선언
    let output = sut.transform(input: input)
        
	// Output에서 round 값을 관찰하기 위한 Observer 생성
    let targetObserver = scheduler.createObserver(Int.self)
    let roundObserver = scheduler.createObserver(Int.self)
        
    // when
		
	// 테스트할 target과 round 출력에 대한 바인딩 
    scheduler.scheduleAt(0) {
        output.target
            .bind(to: targetObserver)
            .disposed(by: self.disposeBag)
            
        output.round
            .drive(roundObserver)
            .disposed(by: self.disposeBag)
    }
    
    // 게임 시작 
    scheduler.scheduleAt(10) {
        gameStart.accept(())
    }
    
    // 테스트 실행
    scheduler.start()
        
    // then
    XCTAssertEqual(targetObserver.events, [
	    .next(0, 1),
	    .next(10, 2)]
    )
     
    XCTAssertEqual(roundObserver.events, [.next(0, 1)])
}

테스트 설정(Given)

  1. Stubbed Data 준비
    • 서버에서 올 것으로 예상되는 응답 데이터를 스텁(stub)으로 준비합니다. 여기서는 "[2]"라는 문자열을 UTF8로 인코딩하여 Data 객체로 변환합니다.
  2. URL 및 HTTP 응답 스텁 생성
    • 요청할 URL을 생성하고, 이 URL에 대한 HTTPURLResponse 객체를 스텁으로 준비합니다. 여기서는 상태 코드가 200인 정상 응답을 나타냅니다.
  3. URLSession 스텁 생성
    • URLSessionStub이라는 커스텀 URLSession 클래스를 사용하여 네트워크 요청에 대한 스텁을 생성합니다. 이 스텁은 위에서 준비한 데이터와 HTTP 응답을 반환하도록 설정됩니다.
  4. 시스템 테스트 대상 설정
    • sut.urlSession에 스텁된 URLSession을 할당하여, 실제 네트워크 요청 대신 스텁 데이터를 사용하도록 설정합니다.
  5. 테스트 이벤트 준비
    • PublishRelay를 사용하여 게임 시작, 숫자 확인, 스테이지 리셋 이벤트를 시뮬레이션할 수 있도록 준비합니다.
  6. ViewModel 입력 및 출력 설정
    • FindNumberViewModel.Input 구조체를 사용하여 뷰 모델에 전달할 입력을 생성합니다.
    • sut.transform을 호출하여 뷰 모델의 출력을 받습니다.
  7. Output 관찰을 위한 Observer 생성
    • targetround 출력값을 관찰하기 위해 TestScheduler를 사용하여 Observer를 생성합니다.

테스트 실행(When)

  1. 스케줄러를 사용한 이벤트 스케줄링
    • 시간 0에서 targetround 출력에 대한 바인딩을 설정합니다.
    • 시간 10에서 gameStart 이벤트를 발생시킵니다.
  2. 스케줄러 시작
    • scheduler.start()를 호출하여 테스트 스케줄러를 실행합니다.

결과 검증(Then)

  1. 예상된 이벤트와 실제 이벤트 비교:
    • targetObserver의 이벤트가 예상된 이벤트와 일치하는지 검증합니다.
    • roundObserver의 이벤트가 예상된 이벤트와 일치하는지 검증합니다.
  • targetObserver 값은 최초 ViewModel이 생성 될 때 1로 초기화 되어있고, 서버를 통해 2를 받는 상황입니다. 그러므로 [.next(0, 1), .next(10, 2)] 의 결과가 발생할 것을 예상할 수 있습니다.
  • roundObserverFindNumberViewModel이 생성될때 초기값을 받으므로 [.next(0, 1)]의 결과가 발생할 것을 예상할 수 있습니다.

스테이지 진행 리팩토링

  • 이 코드는 기존 FindNumber_Test 코드 중 test_사용자_선택번호와_타겟이_같을_경우() 함수와 test_사용자_선택번호와_타겟이_다를_경우() 함수를 RxSwift를 사용하여 리팩토링한 버전입니다.
  • RxSwift는 시간에 따라 변화하는 이벤트 스트림을 처리하는 방식을 사용하기 때문에, 기존에는 별개의 두 테스트로 작성되었던 것들을 하나의 테스트 코드로 통합해도 무방하다고 판단했습니다. 이렇게 하면, 한 함수 내에서 두 가지 시나리오를 모두 테스트할 수 있어 효율적입니다.
func test_스테이지_진행() {
    // given
    let stubbedData = "[1]".data(using: .utf8)
    let urlString = "http://www.randomnumberapi.com/api/v1.0/random?min=1&max=3&count=1"
    let url = URL(string: urlString)!
    let stubbedResponse = HTTPURLResponse(
        url: url,
        statusCode: 200,
        httpVersion: nil,
        headerFields: nil
    )
    let urlSessionStub = URLSessionStub(
        stubbedData: stubbedData,
        stubbedResponse: stubbedResponse,
        stubbedError: nil
    )
        
    sut.urlSession = urlSessionStub
    
    // 스테이지 진행시 발생할 이벤트를 시뮬레이션하기 위한 PublishRelay 생성 
    let checkNumber = PublishRelay<Int>()
	
	// ViewModel의 Input 구조체 생성
    let input = FindNumberViewModel.Input(
        gameStart: PublishRelay<Void>(),
        checkNumber: checkNumber,
        resetStage: PublishRelay<Void>()
    )
    
    // ViewModel의 Output을 받기 위한 변수 선언  
    let output = sut.transform(input: input)
    
    // Output에서 round 값을 관찰하기 위한 Observer 생성 
    let targetObserver = scheduler.createObserver(Int.self)
    let roundObserver = scheduler.createObserver(Int.self)
    let bestRecordObserver = scheduler.createObserver(Int.self)
    let currentRecordObserver = scheduler.createObserver(Int.self)
        
    // when
    scheduler.scheduleAt(0) {
        output.target
            .bind(to: targetObserver)
            .disposed(by: self.disposeBag)
            
        output.round
            .drive(roundObserver)
            .disposed(by: self.disposeBag)
            
        output.bestRecord
            .drive(bestRecordObserver)
            .disposed(by: self.disposeBag)
            
        output.currentRecord
            .drive(currentRecordObserver)
            .disposed(by: self.disposeBag)
    }
        
        
    scheduler.scheduleAt(10) {
        checkNumber.accept(1)
    }
        
    scheduler.scheduleAt(15) {
        checkNumber.accept(1)
    }
        
    scheduler.scheduleAt(20) {
        checkNumber.accept(1)
    }
        
    scheduler.scheduleAt(25) {
        checkNumber.accept(2)
    }
        
    scheduler.scheduleAt(30) {
        checkNumber.accept(1)
    }
        
    scheduler.start()
        
    // then
    XCTAssertEqual(targetObserver.events, [
        .next(0, 1),
        .next(10, 1),
        .next(15, 1),
        .next(20, 1),
        // 해당 테스트를 통해 틀렸을 때 api 호출은 안한다는것을 확인할 수 있음 
        // .next(0, 1),
        .next(30, 1)
    ])
        
    XCTAssertEqual(roundObserver.events, [
        .next(0, 1),
        .next(10, 2),
        .next(15, 3),
        .next(20, 4),
        .next(25, 1),
        .next(30, 2)]
    )
    XCTAssertEqual(bestRecordObserver.events, [
        .next(0, 0),
        .next(10, 1),
        .next(15, 2),
        .next(20, 3),
        .next(30, 3)]
    )
        
    XCTAssertEqual(currentRecordObserver.events, [
        .next(0, 0),
        .next(10, 1),
        .next(15, 2),
        .next(20, 3),
        .next(25, 0),
        .next(30, 1)]
    )
}

테스트 설정(Given)

  1. Stubbed Data 및 HTTP 응답 준비
    • 예상되는 서버 응답("[1]")을 스텁 데이터로 준비합니다.
    • urlString을 사용하여 URL 객체를 생성하고, 이 URL에 대한 HTTPURLResponse 객체를 스텁으로 준비합니다.
    • URLSessionStub 클래스를 사용하여, 이 스텁 데이터와 응답을 반환하는 가짜 URLSession을 생성합니다.
    • 시스템 테스트 대상(sut)의 urlSession 프로퍼티에 이 스텁을 할당합니다.
  2. ViewModel 입력 및 출력 설정
    • PublishRelay를 사용하여 게임 시작, 숫자 확인, 스테이지 리셋 이벤트를 준비합니다.
    • 이 입력값들을 사용하여 뷰 모델에 전달할 Input 구조체를 생성하고, 뷰 모델의 transform 메서드를 호출하여 출력을 가져옵니다.
  3. Output 관찰을 위한 Observer 생성
    • target, round, bestRecord, currentRecord 출력값을 관찰하기 위한 Observer들을 생성합니다.

테스트 실행(When)

  1. 스케줄러를 사용한 이벤트 스케줄링
    • 시간 0에서 각 출력에 대한 바인딩을 설정합니다.
    • 시간 10, 15, 20, 25, 30에 checkNumber 이벤트를 발생시킵니다.
    • 해당 이벤트는 성공, 성공, 성공, 실패, 성공 입니다.
  2. 스케줄러 시작
    • scheduler.start()를 호출하여 테스트 스케줄러를 실행합니다.

결과 검증(Then)

  • 예상된 이벤트와 실제 이벤트 비교
    • targetObserver, roundObserver, bestRecordObserver, currentRecordObserver의 이벤트가 예상된 이벤트와 일치하는지 검증합니다.
    • 테스트를 통해 숫자를 잘못 입력했을 때 API 호출이 발생하지 않는 것을 확인합니다.
  1. targetObserver
    • ViewModel 생성 시 target 값이 1로 초기화됩니다.
    • 사용자가 checkNumber() 메서드를 통해 target을 맞추면, target 값이 변경됩니다.
    • 이 Observer는 성공적인 경우에만 .next 이벤트가 발생하는 것을 확인합니다.
  2. roundObserver
    • 초기값은 1이며, 사용자가 성공할 때마다 값이 증가합니다.
    • 사용자가 실패하면 값이 다시 1로 초기화됩니다.
    • 이 Observer를 통해 이 로직이 정상적으로 작동하는지 확인할 수 있습니다.
  3. bestRecordObserver
    • 이 Observer는 내부적으로 bestRecord 값의 최댓값이 방출되는 것을 관찰합니다.
    • 테스트 코드를 통해 bestRecord의 동작이 정상임을 확인할 수 있습니다.
  4. currentRecordObserver
    • 사용자가 성공할 경우에는 값이 증가하고, 실패할 경우에는 0으로 초기화됩니다.
    • 이 Observer를 통해 해당 로직이 올바르게 작동하는지 확인할 수 있습니다.

스테이지 초기화 리팩토링

  • 이 코드는 기존 FindNumber_Test 코드 중 test_스테이지_초기화() 함수를 RxSwift를 사용하여 리팩토링한 버전입니다.
func test_스테이지_초기화() {
    // given
    // 스테이지 초기화시 발생할 이벤트를 시뮬레이션하기 위한 PublishRelay 생성 
    let resetStage = PublishRelay<Void>()
	
	// ViewModel의 Input 구조체 생성
    let input = FindNumberViewModel.Input(
        gameStart:  PublishRelay<Void>(),
        checkNumber: PublishRelay<Int>(),
        resetStage: resetStage
    )
	
	// ViewModel의 Output을 받기 위한 변수 선언 
    let output = sut.transform(input: input)
    
    // Output에서 round 값을 관찰하기 위한 Observer 생성     
    let roundObserver = scheduler.createObserver(Int.self)
    let bestRecordObserver = scheduler.createObserver(Int.self)
    let currentRecordObserver = scheduler.createObserver(Int.self)
    
	// 테스트할 round, bestRecord, currentRecord 출력에 대한 바인딩 
	scheduler.scheduleAt(0) { 
	    output.round
	        .drive(roundObserver)
	        .disposed(by: disposeBag)
        
	    output.bestRecord
	        .drive(bestRecordObserver)
	        .disposed(by: disposeBag)
        
	    output.currentRecord
	        .drive(currentRecordObserver)
	        .disposed(by: disposeBag)
	}	
        
    // when 
    scheduler.createColdObservable([.next(10, 4)])
        .asDriver(onErrorJustReturn: 0)
        .drive(roundObserver)
        .disposed(by: disposeBag)
        
    scheduler.createColdObservable([.next(10, 5)])
        .asDriver(onErrorJustReturn: 0)
        .drive(bestRecordObserver)
        .disposed(by: disposeBag)
        
    scheduler.createColdObservable([.next(10, 3)])
        .asDriver(onErrorJustReturn: 0)
        .drive(currentRecordObserver)
        .disposed(by: disposeBag)
        
    // 스테이지 초기화
    scheduler.scheduleAt(20) {
        resetStage.accept(())
    }
    
    // 테스트 실행
    scheduler.start()
        
    // then
    XCTAssertEqual(roundObserver.events, [
        .next(0, 1),
        .next(10, 4),
        .next(20, 1)]
    )
    XCTAssertEqual(bestRecordObserver.events, [
        .next(0, 0),
        .next(10, 5),
        .next(20, 0)]
    )
    XCTAssertEqual(currentRecordObserver.events, [
        .next(0, 0),
        .next(10, 3),
        .next(20, 0)]
    )
}

테스트 설정 (given)

  1. 입력값 설정
    • 게임 시작, 숫자 확인, 스테이지 초기화를 위한 PublishRelay 인스턴스들을 생성합니다.
    • 이들을 FindNumberViewModel.Input 구조체에 전달하여 뷰 모델의 입력을 구성합니다.
  2. ViewModel 변환
    • sut.transform 메서드를 호출하여 입력을 출력으로 변환합니다.
  3. Observer 생성 및 바인딩
    • round, bestRecord, currentRecord 출력값을 관찰하기 위한 Observer들을 생성하고, 출력에 바인딩합니다.

테스트 실행 (when)

  1. 스케줄러를 사용한 이벤트 발생
    • 스케줄러를 사용하여 round, bestRecord, currentRecord 값에 대한 가상의 이벤트를 생성하고, 각각의 Observer에 바인딩합니다. 이러한 이벤트들은 시간 10에 발생합니다.
    • 해당 이벤트들은 기존 게임이 4라운드, 최대 5 연속, 현재 3 연속으로 가정합니다.
  2. 스테이지 초기화
    • 시간 20에 resetStage 이벤트를 발생시키면서 스테이지를 초기화합니다.
  3. 스케줄러 시작
    • scheduler.start()를 호출하여 테스트 스케줄러를 시작합니다.

결과 검증 (then)

  1. roundObserver 검증
    • 초기값이 1로 설정되어 있고, 가상 이벤트에 의해 4로 변경된 후, 스테이지 초기화에 의해 다시 1로 초기화되는 것을 확인합니다.
  2. bestRecordObserver 검증
    • 초기값은 0, 가상 이벤트에 의해 5로 변경되고, 스테이지 초기화에 의해 0으로 초기화됩니다.
  3. currentRecordObserver 검증
    • 초기값은 0, 가상 이벤트에 의해 3으로 변경되고, 스테이지 초기화에 의해 0으로 초기화됩니다.

에러처리 확인 테스트 코드 작성

네트워크 오류

func test_checkNumber_네트워크_오류_발생() {
    // given
    // 네트워크 요청 실패를 시뮬레이션하기 위해 URLSessionStub 구성
    let urlSessionStub = URLSessionStub(
        stubbedData: nil,
        stubbedResponse: nil,
        stubbedError: MyError.networkError
    )
        
    sut.urlSession = urlSessionStub
        
    let errorObserver = scheduler.createObserver(MyError.self)
        
    // ViewModel의 Input 구조체 생성
    let input = FindNumberViewModel.Input(
        gameStart: PublishRelay<Void>(),
        checkNumber: PublishRelay<Int>(),
        resetStage: PublishRelay<Void>()
    )
        
    // ViewModel의 Output을 받기 위한 변수 선언
    let output = sut.transform(input: input)
        
    // when
    // Output에서 error 값을 관찰하기 위한 Observer 생성
    output.error
        .drive(errorObserver)
        .disposed(by: disposeBag)
        
    // 게임 시작 이벤트 발생
    input.checkNumber.accept(1)
        
    // then
    // 오류 발생 검증
    XCTAssertEqual(errorObserver.events, [.next(0, MyError.networkError)])
    }
  1. 네트워크 요청 실패 시뮬레이션
    • URLSessionStub을 사용하여 네트워크 요청 실패를 시뮬레이션합니다. 여기서는 stubbedData, stubbedResponsenil로 설정하고, stubbedErrorMyError.networkError를 할당합니다.
  2. ViewModel 설정 및 입력 구성
    • sut.urlSessionurlSessionStub을 할당합니다.
    • 게임 시작, 숫자 확인, 스테이지 리셋을 위한 PublishRelay 인스턴스를 생성하고, 이를 FindNumberViewModel.Input 구조체에 전달합니다.
  3. 에러 Observer 생성 및 바인딩
    • errorObserver를 생성하여 ViewModel의 output.error에 바인딩합니다.
  4. 게임 시작 이벤트 발생 및 결과 검증
    • checkNumber1을 전달하여 게임 시작 이벤트를 발생시킵니다.
    • errorObserverMyError.networkError를 정확하게 받는지 확인합니다.

디코딩 오류

func test_gameStart_디코딩_오류() {
    // given
    // 디코딩 오류를 시뮬레이션하기 위해 잘못된 형식의 데이터를 설정
    let invalidData = "[d]".data(using: .utf8)!
        
    let urlString = "http://www.randomnumberapi.com/api/v1.0/random?min=1&max=3&count=1"
    let url = URL(string: urlString)!
    let stubbedResponse = HTTPURLResponse(
        url: url,
        statusCode: 200,
        httpVersion: nil,
        headerFields: nil
    )
        
    let urlSessionStub = URLSessionStub(
        stubbedData: invalidData,
        stubbedResponse: stubbedResponse,
        stubbedError: nil
    )
        
    sut.urlSession = urlSessionStub
        
    // ViewModel의 Input 구조체 생성
    let input = FindNumberViewModel.Input(
        gameStart: PublishRelay<Void>(),
        checkNumber: PublishRelay<Int>(),
        resetStage: PublishRelay<Void>()
    )
        
    let errorObserver = scheduler.createObserver(MyError.self)
        
    // ViewModel의 Output을 받기 위한 변수 선언
    let output = sut.transform(input: input)
        
    // when
    // Output에서 error 값을 관찰하기 위한 Observer 생성
    scheduler.scheduleAt(0) {
        output.error
            .drive(errorObserver)
            .disposed(by: self.disposeBag)
    }
        
    scheduler.start()
        
    // 게임 시작 이벤트 발생
    input.gameStart.accept(())
        
    // then
    XCTAssertEqual(errorObserver.events, [.next(0, MyError.decodingError)])
    }
  1. 디코딩 오류 시뮬레이션
    • 잘못된 형식의 데이터("[d]")를 설정하여 디코딩 오류를 시뮬레이션합니다.
    • URLSessionStub을 사용하여 해당 데이터와 함께 정상적인 HTTP 응답을 스텁으로 설정합니다.
  2. ViewModel 설정 및 입력 구성
    • sut.urlSessionurlSessionStub을 할당합니다.
    • FindNumberViewModel.Input 구조체를 생성하여 ViewModel에 입력합니다.
  3. 에러 Observer 생성 및 스케줄링
    • errorObserver를 생성하고, output.error에 바인딩합니다.
    • scheduler를 사용하여 스케줄링을 설정합니다.
  4. 게임 시작 이벤트 발생 및 결과 검증
    • gameStart에 이벤트를 전달하여 게임을 시작합니다.
    • errorObserverMyError.decodingError를 정확하게 받는지 확인합니다.

RxBlocking을 활용해서 테스트 코드 작성하기

  • RxBlockingRxTest와 함께 사용할 수 있는 테스팅 프레임워크 중 하나이며, 이 라이브러리의 핵심 목적은 비동기적으로 동작하는 Observable 시퀀스를 동기적으로 변환하여 테스트하기 쉽게 만드는 것입니다.
  1. BlockingObservable
    • RxBlocking은 Observable을 BlockingObservable로 변환합니다. 이는 현재 스레드를 차단하고, 지정된 조건이 충족될 때까지 기다립니다.
    • 이를 통해 Observable을 동기적인 방식으로 테스트할 수 있습니다.
  2. 테스트를 위한 연산자
    • toArray(): Observable이 완료될 때까지 기다린 후, 모든 이벤트를 배열로 반환합니다.
    • first(): 첫 번째 이벤트를 기다렸다가 반환합니다.
    • last(): 마지막 이벤트를 기다렸다가 반환합니다.

RxBlocking 활용 시나리오

  • 유한 시퀀스 테스트
    - RxBlocking은 완료(completed) 또는 에러(error) 이벤트로 종료되는 유한 시퀀스를 테스트하는 데 적합합니다.
  • 특정 이벤트 검증
    - Observable의 특정 이벤트(첫 번째, 마지막 등)를 검증하고자 할 때 유용합니다.
  • 비동기 로직의 동기화
    - 비동기적으로 작동하는 로직을 동기적으로 테스트하여, 테스트 코드의 복잡성을 줄일 수 있습니다.

FindNumberSlowTest 리팩토링 - RxBlocking을 활용한 테스트 코드 작성

이 코드는 FindNumber 프로젝트의 FindNumberSlowTests 클래스에서 test_유효한API_호출후_HTTPStatusCode200_받기 함수를 RxBlocking을 활용하여 작성한 단위 테스트입니다. 코드의 주요 부분은 네트워크 통신을 통해 HTTP 상태 코드 200을 정상적으로 받는지 검증하는 것입니다.

import XCTest

import RxSwift
import RxBlocking

@testable import FindNumber

final class FindNumberSlowTests: XCTestCase {
    var sut: URLSession!
    let networkMonitor = NetworkMonitor.shared
    
    override func setUpWithError() throws {
        try super.setUpWithError()
        sut = URLSession(configuration: .default)
    }
    
    override func tearDownWithError() throws {
        sut = nil
        try super.tearDownWithError()
    }
    
    func test_유효한API_호출후_HTTPStatusCode200_받기() throws {
	    // network에 연동되었을 때만 테스트 실행 
        try XCTSkipUnless(networkMonitor.isReachable, "Network connectivity needed for this test.")
        
        // given
        let urlString = "https://www.randomnumberapi.com/api/v1.0/random?min=0&max=3&count=1"
        let url = URL(string: urlString)!
        var statusCode: Int?
        var responseError: Error?
        
        // when
        let dataTaskObservable = sut.dataTaskObservable(with: url)
        do {
            let result = try dataTaskObservable
                .map { $0.urlResponse as? HTTPURLResponse }
                .compactMap { $0?.statusCode }
                .toBlocking(timeout: 3)
                .first()
            
            statusCode = result
        } catch {
            responseError = error
        }

        // then
        XCTAssertNil(responseError)
        XCTAssertEqual(statusCode, 200)
    }
}
  1. 클래스 및 속성 설정
    • FindNumberSlowTestsXCTestCase를 상속받는 테스트 클래스입니다.
    • sut (System Under Test)는 테스트에 사용될 URLSession 인스턴스입니다.
    • networkMonitor를 사용하여 네트워크 연결 상태를 확인합니다.
  2. 테스트 설정 (setUpWithError 메서드)
    • URLSession 인스턴스를 기본 설정으로 초기화합니다.
  3. 테스트 종료 (tearDownWithError 메서드)
    • 사용한 URLSession 인스턴스를 해제합니다.
  4. 테스트 케이스 (test_유효한API_호출후_HTTPStatusCode200_받기 메서드)
    • 네트워크 연결이 필요한 테스트이므로, XCTSkipUnless를 사용하여 네트워크 연결이 없을 경우 테스트를 건너뜁니다.
    • API URL을 정의하고 URL 객체를 생성합니다.
    • URLSessiondataTaskObservable을 사용하여 네트워크 요청을 Observable로 변환합니다.
    • .compactMap { $0?.statusCode } 으로 변환된 HTTPURLResponse 객체에서 HTTP 상태 코드를 추출합니다. compactMap은 옵셔널 체이닝을 통해 nil이 아닌 경우에만 상태 코드를 반환합니다.
    • .toBlocking(timeout: 3): Observable 시퀀스를 BlockingObservable로 변환합니다. 이는 최대 3초 동안 대기하면서 Observable의 이벤트를 기다립니다.
    • .first()변환된 BlockingObservable에서 첫 번째 이벤트를 동기적으로 가져옵니다. 이는 첫 번째 네트워크 요청의 결과 (여기서는 HTTP 상태 코드)를 의미합니다.
    • statusCode에 HTTP 상태 코드를 할당합니다.
    • 예외 처리를 통해 오류가 발생했는지 확인합니다.
  5. 결과 검증
    • 응답 오류가 nil인지 (즉, 오류가 없었는지) 확인합니다.
    • 받아온 HTTP 상태 코드가 200인지 검증합니다.

결론

  • RxSwift의 Observable은 시간을 따라 변화하는 이벤트 스트림으로 표현되며, 이를 테스트하기 위해 RxTest와 RxBlocking이 사용됩니다.
  • RxTest는 가상 시간을 이용하여 이벤트의 발생을 정밀하게 제어할 수 있게 해주며, RxBlocking은 Observable을 동기적으로 변환하여 테스트를 단순화합니다.

출처(참고문헌)

원본코드

제가 학습한 내용을 요약하여 정리한 것입니다. 내용에 오류가 있을 수 있으며, 어떠한 피드백도 감사히 받겠습니다.

감사합니다.

0개의 댓글

관련 채용 정보

Powered by GraphCDN, the GraphQL CDN