시작하기 앞서, 해당 코드는 번호 찾기 게임: RxSwift로 리팩토링하기(코드)와 번호찾기 게임: Unit test & UI test(코드)를 기반으로 작성되었습니다.
TestScheduler
TestScheduler
를 사용하면, 가상 시간 단위를 기반으로 하여 특정 시점에 어떤 이벤트가 발생하거나, 어떤 상태가 변화하는지 정밀하게 시뮬레이션 할 수 있습니다. 이를 통해 실제 시간을 기다리지 않고도 시간에 의존하는 연산들을 테스트할 수 있습니다. 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
의 이벤트가 예상된 이벤트와 일치하는지 검증합니다.targetObserver
target
값이 1로 초기화됩니다.checkNumber()
메서드를 통해 target
을 맞추면, target
값이 변경됩니다..next
이벤트가 발생하는 것을 확인합니다.roundObserver
bestRecordObserver
bestRecord
값의 최댓값이 방출되는 것을 관찰합니다.bestRecord
의 동작이 정상임을 확인할 수 있습니다.currentRecordObserver
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)]
)
}
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
를 정확하게 받는지 확인합니다.BlockingObservable
toArray()
: 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
인지 (즉, 오류가 없었는지) 확인합니다.제가 학습한 내용을 요약하여 정리한 것입니다. 내용에 오류가 있을 수 있으며, 어떠한 피드백도 감사히 받겠습니다.
감사합니다.