시작하기 앞서, 해당 코드는 번호 찾기 게임: RxSwift로 리팩토링하기(코드)와 번호찾기 게임: Unit test & UI test(코드)를 기반으로 작성되었습니다.
TestSchedulerTestScheduler를 사용하면, 가상 시간 단위를 기반으로 하여 특정 시점에 어떤 이벤트가 발생하거나, 어떤 상태가 변화하는지 정밀하게 시뮬레이션 할 수 있습니다. 이를 통해 실제 시간을 기다리지 않고도 시간에 의존하는 연산들을 테스트할 수 있습니다. Recorded.next(_:_:)Recorded.completed(_:_:)Recorded.error(_:_:_:)RxTest를 사용하기 앞서, 간단하게 HotObservable과 ColdObservable로 구분되고 있는 Observable의 특성부터 알아보겠습니다.Hot Observable은 구독자의 존재 유무와 상관없이 데이터를 생성하고 방출하는 Observable입니다. Hot Observable의 주요 특성은 아래와 같습니다. Hot Observable은 구독자가 없어도 데이터를 생성하고 이벤트를 방출합니다. 따라서 구독자가 시작할 때 이미 방출된 이벤트는 받을 수 없습니다. Hot Observable은 여러 구독자와 데이터를 공유할 수 있으며, 모든 구독자는 Observable이 생성하는 동일한 데이터 스트림을 관찰합니다. BehaviorRelay 또는 UI 이벤트 등에서 주로 사용됩니다. Hot Observable입니다. Cold Observable은 구독자가 구독을 시작할 때만 데이터를 생성하고 방출하는 Observable입니다. Cold Observable은 구독자가 구독을 시작하면 그때부터 데이터를 생성하고 이벤트를 방출합니다.dataTaskObservable 메서드 내부에서 Observable.create로 Cold Observable를 나타내고 있습니다. 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)])
}
"[2]"라는 문자열을 UTF8로 인코딩하여 Data 객체로 변환합니다.HTTPURLResponse 객체를 스텁으로 준비합니다. 여기서는 상태 코드가 200인 정상 응답을 나타냅니다.URLSessionStub이라는 커스텀 URLSession 클래스를 사용하여 네트워크 요청에 대한 스텁을 생성합니다. 이 스텁은 위에서 준비한 데이터와 HTTP 응답을 반환하도록 설정됩니다.sut.urlSession에 스텁된 URLSession을 할당하여, 실제 네트워크 요청 대신 스텁 데이터를 사용하도록 설정합니다.PublishRelay를 사용하여 게임 시작, 숫자 확인, 스테이지 리셋 이벤트를 시뮬레이션할 수 있도록 준비합니다.FindNumberViewModel.Input 구조체를 사용하여 뷰 모델에 전달할 입력을 생성합니다.sut.transform을 호출하여 뷰 모델의 출력을 받습니다.target과 round 출력값을 관찰하기 위해 TestScheduler를 사용하여 Observer를 생성합니다. target과 round 출력에 대한 바인딩을 설정합니다.gameStart 이벤트를 발생시킵니다.scheduler.start()를 호출하여 테스트 스케줄러를 실행합니다.targetObserver의 이벤트가 예상된 이벤트와 일치하는지 검증합니다.roundObserver의 이벤트가 예상된 이벤트와 일치하는지 검증합니다.targetObserver 값은 최초 ViewModel이 생성 될 때 1로 초기화 되어있고, 서버를 통해 2를 받는 상황입니다. 그러므로 [.next(0, 1), .next(10, 2)] 의 결과가 발생할 것을 예상할 수 있습니다. roundObserver는 FindNumberViewModel이 생성될때 초기값을 받으므로 [.next(0, 1)]의 결과가 발생할 것을 예상할 수 있습니다.FindNumber_Test 코드 중 test_사용자_선택번호와_타겟이_같을_경우() 함수와 test_사용자_선택번호와_타겟이_다를_경우() 함수를 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)]
)
}
"[1]")을 스텁 데이터로 준비합니다.urlString을 사용하여 URL 객체를 생성하고, 이 URL에 대한 HTTPURLResponse 객체를 스텁으로 준비합니다.URLSessionStub 클래스를 사용하여, 이 스텁 데이터와 응답을 반환하는 가짜 URLSession을 생성합니다.sut)의 urlSession 프로퍼티에 이 스텁을 할당합니다.PublishRelay를 사용하여 게임 시작, 숫자 확인, 스테이지 리셋 이벤트를 준비합니다.Input 구조체를 생성하고, 뷰 모델의 transform 메서드를 호출하여 출력을 가져옵니다.target, round, bestRecord, currentRecord 출력값을 관찰하기 위한 Observer들을 생성합니다.checkNumber 이벤트를 발생시킵니다.scheduler.start()를 호출하여 테스트 스케줄러를 실행합니다.targetObserver, roundObserver, bestRecordObserver, currentRecordObserver의 이벤트가 예상된 이벤트와 일치하는지 검증합니다.targetObservertarget 값이 1로 초기화됩니다.checkNumber() 메서드를 통해 target을 맞추면, target 값이 변경됩니다..next 이벤트가 발생하는 것을 확인합니다.roundObserverbestRecordObserverbestRecord 값의 최댓값이 방출되는 것을 관찰합니다.bestRecord의 동작이 정상임을 확인할 수 있습니다.currentRecordObserverFindNumber_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)]
)
}
PublishRelay 인스턴스들을 생성합니다.FindNumberViewModel.Input 구조체에 전달하여 뷰 모델의 입력을 구성합니다.sut.transform 메서드를 호출하여 입력을 출력으로 변환합니다.round, bestRecord, currentRecord 출력값을 관찰하기 위한 Observer들을 생성하고, 출력에 바인딩합니다.round, bestRecord, currentRecord 값에 대한 가상의 이벤트를 생성하고, 각각의 Observer에 바인딩합니다. 이러한 이벤트들은 시간 10에 발생합니다.resetStage 이벤트를 발생시키면서 스테이지를 초기화합니다.scheduler.start()를 호출하여 테스트 스케줄러를 시작합니다.roundObserver 검증bestRecordObserver 검증currentRecordObserver 검증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)])
}
URLSessionStub을 사용하여 네트워크 요청 실패를 시뮬레이션합니다. 여기서는 stubbedData, stubbedResponse를 nil로 설정하고, stubbedError에 MyError.networkError를 할당합니다.sut.urlSession에 urlSessionStub을 할당합니다.PublishRelay 인스턴스를 생성하고, 이를 FindNumberViewModel.Input 구조체에 전달합니다.errorObserver를 생성하여 ViewModel의 output.error에 바인딩합니다.checkNumber에 1을 전달하여 게임 시작 이벤트를 발생시킵니다.errorObserver가 MyError.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)])
}
"[d]")를 설정하여 디코딩 오류를 시뮬레이션합니다.URLSessionStub을 사용하여 해당 데이터와 함께 정상적인 HTTP 응답을 스텁으로 설정합니다.sut.urlSession에 urlSessionStub을 할당합니다.FindNumberViewModel.Input 구조체를 생성하여 ViewModel에 입력합니다.errorObserver를 생성하고, output.error에 바인딩합니다.scheduler를 사용하여 스케줄링을 설정합니다.gameStart에 이벤트를 전달하여 게임을 시작합니다.errorObserver가 MyError.decodingError를 정확하게 받는지 확인합니다.BlockingObservabletoArray(): Observable이 완료될 때까지 기다린 후, 모든 이벤트를 배열로 반환합니다.first(): 첫 번째 이벤트를 기다렸다가 반환합니다.last(): 마지막 이벤트를 기다렸다가 반환합니다. 이 코드는 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)
}
}
FindNumberSlowTests는 XCTestCase를 상속받는 테스트 클래스입니다.sut (System Under Test)는 테스트에 사용될 URLSession 인스턴스입니다.networkMonitor를 사용하여 네트워크 연결 상태를 확인합니다.setUpWithError 메서드)URLSession 인스턴스를 기본 설정으로 초기화합니다.tearDownWithError 메서드)URLSession 인스턴스를 해제합니다.test_유효한API_호출후_HTTPStatusCode200_받기 메서드)XCTSkipUnless를 사용하여 네트워크 연결이 없을 경우 테스트를 건너뜁니다.URL 객체를 생성합니다.URLSession의 dataTaskObservable을 사용하여 네트워크 요청을 Observable로 변환합니다..compactMap { $0?.statusCode } 으로 변환된 HTTPURLResponse 객체에서 HTTP 상태 코드를 추출합니다. compactMap은 옵셔널 체이닝을 통해 nil이 아닌 경우에만 상태 코드를 반환합니다..toBlocking(timeout: 3): Observable 시퀀스를 BlockingObservable로 변환합니다. 이는 최대 3초 동안 대기하면서 Observable의 이벤트를 기다립니다..first()변환된 BlockingObservable에서 첫 번째 이벤트를 동기적으로 가져옵니다. 이는 첫 번째 네트워크 요청의 결과 (여기서는 HTTP 상태 코드)를 의미합니다.statusCode에 HTTP 상태 코드를 할당합니다.nil인지 (즉, 오류가 없었는지) 확인합니다.제가 학습한 내용을 요약하여 정리한 것입니다. 내용에 오류가 있을 수 있으며, 어떠한 피드백도 감사히 받겠습니다.
감사합니다.