코드 및 기능 설명
- 이 글은 사용자가 지역을 선택할 때 관련 데이터가 올바르게 처리되는지 확인하는
ViewModel에 대해 설명하고 있습니다.
- 사용자는 대분류 지역을 선택하면 소분류 지역이 나타나고, 소분류 지역을 선택하면 해당 지역 정보가 저장되면서 화면이 pop되는 기능이 있습니다.
- 이때 대분류 지역은
MainRegion, 소분류의 지역은 SubRegion의 구조체로 만들어져있습니다.
struct Regions: Equatable {
let mainRegion: [MainRegion]
static func == (lhs: Regions, rhs: Regions) -> Bool {
return lhs.mainRegion == rhs.mainRegion
}
}
struct MainRegion: Equatable {
let region: String
let subRegion: [SubRegion]
var isSelected: Bool
}
struct SubRegion: Equtable {
let region: String
var themeCount: Int?
}
- 아래는 소분류 지역을 선택 할 때 저장할 구조체인
Region 구조체입니다.
struct Region: Equatable {
let mainRegion: String
let subRegion: String
static func == (lhs: Region, rhs: Region) -> Bool {
return lhs.mainRegion == rhs.mainRegion && lhs.subRegion == rhs.subRegion
}
}
기존 ViewModel
- 기존의
RegionSettingViewModel은 RxSwift를 사용하고 있습니다. 주요 로직은 사용자가 소분류 지역을 선택할 때 MainRegion과 SubRegion 정보를 합쳐 Region 구조체로 변환하고, 이를 저장하는 기능을 포함하고 있습니다.
import UIKit
import RxSwift
import RxCocoa
final class RegionSettingViewModel: NagazaViewModel {
private weak var actions: RegionSettingCoordinatorActions?
private let regionSettingUseCase: RegionSettingUseCaseProtocol!
struct Input {
let subRegionSelected: Driver<SubRegion>
}
struct Output {
let subRegionSelected: Driver<Void>
}
private let mainRegions = BehaviorRelay<[MainRegion]>(value: [])
private let subRegions = BehaviorRelay<[SubRegion]>(value: [])
func transform(input: Input) -> Output {
let subRegionSelected = input.subRegionSelected
.flatMapLatest { [weak self] subRegion -> Driver<Void> in
guard let self = self else { return .just(()) }
return self.saveSelectedRegion(subRegion: subRegion)
}
return Output(
subRegionSelected: subRegionSelected,
)
}
private func saveSelectedRegion(subRegion: SubRegion) -> Driver<Void> {
let selectedMainRegion = mainRegions.value.first(where: { $0.isSelected })?.region ?? "전국"
return Observable.create { [weak self] observer in
let region = Region(
mainRegion: selectedMainRegion,
subRegion: subRegion.region
)
self?.regionSettingUseCase.saveRegion(newRegion: region) { result in
switch result {
case .success(_):
observer.onNext(())
case .failure(let error):
self?.errorSubject.onNext(error)
observer.onError(error)
}
}
return Disposables.create()
}
.observe(on: MainScheduler.instance)
.do(onNext: { [weak self] in
self?.popViewController()
})
.asDriverOnErrorJustEmpty()
}
}
기존 ViewModel에 대한 테스트 코드를 작성하면서 발생한 문제점
final class MockRegionSettingUseCase: RegionSettingUseCaseProtocol {
var mockFetchRetionsResult: Result<Regions, Error>? = nil
var mockSaveRegion: Result<Region, Error>? = nil
func fetchRegions(
isRequestThemesCount: Bool,
completion: @escaping (Result<Regions, Error>) -> Void
) {
if let result = mockFetchRegionsResult {
completion(result)
}
}
func saveRegion(
newRegion: Region,
completion: @escaping (Result<Region, Error>) -> Void
) {
if let result = mockSaveRegion {
completion(result)
}
}
}
final class RegionSetiingViewModelTest: XCTestCase {
private var viewModel: RegionSettingViewModel!
private var useCase: MockRegionSettingUseCase!
private var scheduler: TestScheduler!
private var disposeBag: DisposeBag!
override func setUpWithError() throws {
try super.setUpWithError()
useCase = MockRegionSettingUseCase()
viewModel = RegionSettingViewModel(
isRequestThemeCount: true,
regionSettingUseCase: useCase
)
scheduler = TestScheduler(initialClock: 0)
disposeBag = DisposeBag()
}
override func tearDownWithError() throws {
useCase = nil
viewModel = nil
scheduler = nil
disposeBag = nil
try super.tearDownWithError()
}
func test_상세지역_선택시_지역저장() {
let mockRegions = mockRegionsWithThemeCount
let selectedSubRegion = SubRegion(region: "강남", themeCount: 200)
useCase.mockFetchRegionsResult = .success(mockRegions)
useCase.mockSaveRegion = .success(selectedRegion)
let viewWillAppear = scheduler.createColdObservable([.next(0, ())])
let subRegionSelected = scheduler.createColdObservable([.next(10, selectedSubRegion)])
.asDriverOnErrorJustEmpty()
let input = RegionSettingViewModel.Input(
viewWillAppearTrigger: viewWillAppear,
mainRegionSelected: Driver.empty(),
subRegionSelected: subRegionSelected,
popViewControler: Driver.empty()
)
let output = viewModel.transform(input: input)
let completedObserver = scheduler.createObserver(String.self)
scheduler.scheduleAt(0) {
output.viewWillAppearTrigger
.drive()
.disposed(by: self.disposeBag)
output.subRegionSelected
.map { _ in "저장 성공" }
.drive(completedObserver)
.disposed(by: self.disposeBag)
}
scheduler.start()
XCTAssertEqual(
completedObserver.events,
[
.next(10, "저장 성공")
]
)
}
}
}
- 기존 테스트 코드는
MockRegionSettingUseCase를 사용하여 SubRegion 선택 시 저장이 성공적으로 되는지 확인합니다.
- 그러나, 이 테스트는 ViewModel 내부에서 생성되는
Region구조체의 데이터가 예상대로 처리되는지를 실제로 검증하지 못했습니다.
- 테스트는 단순히
UseCase의 성공 응답을 받고, 성공 메시지를 출력하는 것으로 종료되고 있기 때문입니다.
개선된 테스트 코드
- 테스트를 개선하기 위해
MockRegionSettingUseCase내에 savedRegion 속성을 추가하여 실제로 저장되는 Region 객체를 캡처하고, 이를 기대 결과와 비교합니다.
- MockUseCase
final class MockRegionSettingUseCase: RegionSettingUseCaseProtocol {
var mockFetchRetionsResult: Result<Regions, Error>? = nil
var savedRegion: Region?
func fetchRegions(
isRequestThemesCount: Bool,
completion: @escaping (Result<Regions, Error>) -> Void
) {
if let result = mockFetchRegionsResult {
completion(result)
}
}
func saveRegion(
newRegion: Region,
completion: @escaping (Result<Region, Error>) -> Void
) {
savedRegion = newRegion
}
}
func test_상세지역_선택시_지역저장() {
let mockRegions = mockRegionsWithThemeCount
let selectedSubRegion = SubRegion(region: "강남", themeCount: 200)
let expectedRegion = Region(mainRegion: "서울", subRegion: "강남")
useCase.mockFetchRegionsResult = .success(mockRegions)
let viewWillAppear = scheduler.createColdObservable([.next(0, ())])
.asDriverOnErrorJustEmpty()
let subRegionSelected = scheduler.createColdObservable([.next(10, selectedSubRegion)])
.asDriverOnErrorJustEmpty()
let input = RegionSettingViewModel.Input(
viewWillAppearTrigger: viewWillAppear,
mainRegionSelected: Driver.empty(),
subRegionSelected: subRegionSelected,
popViewControler: Driver.empty()
)
let output = viewModel.transform(input: input)
scheduler.scheduleAt(0) {
output.viewWillAppearTrigger
.drive()
.disposed(by: self.disposeBag)
output.subRegionSelected
.drive()
.disposed(by: self.disposeBag)
}
scheduler.start()
XCTAssertEqual(useCase.savedRegion, expectedRegion)
}
- 이러한 접근 방법은 ViewModel의 데이터 처리 방법을 정확하게 검증할 수 있게 됩니다.
결론
- 이번 테스트 과정을 통해, 단순히 기능이 성공적으로 실행되었는지의 여부보다는 해당 기능을 수행하기 위해 내부에서 어떤 로직이 실행되는지를 정확하게 파악하고 검증하는 것이 더 중요하다는 것을 깨달았습니다.
- 예를 들어, 저장 메서드가 실행될 때
ViewModel에서 Region 객체가 올바르게 생성되는것을 확인하는 것이 해당 기능의 핵심 테스트 기능이라고 볼 수 있습니다.
- 즉, 테스트가 단순히 결과의 성공 여부를 확인하는 것을 넘어서, 실제 데이터 처리 과정을 철저히 검증하는 방향으로 진행되어야 한다고 생각합니다.