테스트?

개발을 꽤 오랜시간 해왔었지만 Unit Test, TDD 같은 말만 들어 보고 테스트 코드는 단 한줄도 작성해본적이 없었는데요. 개인적으론 어차피 개발하면서 테스트 다 하는데 굳이 해야하나? 하며 테스트 코드를 작성 할 시간에 다른 기능하나 더 만들자는 생각을 가지고 있었습니다. 하지만 프로젝트 크기가 점점 커지면서 여러 사이드 이펙트들이 많이 생겨나기 시작했습니다. TDD는 아니라도 지금 현재 프로젝트의 아키텍처인 MVVM에서 최대한 효율적인 방법으로 Test를 짜보자는 생각에 많은 블로그와 Github 소스들을 뒤져서 빠르게 습득한 후 적용해봤습니다!

프로젝트 세팅

일단 ViewModel을 테스트 하기 위해 가장 먼저 해야 할 것은 무엇일까요??

네 맞습니다. 바로 프로젝트 생성이죠 ㅋㅋ

SwiftUI를 사용하고 싶지만 현재 Target Version 때문에 실무에서 사용하기 힘드니 UI는 Storyboard로 선택하고 생성해줍니다. 우리는 테스트를 해야되니 include Unit Tests에 체크 해주시구요.

target 'MVVMRxSwiftTest' do

    use_frameworks!
    pod 'RxSwift'
    pod 'RxCocoa'

    target 'MVVMRxSwiftTestTests' do
    inherit! :search_paths
    pod 'RxTest'
    pod 'RxNimble/RxTest'
    end

end

다음과 같이 Podfile을 작성한 후 라이브러리들을 설치해줍니다. 눈치채셨겠지만 RxSwift의 스트림 테스트를 위한 RxTest와 테스트의 편의성을 더해줄 RxTest용 RxNimble을 설치해줬습니다.

샘플앱 만들기

일단 샘플앱으로 간단한 계수기 앱을 만들어 보겠습니다.

ViewModel을 먼저 설계 해보겠습니다.
https://github.com/sergdort/CleanArchitectureRxSwift#application-1 을 참고해 설계했습니다.

protocol ViewModelType: class {
    associatedtype Input
    associatedtype Output

    func transform(input: Input) -> Output
}

위와 같은 프로토콜을 하나 만들어 준 후

final class CounterViewModel: ViewModelType {
    
    struct Input {
    }
    
    struct Output {
    }
    
    func transform(input: Input) -> Output {
    }
}

다음과 같은 ViewModel을 하나 만들어줍니다.

해당 ViewModel은 위에서 만든 ViewModelType 이라는 프로토콜을 채택해줍니다.

그리고 위와 같이 세팅 해줍니다.

transform(input:) 함수는 InputOutput으로 변환해줍니다. 물론 우리가 직접 작성해야죠 ㅋㅋ

struct Input {
        var plusAction: Observable<Void>
        var subtractAction: Observable<Void>
}

먼저 위와 같이 Input을 작성해보겠습니다.

Input 구조체 안에 있는 plusActionsubtractAction은 각각 버튼에서 tap Observable을 구독하기 위해 받아주구요.

struct Output {
        var countedValue: Driver<Int>
}

그 다음 Output 도 작성해줍니다. Output 구조체 안에 있는 countedValue는 현재 계수된 값을 전달합니다.

let disposeBag = DisposeBag()

func transform(input: Input) -> Output {
        let countedValue = BehaviorRelay(value: 0)
        
        input.plusAction
        .subscribe(onNext: { _ in
            countedValue.accept(countedValue.value + 1)
        }).disposed(by: disposeBag)
        
        input.subtractAction
                .subscribe(onNext: { _ in
                        countedValue.accept(countedValue.value - 1)
                }).disposed(by: disposeBag)
        
        return Output(countedValue: countedValue.asDriver(onErrorJustReturn: 0))
}

transform(input:) 는 다음과 같이 작성해줍니다.

Rx를 써보신분들이라면 당연히 아시겠지만 BehaviorRelay 하나 만들어 놓고 값을 가감 시켜주는 코드입니다.

자, ViewModel은 완성되었구요. 이제 이 ViewModel을 보고있을 View를 만들어 보겠습니다!

final class CounterViewController: UIViewController {

    @IBOutlet var countLabel: UILabel!
    @IBOutlet var plusButton: UIButton!
    @IBOutlet var subtractButton: UIButton!
    
    var disposeBag = DisposeBag()
    var viewModel = CounterViewModel()
    
    private lazy var input = CounterViewModel.Input(plusAction: plusButton.rx.tap.asObservable(),
                                                    subtractAction: subtractButton.rx.tap.asObservable())
    private lazy var output = viewModel.transform(input: input)
    
    override func viewDidLoad() {
        super.viewDidLoad()
        bindViewModel()
    }
    
    private func bindViewModel() {
        output.countedValue.map { String($0) }.drive(countLabel.rx.text).disposed(by: disposeBag)
    }
}

간단하게 Storyboard로 UI를 짠 후 ViewController를 작성해봤습니다.

input 프로퍼티와 output 프로퍼티는 아까만든 CounterViewModel 을 활용하여 선언해줬습니다.

이렇게 작성한 코드를 Run해보면??

알흠다운 계수기 앱이 완성되었습니다!

자, 이렇게 간단한 계수기 앱이지만 누군가가 ViewModel 코드를 바꿀 수도 있고 외부 라이브러리나 내부 코드에서 비즈니스 로직이 변경되어 해당 로직이 비정상적으로 작동할 수 있겠죠?

하지만 우리에겐 이 ViewModel 항상 신뢰하는 방법이 있습니다!

바로 테스트 코드를 작성하는것이죠!

테스트 코드 작성하기

이제 드디어 테스트 코드를 작성 할 시간입니다.

일단 프로젝트 생성시에 같이 생성된 Tests 파일을 열어봅시다.

그 후 다음과 같이 코드를 덮어 씌워줍니다.

class CounterTests: XCTestCase {
    
    override func setUp() {                                  
    }
    
    func testCountedValue() {
    }
}

이제 저 빈속들을 또 채울 시간이네요.

일단 class 내에 다음과 같이 껍데기 선언을 해줍니다.

class CounterTests: XCTestCase {
    
    var viewModel: CounterViewModel!
    var output: CounterViewModel.Output!
    var scheduler: TestScheduler!
    var disposeBag: DisposeBag!
    
    var plusSubject: PublishSubject<Void>!
    var subtractSubject: PublishSubject<Void>!
}

테스트를 하나하나 실행할때마다 setUp() 메소드에서 값들을 초기화해주기 때문에 껍데기로 선언해줍니다.

setup() 메소드를 다음과 같이 채워줍니다.

override func setUp() {
    scheduler = TestScheduler(initialClock: 0)
    disposeBag = DisposeBag()
    plusSubject = PublishSubject<Void>()
    subtractSubject = PublishSubject<Void>()
    viewModel = CounterViewModel()
    output = viewModel.transform(input: .init(plusAction: plusSubject.asObservable(),
                                                subtractAction: subtractSubject.asObservable()))
}

그러고 난 후에 드디어 countedValue 대한 테스트 코드를 짜보겠습니다.

func testCountedValue() {
        scheduler.createColdObservable(
                [
                        .next(10, ()),
            .next(20, ()),
            .next(30, ())
        ]
    ).bind(to: plusSubject).disposed(by: disposeBag)
        
    scheduler.createColdObservable(
        [
            .next(25, ())
        ]
    ).bind(to: subtractSubject).disposed(by: disposeBag)

    expect(self.output.countedValue).events(scheduler: scheduler, disposeBag: disposeBag).to(equal(
        [
            .next(0, 0),
            .next(10, 1),  
            .next(20, 2),      
            .next(25, 1),
            .next(30, 2)
        ]
    ))
}

자 뭔가 나왔는데요?

천천히 한번 훑어보겠습니다.

간단 하게 생각하면 우리가 만든 ViewModel안에 있는 Observable에 특정 가상시간에 특정 값을 emit해준다고 생각하시면됩니다. 그 후 나온 값과 테스트 성공을 예측한 값을 비교해서 확인하는거죠!

작성된 스트림을 펼쳐서 보면 가상시간 10 → +1

가상시간 20 → +1

가상시간 25 → -1 가상시간 30 → +1 위와 같습니다.

계속 가상시간을 강조하는 이유는 실제 시간과는 다른 말그대로 테스트만을 위한 가상의 시간이기 때문입니다.

위 코드 구조를 그림으로 설명하면 다음과 같습니다.

위에서 만든 TestSchedulerColdObservable의 emit들을 예약해놓고 마지막 expect에서 성공 예측 값과 비교해줍니다. 비교 값은 [Recorded<Event<T>>] 타입입니다.

그런데 위 그림에 있는 TestableObserver 이 현재 작성된 코드에는 존재하지않는데요? 어떻게 된 일일까요?

자 이게 바로 RxNimble 덕분에 가능한것인데요. expect 메소드에 체이닝된 events(scheduler: disposeBag:) 메소드 내부를 확인해보시면

func events(scheduler: TestScheduler,
            disposeBag: DisposeBag,
            startAt initialTime: Int = 0) -> Expectation<RecordedEvents<T.Element>> {
    return transform { source in
                let results = scheduler.createObserver(T.Element.self)

        scheduler.scheduleAt(initialTime) {
            source?.asObservable().subscribe(results).disposed(by: disposeBag)
        }
        scheduler.start()

        return results.events
    }
}

이 친구가 알아서 TestableObserver를 만들고 있었던거죠 TestScheduler 를 시작하는 코드도 보이는군요.

이렇게 만든 테스트를 실행해보면?

이렇게 테스트가 성공한것을 볼 수 있습니다!

하지만 일단 테스트부터 믿어야겠죠?

처음에 만든 CounterViewModeltransform 에서 plusAction과 substractAction의 로직을 서로 반대로 변경해보겠습니다.

func transform(input: Input) -> Output {
    let countedValue = BehaviorRelay(value: 0)
        
    input.plusAction
        .subscribe(onNext: { _ in
            countedValue.accept(countedValue.value - 1)
        }).disposed(by: disposeBag)
    
    input.subtractAction
        .subscribe(onNext: { _ in
            countedValue.accept(countedValue.value + 1)
        }).disposed(by: disposeBag)
    
    return Output(countedValue: countedValue.asDriver(onErrorJustReturn: 0))
}

자 이 상태에서 테스트를 실행하면?

와우 테스트가 실패했습니다!

error: -[MVVMRxSwiftTestTests.CounterTests testCountedValue] : expected to emit <[AnyEquatable<Recorded<Event<Int>>>(_target: next(0) @ 0, _comparer: (Function)), AnyEquatable<Recorded<Event<Int>>>(_target: next(1) @ 10, _comparer: (Function)), AnyEquatable<Recorded<Event<Int>>>(_target: next(2) @ 20, _comparer: (Function)), AnyEquatable<Recorded<Event<Int>>>(_target: next(1) @ 25, _comparer: (Function)), AnyEquatable<Recorded<Event<Int>>>(_target: next(2) @ 30, _comparer: (Function))]>, got <[next(0) @ 0, next(-1) @ 10, next(-2) @ 20, next(-1) @ 25, next(-2) @ 30]>

에러 코드는 이렇게 뜨는데요.

뭐 대충 "니가 예측한 순서 및 값이랑 다르다." 그런 뜻입니다.

여기까지 간단하게 ViewModel을 테스트 해봤는데요?

하지만 실제로 사용하기엔 뭔가 빠진 듯한 느낌이 드는데요… 그렇습니다. 바로 HTTP 통신입니다.

HTTP 통신 테스트하기

평소 HTTP 통신을 위한 Layer 라이브러리로 Moya 를 사용하기 때문에 예시는 Moya를 활용하여 작성하겠습니다.

처음에 만든 Podfile을 다음과 같이 수정 하여 RxSwift를 위한 Moya 라이브러리를 설치해줍니다.

target 'MVVMRxSwiftTest' do

    use_frameworks!
    pod 'RxSwift'
    pod 'RxCocoa'
    pod 'Moya/RxSwift'

    target 'MVVMRxSwiftTestTests' do
    inherit! :search_paths
    pod 'RxTest'
    pod 'RxNimble/RxTest'
    end

end

디펜던시로 인해 설치가 잘 안되시면 pod update 해주세요.

그리고 다음과 같은 상황을 가정하고 기존 코드를 수정해줍니다.

  • 서버에서 받아온 데이터로 기본값을 세팅한다
  • 서버에서 받아온 데이터는 JSON 형식이며 명세는 다음과 같다
{
    "counterDefaultValue": 0
}

여기서 갑자기 MVVM 장점 어필 타임!

우리는 이 작업을 위해 View의 코드는 refresh Input을 제외하곤 단 하나도 건들필요가 없습니다! (와~ 머박~)

struct CounterDataModel: Codable {   
    var counterDefaultValue: Int
}

일단 갓이프트의 킹더블 써서 모델 하나 만들어준 후

레이어로 사용 할 TargetType를 하나 만들어 줍니다.

struct CounterAPI: TargetType {
    var baseURL: URL {
        URL(string: "https://swift.org")!
    }
    
    var path: String {
        ""
    }
    
    var method: Moya.Method {
        .get
    }
    
    var sampleData: Data {
        "}".data(using: .utf8)!
    }
    
    var task: Task {
        .requestPlain
    }
    
    var headers: [String : String]? {
        nil
    }
}

TargetType 채택 후 필수 프로퍼티들을 다 생성해줬구요. 실무에선 보통 도메인별로 묶어서 만들기 때문에 enum으로 만들긴하지만 우리는 실제 통신은 하지 않을거니 대충 적어주시면됩니다.

CounterViewModel을 다음과 같이 수정해보겠습니다.

final class CounterViewModel: ViewModelType {
    
    let disposeBag = DisposeBag()
    var provider = MoyaProvider<CounterAPI>()
    
    struct Input {
        var refresh: Observable<Void>
        var plusAction: Observable<Void>
        var subtractAction: Observable<Void>
    }
    
    struct Output {
        var countedValue: Driver<Int>
    }
    
    func transform(input: Input) -> Output {
        let countedValue = BehaviorRelay(value: 0)
        
        let counterObservable = input.refresh
            .flatMapLatest { [provider] _ in
                return provider.rx.request(.init())
                    .map(CounterDataModel.self)
        }.share()
        
        counterObservable.map { $0.counterDefaultValue }
            .subscribe(onNext: { defaultValue in
                countedValue.accept(defaultValue)
            }).disposed(by: disposeBag)
        
        input.plusAction
            .skipUntil(counterObservable)
            .subscribe(onNext: { _ in
                countedValue.accept(countedValue.value + 1)
            }).disposed(by: disposeBag)
        
        input.subtractAction
            .skipUntil(counterObservable)
            .subscribe(onNext: { _ in
                countedValue.accept(countedValue.value - 1)
            }).disposed(by: disposeBag)
        
        return Output(countedValue: countedValue.asDriver(onErrorJustReturn: 0))
    }
}

보시면 아시겠지만

Inputrefresh 와 아까만든 TargetTypeTarget으로 한 MoyaProvider 가 생긴것을 볼 수 있습니다.

transform(input:) 위에서 적은 상황에 맞는 통신을 위한 코드가 작성되어있구요.

CounterViewModel.Input 초기화하는 곳에서 refresh 추가해주는거 잊지 마시구요.

자 이제 다시 테스트 코드를 작성하러 가봅시다

var viewModel: CounterViewModel!
var output: CounterViewModel.Output!
var scheduler: TestScheduler!
var disposeBag: DisposeBag!

var refreshSubject: PublishSubject<Void>!
var plusSubject: PublishSubject<Void>!
var subtractSubject: PublishSubject<Void>!

override func setUp() {
    scheduler = TestScheduler(initialClock: 0)
    disposeBag = DisposeBag()
    refreshSubject = PublishSubject<Void>()
    plusSubject = PublishSubject<Void>()
    subtractSubject = PublishSubject<Void>()
    viewModel = CounterViewModel()
    output = viewModel.transform(input: .init(refresh: .just(()),
                                                plusAction: plusSubject.asObservable(),
                                                subtractAction: subtractSubject.asObservable()))
}

위와 같이refreshSubject 껍데기 선언 후 setUp() 에 초기화 코드를 넣어줍니다.

Mock 데이터를 쉽게 만들기 위해서 다음과 같은 확장을 해줍니다.

extension Endpoint {
    class func succeedEndpointClosure<T: TargetType, E: Encodable>(_ targetType: T.Type, with object: E) -> (T) -> Endpoint {
        return { (target: T) -> Endpoint in
            let data = try! JSONEncoder().encode(object)
            return Endpoint(url: URL(target: target).absoluteString,
                            sampleResponseClosure: {.networkResponse(200, data)},
                            method: target.method,
                            task: target.task,
                            httpHeaderFields: target.headers)
        }
    }
}

뭐 대충 킹갓 코더블써서 Mock MoyaProvider용 sampleResponse 만들어 주는 코드입니다.

그 후 output 초기화 전에 다음과 같이 방금 만든 확장함수를 사용하여 viewModelprovider를 초기화해줍니다.

override func setUp() {
    scheduler = TestScheduler(initialClock: 0)
    disposeBag = DisposeBag()
    refreshSubject = PublishSubject<Void>()
    plusSubject = PublishSubject<Void>()
    subtractSubject = PublishSubject<Void>()
    viewModel = CounterViewModel()
    viewModel.provider = MoyaProvider<CounterAPI>(endpointClosure: Endpoint.succeedEndpointClosure(CounterAPI.self, with: CounterDataModel(counterDefaultValue: 5)),
                                                    stubClosure: MoyaProvider.immediatelyStub)
    output = viewModel.transform(input: .init(refresh: .just(()),
                                                plusAction: plusSubject.asObservable(),
                                                subtractAction: subtractSubject.asObservable()))
}

코드 보시면 아시겠지만 서버에서 받아온 기본값이 5일때의 상황입니다.

func testCountedValue() {
    scheduler.createColdObservable(
        [
            .next(0, ())
        ]
    ).bind(to: refreshSubject).disposed(by: disposeBag)
    
    scheduler.createColdObservable(
        [
            .next(10, ()),
            .next(20, ()),
            .next(30, ())
        ]
    ).bind(to: plusSubject).disposed(by: disposeBag)
    
    scheduler.createColdObservable(
        [
            .next(25, ())
        ]
    ).bind(to: subtractSubject).disposed(by: disposeBag)

    expect(self.output.countedValue).events(scheduler: scheduler, disposeBag: disposeBag).to(equal(
            [
                .next(0, 5),
                .next(10, 6),
                .next(20, 7),
                .next(25, 6),
                .next(30, 7)
            ]
    ))
}

자 이제 위와 같이 refresh 를 위한 ColdObservable 를 예약 해줄 코드와 상황에 맞는 성공 예측 값만 설정 해주면?

이렇게 간단하게 HTTP 통신 테스트 까지 끝나고 ViewModel을 믿을 수 있게 되었습니다~!

최종 코드

마무리

회사에서 리햅이라는 시간을 제공해줘서 처음으로 테스트코드를 작성해봤는데요. 아무래도 RxSwift를 사용 중 이라 스트림을 테스트 하는거라 생각보다 개념이해에서 많은 시간을 소요한거같네요. 아직 TDD를 꼭해야하나? 라는 생각이 들긴하지만 테스트코드 작성은 생각보다 쉽고 작성하면 오는 이점이 많다라는 점에서 TDD를 하지 않아도 테스트 코드는 항상 옳다라는 생각을 하면서 새로작업하는 기능이나 기존 기능 유지보수시에 테스트 코드를 작성해야겠다라는 생각이들었습니다.

감사합니다!

"테다익선" 테스트는 많을수록 좋다.