본문 바로가기

TIL

[TID] #9 - Nibmle, Quick, RxTest, RxNimble로 UseCase 및 ViewModel 테스트 코드 작성

Testable한 코드, 책임의 분리, 아키텍쳐의 중요성.. 등등 이런 말은 많이 했지만 '구조'의 중요성에서 항상 언급되는 테스트 코드의 작성에는 소홀했던 것 같다. 따라서 Nimble, Quick 등 테스트를 도와주는 라이브러리 및 RxTest를 통해 UseCase와 ViewModel의 일부 기능에 대한 테스트 코드를 작성해 보았다.

 

1 .XCTest + RxTest

우선 가장 기본적인 XCTest를 이용해서 작성했다. RxBlocking보다는 이벤트를 직관적으로 나열해서 볼 수 있는 RxTest를 이용했다.

 

Setup과 TearDown에서 새로운 인스턴스를 주입하고 제거해준다.

import XCTest

import RxSwift
import RxTest

@testable import BasicTest

final class MyPageUseCaseTest: XCTestCase {
    
    private var scheduler: TestScheduler!
    private var disposeBag: DisposeBag!
    private var myPageUseCase: MyPageUseCase!
    private var myPageRepository: MyPageRepository!
    
    override func setUp() {
        self.scheduler = TestScheduler(initialClock: 0)
        self.disposeBag = DisposeBag()
        self.myPageRepository = MockMyPageRepository()
        self.myPageUseCase = DefaultMyPageUseCase(repository: self.myPageRepository)
    }
    
    override func tearDownWithError() throws {
        self.myPageUseCase = nil
        self.myPageRepository = nil
        self.disposeBag = nil
    }
    
    func test_check_fetching() {
        let fetchedDataOutput = scheduler.createObserver(MyPageEntity?.self)
        
        self.scheduler.createColdObservable([
            .next(10, ())
        ])
        .withUnretained(self)
        .subscribe(onNext: { strongSelf, _ in
            strongSelf.myPageUseCase.fetchMyPageData()
        })
        .disposed(by: self.disposeBag)
        
        self.myPageUseCase.myPageFetched
            .subscribe(fetchedDataOutput)
            .disposed(by: self.disposeBag)
        
        self.scheduler.start()
        
        XCTAssertEqual(fetchedDataOutput.events, [
            .next(10, MockMyPageRepository.sampleFetchedData)
        ])
    }
}

2 .Nimble + RxTest

Nimble은 expect 메서드를 통해 다양한 expectation, equality를 쉽게 비교하게 해준다. 훨씬 편하고 직관적인 것 같다.

import XCTest

import Nimble
import RxSwift
import RxTest

@testable import BasicTest

final class MyPageUseCaseTestWithNimble: XCTestCase {
    
    private var scheduler: TestScheduler!
    private var disposeBag: DisposeBag!
    private var myPageUseCase: MyPageUseCase!
    private var myPageRepository: MyPageRepository!
    
    override func setUp() {
        self.scheduler = TestScheduler(initialClock: 0)
        self.disposeBag = DisposeBag()
        self.myPageRepository = MockMyPageRepository()
        self.myPageUseCase = DefaultMyPageUseCase(repository: self.myPageRepository)
    }
    
    override func tearDownWithError() throws {
        self.myPageUseCase = nil
        self.myPageRepository = nil
        self.disposeBag = nil
    }
    
    func test_check_fetching() {
        let fetchedDataOutput = scheduler.createObserver(MyPageEntity?.self)
        
        self.scheduler.createColdObservable([
            .next(10, ())
        ])
        .withUnretained(self)
        .subscribe(onNext: { strongSelf, _ in
            strongSelf.myPageUseCase.fetchMyPageData()
        })
        .disposed(by: self.disposeBag)
        
        self.myPageUseCase.myPageFetched
            .subscribe(fetchedDataOutput)
            .disposed(by: self.disposeBag)
        
        self.scheduler.start()
        
        expect(fetchedDataOutput.events).to(
            equal([.next(10, MockMyPageRepository.sampleFetchedData)]),
            description: "failed - Repository에서 제공한 데이터와 일치하는지 테스트"
        )
    }
}

3. RxNimble

RxNimble은 expect에서 .events() 메서드가 추가되어 있다. scheduler와 disposebag을 지정할 수 있다.

import XCTest

import Nimble
import RxNimble
import RxSwift
import RxTest

@testable import BasicTest

final class MyPageUseCaseTestWithRxNimble: XCTestCase {
    
    private var scheduler: TestScheduler!
    private var disposeBag: DisposeBag!
    private var myPageUseCase: MyPageUseCase!
    private var myPageRepository: MyPageRepository!
    
    override func setUp() {
        self.scheduler = TestScheduler(initialClock: 0)
        self.disposeBag = DisposeBag()
        self.myPageRepository = MockMyPageRepository()
        self.myPageUseCase = DefaultMyPageUseCase(repository: self.myPageRepository)
    }
    
    override func tearDownWithError() throws {
        self.myPageUseCase = nil
        self.myPageRepository = nil
        self.disposeBag = nil
    }
    
    func test_check_fetching() {
        let fetchedDataOutput = scheduler.createObserver(MyPageEntity?.self)
        
        self.scheduler.createColdObservable([
            .next(10, ())
        ])
        .withUnretained(self)
        .subscribe(onNext: { strongSelf, _ in
            strongSelf.myPageUseCase.fetchMyPageData()
        })
        .disposed(by: self.disposeBag)
        
        self.myPageUseCase.myPageFetched
            .subscribe(fetchedDataOutput)
            .disposed(by: self.disposeBag)
        
        expect(self.myPageUseCase.myPageFetched)
            .events(scheduler: self.scheduler,
                    disposeBag: self.disposeBag)
            .to(equal(
                [.next(10, MockMyPageRepository.sampleFetchedData)]
            ), description: "failed - Repository에서 제공한 데이터와 일치하는지 테스트")
    }
}

4. Quick + RxNimble

Quick은 전혀 다른 형식이다. BDD를 편하게 실행할 수 있다.

이 부분에서 기존 input에 접근할 수 있는 방법이 이미 작성했던 뷰모델에는 존재하지 않아서, 참조할 수 있는 input 프로퍼티를 뷰모델 내부에 선언했다. 테스트할 요소가 많다면 이러한 형식을 따르는 것이 아직은 가장 좋은 방법인 것 같다.

 

그리고 main scheduler 이슈가 발생해서 DispatchQueue를 이용해 스레드를 바꿔줬는데, 분명 뭔가 놓치고 있는 부분이 있는 것 같다. 천천히 고민해볼 생각이다.

import XCTest

import Nimble
import Quick
import RxCocoa
import RxNimble
import RxSwift
import RxTest

@testable import BasicTest

final class MyPageViewModelTest: QuickSpec {
    override func spec() {
        var scheduler: TestScheduler!
        var disposeBag: DisposeBag!
        
        var testViewDidLoad: PublishSubject<Void>!
        var editButtonTapped: PublishSubject<Void>!
        var usernameEditOutput: PublishSubject<MyPageEditableView.EndEditOutput>!
        var usernameAlertDismissed: PublishSubject<Void>!
        var pushSwitchChanged: PublishSubject<Bool>!
        var pushTimePicked: PublishSubject<String>!
        var logoutButtonTapped: PublishSubject<Void>!
        var withdrawlButtonTapped: PublishSubject<Void>!
        
        describe("MyPageVC가 로드되고") {
            var myPageUseCase: MyPageUseCase!
            var myPageViewModel: MyPageViewModel!
            var input: MyPageViewModel.Input!
            var output: MyPageViewModel.Output!
            
            beforeEach {
                scheduler = TestScheduler(initialClock: 0)
                disposeBag = DisposeBag()
                myPageUseCase = MockMyPageUseCase()
                myPageViewModel = MyPageViewModel(useCase: myPageUseCase)
                
                testViewDidLoad = PublishSubject<Void>()
                editButtonTapped = PublishSubject<Void>()
                usernameEditOutput = PublishSubject<MyPageEditableView.EndEditOutput>()
                usernameAlertDismissed = PublishSubject<Void>()
                pushSwitchChanged = PublishSubject<Bool>()
                pushTimePicked = PublishSubject<String>()
                logoutButtonTapped = PublishSubject<Void>()
                withdrawlButtonTapped = PublishSubject<Void>()
            }
            
            afterEach {
                scheduler = nil
                disposeBag = nil
                myPageUseCase = nil
                myPageViewModel = nil
                
                testViewDidLoad = nil
                editButtonTapped = nil
                usernameEditOutput = nil
                usernameAlertDismissed = nil
                pushSwitchChanged = nil
                pushTimePicked = nil
                logoutButtonTapped = nil
                withdrawlButtonTapped = nil
            }
            
            context("닉네임을 편집하려고 하는 상황") {
                
                beforeEach {
                    input = MyPageViewModel.Input(viewDidLoad: testViewDidLoad.asObservable(),
                                                  editButtonTapped: editButtonTapped.asObservable(),
                                                  usernameEditOutput: usernameEditOutput.asObservable(),
                                                  usernameAlertDismissed: usernameAlertDismissed.asObservable(),
                                                  pushSwitchChagned: pushSwitchChanged.asObservable(),
                                                  pushTimePicked: pushTimePicked.asObservable(),
                                                  logoutButtonTapped: logoutButtonTapped.asObservable(),
                                                  withdrawlButtonTapped: withdrawlButtonTapped.asObservable())
                    
                    output = myPageViewModel.transform(from: input, disposeBag: disposeBag)
                    
                    scheduler.createColdObservable([
                        .next(3, Void())
                    ])
                    .bind(to: testViewDidLoad)
                    .disposed(by: disposeBag)
                }
                
                it("먼저 데이터가 불러와진다.") {
                    DispatchQueue.main.async {
                        expect(output.myPageDataFetched)
                            .events(scheduler: scheduler, disposeBag: disposeBag)
                            .to(equal([
                                .next(0, nil),
                                .next(3, MockMyPageUseCase.sampleMyPageInformation),
                            ]))
                    }
                }
                
                context("editButton을 두 번 누르면") {
                    beforeEach {
                        scheduler.createColdObservable([
                            .next(15, Void()),
                            .next(30, Void())
                        ])
                        .bind(to: editButtonTapped)
                        .disposed(by: disposeBag)
                    }
                    
                    it("첫 번째에 닉네임 편집이 시작된다") {
                        DispatchQueue.main.async {
                            expect(output.startUsernameEdit)
                                .events(scheduler: scheduler, disposeBag: disposeBag)
                                .to(equal([
                                    .next(15, true)
                                ]))
                        }
                    }
                    
                    it("두 번은 눌러도 변화가 없다") {
                        DispatchQueue.main.async {
                            expect(output.startUsernameEdit)
                                .events(scheduler: scheduler, disposeBag: disposeBag)
                                .notTo(equal([
                                    .next(30, true)
                                ]))
                        }
                    }
                }
            }
        }
    }
}

느낀점

테스트 코드 작성도 어려운 일은 아니었지만, 뷰 구현 및 비즈니스 로직 작성과 마찬가지로 익숙해지면 자연스럽게 작성할 수 있을 것 같다. 아직은 어떤 단위로 어떤 메서드를 테스트해야 할 것인지에 대해 고민하는 시간이 조금 있었다. 좋은 테스트코드를 작성하는 방법에 대해서도 계속해서 고민해야겠다.

 

테스트에 대한 참고 글

https://medium.com/@enricopiovesan/unit-testing-in-swift-tutorial-92daab95246b

https://medium.com/cobe-mobile/unit-testing-in-swift-part-1-the-philosophy-9bc85ed5001b

 

위의 내용과 별개로 Container로 좋은 레포가 있어서 아카이빙..

https://github.com/hmlongco/Factory/blob/main/Sources/Factory/Factory.swift