단위 테스트를 작성하면서 얻은 나의 실수와 교훈

전성훈·2024년 5월 9일
0

iOS/TEST

목록 보기
3/3
post-thumbnail

코드 및 기능 설명

  • 이 글은 사용자가 지역을 선택할 때 관련 데이터가 올바르게 처리되는지 확인하는 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를 사용하고 있습니다. 주요 로직은 사용자가 소분류 지역을 선택할 때 MainRegionSubRegion 정보를 합쳐 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에 대한 테스트 코드를 작성하면서 발생한 문제점

  • MockUseCase
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)
        }
    }
}
  • ViewModelTest
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_상세지역_선택시_지역저장() {
        // given
        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)
        
        // when
        scheduler.scheduleAt(0) {
	        output.viewWillAppearTrigger
		        .drive()
		        .disposed(by: self.disposeBag)
		    
            output.subRegionSelected
                .map { _ in "저장 성공" }
                .drive(completedObserver)
                .disposed(by: self.disposeBag)
        }
        
        scheduler.start()
        
        // then
        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 mockSaveRegion: Result<Region, 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
    ) { 
    	// 개선된 코드
        // if let result = mockSaveRegion {
        //     completion(result)
        // }
        savedRegion = newRegion
    }
}
  • ViewModelTest
    func test_상세지역_선택시_지역저장() {
        // given
        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)
        
        // when
        scheduler.scheduleAt(0) {
            output.viewWillAppearTrigger
                .drive()
                .disposed(by: self.disposeBag)
            
            output.subRegionSelected
                .drive()
                .disposed(by: self.disposeBag)
        }
        
        scheduler.start()
        
        // 개선된 코드 
        // then
        XCTAssertEqual(useCase.savedRegion, expectedRegion)
    }
  • 이러한 접근 방법은 ViewModel의 데이터 처리 방법을 정확하게 검증할 수 있게 됩니다.

결론

  • 이번 테스트 과정을 통해, 단순히 기능이 성공적으로 실행되었는지의 여부보다는 해당 기능을 수행하기 위해 내부에서 어떤 로직이 실행되는지를 정확하게 파악하고 검증하는 것이 더 중요하다는 것을 깨달았습니다.
  • 예를 들어, 저장 메서드가 실행될 때 ViewModel에서 Region 객체가 올바르게 생성되는것을 확인하는 것이 해당 기능의 핵심 테스트 기능이라고 볼 수 있습니다.
  • 즉, 테스트가 단순히 결과의 성공 여부를 확인하는 것을 넘어서, 실제 데이터 처리 과정을 철저히 검증하는 방향으로 진행되어야 한다고 생각합니다.

0개의 댓글