뱅크샐러드 iOS팀이 숨쉬듯이 테스트코드 짜는 방식 3편 - 스펙별 단위 테스트

안녕하세요! 뱅크샐러드에서 iOS개발을 하고 있는 류성두입니다.

저는 상당히 오랜 시간 동안 테스트코드 작성을 대단히 어려운 것, 제한된 범위에서만 가능한 것, 개발 속도를 늦추는 것으로 생각했습니다. 무엇보다도, 테스트코드가 설령 장기적으로는 개발 속도를 빠르게 해줄지라도, 단기적으로는 생산성을 저하시킬 수밖에 없다고 생각했습니다.

하지만 뱅크샐러드에서, 이는 사실이 아니라는 것을 저는 차차 알게 되었습니다. 테스트코드는 쉽고 빠르게 작성할 수 있고, 앱의 기능 대부분을 테스트 할 수 있으며, 무엇보다도, 장기적인 차원에서 뿐만이 아니라 단기적인 차원에서 순간순간의 개발 속도도 훨씬 빠르게 만들어주었습니다. 테스트코드를 어떻게 사용하기에 그럴 수 있었는지, 뱅크샐러드에서 다년간의 시행착오를 통해 쌓아온 각종 노하우를, 총 3편의 글들을 통해 대방출합니다.

1편 - 앱 단위 통합 UI테스트
2편 - 화면 단위 통합 테스트
3편 - 스펙별 단위 테스트👈

단위테스트

뱅크샐러드에서는 최대한 단순하고 일관된 형태로 단위 테스트를 작성할 수 있도록 돕는 도구들을, TestUtility라는 모듈에서 관리하고 있습니다. 이 모듈에서 가장 중요한 도구 몇 가지를 소개합니다.

BaseTestCase

먼저 모든 테스트케이스들의 기반이 되는 BaseTestCase입니다. 모든 테스트코드들이 given, when, then 의 문법으로 짜일 수 있는 구조를 제공합니다. given에서는 해당 시니라오를 재현하기 위해 필요한 데이터를 주입하거나 환경을 설정하고, when에서는 실제 해당 로직을 트리거 하는 코드를 실행시키고, then에서는 그 로직의 출력물을 검사합니다.

open class BaseTestCase: XCTestCase {
    /// 초기에 주입받아야 할 데이터를 지정합니다
    open func given(_ task: () -> Void) {
        task()
    }

    /// 발생해야 할 이벤트, 또는 메소드 호출등을 실행시킵니다
    open func when(_ task: () -> Void) {
        task()
    }

    /// 결과 값이 기대와 같은지 확인합니다
    open func then(_ task: () -> Void) {
        task()
    }
}

RxTestCase

이 BaseTestCase를 상속받아, RxTestCase를 만들었습니다. 뱅크샐러드에서는 RxSwift를 로 대부분의 비동기 로직을 관리하고 있는 만큼, Rx를 통한 입력과 출력을 확인하는 테스트케이스가 대부분입니다.

/// Rx로 만들어진 이벤트 스트림을 테스트하는 테스트케이스
open class RxTestCase<T>: BaseTestCase {
    open var scheduler: TestScheduler!
    open var disposeBag: DisposeBag!
    private var resultObserver: TestableObserver<T>!

    /// 이 TestCase에서 관측하고자 하는 대상을 설정합니다.
    open var eventsToObserve: Observable<T> = .empty() {
        didSet {
            disposeBag = DisposeBag()
            scheduler = TestScheduler(initialClock: 0)
            resultObserver = scheduler.createObserver(T.self)
            eventsToObserve.bind(to: resultObserver).disposed(by: disposeBag)
        }
    }

    open func when(observing events: Observable<T>, _ task: () -> Void) {
        self.eventsToObserve = events
        task()
        executeEvents()
    }

    // 이 테스트에서 처리해야 할 입력을 편하게 생성하도록 합니다.
    open func createEvents<U>(_ events: [Recorded<Event<U>>], to relay: PublishRelay<U>) {
        scheduler.createHotObservable(events)
            .bind(to: relay)
            .disposed(by: disposeBag)
    }

    open func createEvents<U>(_ events: [Recorded<Event<U>>], to subject: PublishSubject<U>) {
        scheduler.createHotObservable(events)
            .bind(to: subject)
            .disposed(by: disposeBag)
    }

    open func executeEvents(advanceTo futureTime: VirtualTimeScheduler<TestSchedulerVirtualTimeConverter>.VirtualTime = 10) {
        scheduler.start()
        scheduler.advanceTo(futureTime)
        scheduler.stop()
    }

    /// 테스트의 결과. 테스트의 마지막에는 , 이 값에 기대한 값이 들어있는지 확인한다.
    open var resultEvents: [Recorded<Event<T>>] {
        resultObserver.events
    }
}

RxTestCase에서는 when(observing: Observer) 메소드가 추가되었습니다. 이 메소드의 매개변수로 들어간 Observer는 최종적으로 resultEvents에 이벤트를 전달해서, 테스트의 마지막인 then 에서는 언제나 이 resultEvents에 기대한 이벤트들이 쌓여있는지를 검사 할 수 있도록 했습니다.

이렇게 입력과 출력을 명확히 함으로써 가독성을 향상시키고, 또 Rx테스트코드 작성을 위해 매번 작성해야했던 TestScheduler 관련 보일러플레이트 코드들을 최대한 줄여, 대부분의 경우, 테스트코드가 3줄 내외로 작성 될 수 있도록 했습니다.

class SampleRxTestCase: RxTestCase<String> {

    func test버튼하나만_누르면_동작_안함() {
        given {
            viewModel = SampleViewModel(data: "Hello")
        }

        when(observing: viewModel.log) {
            createEvents([.next(0, Void())], to: viewModel.aButtonClicked)
        }

        then {
            XCTAssert(resultEvents.isEmpty)
        }
    }

    func test버튼_두개를_눌러야_동작함() {
        given {
            viewModel = SampleViewModel(data: "World")
        }

        when(observing: viewModel.log) {
            createEvents([.next(0, Void())], to: viewModel.aButtonClicked)
            createEvents([.next(1, Void())], to: viewModel.bButtonClicked)
        }

        then {
            XCTAssert(resultEvents == [.next(1, "World")])
        }
    }
} 

EventLoggingTestCase

단위테스트로 반드시 100% 검증되어야 하는 스펙이 딱 하나 있다면 바로 로깅 입니다. 로깅은 육안으로 테스트 할 수 없다보니, 의식적으로 이를 따로 테스트하려고 작정하지 않는 한, 문제가 생겨도 이를 인지하는데 시간이 오래걸립니다. 로깅과 같이 “평소에 눈에 보이지 않는 스펙”이야말로, 가장 먼저 테스트해야 합니다.

뱅크샐러드에서는 이러한 로깅을 EventLogger 라는 도구를 활용해 기록합니다. 그리고 이 도구에서 이벤트를 기록 했는지의 여부를 테스트코드에서 관측할 수 있도록 돕는, eventObserver 라는 속성을 추가했습니다.

따라서 EventLogging관련 코드를 테스트 할 때, 출력은 언제나 이 eventObserver의 내용입니다. 그래서 테스트코드의 출력을 언제나 eventObserver로 바라보게 만드는, EventLoggingTestCase 를 다음과 같이 만들어 활용하고 있습니다.

open class EventLoggingTestCase: RxTestCase<(name: String, properties: [String: Any]?)> {
    open override func setUpWithError() throws {
        try super.setUpWithError()
        EventLogger.eventObserver = PublishSubject<(name: String, properties: [String: Any]?)>()
    }

    open override func when(_ task: () -> Void) {
        eventsToObserve = EventLogger.eventObserver
        task()
        executeEvents()
    }
}

사실, 이런 내용은 너무나 간단한 내용이라 그냥 RxTestCase를 활용해 작성 할 수도 있습니다. 그럼에도 굳이 EventLoggingTestCase를 만든 것은, 그 만큼 이벤트 코드에 대한 테스트의 중요성을 프로젝트 차원에서 강조하기 위함입니다. EventLoggingTestCase로 검색을 했을 때, 관련 예제를 누구나 빠르게 찾아보고 학습할 수 있다는 효과도 있습니다.

PresentationTestCase

화면전환 로직은, 네이티브에서 담당해야 할 로직들을 통합적으로 테스트하기 아주 좋은 지점입니다. 예컨대 어떤 Form을 모두 입력하기 전까지는 다음화면을 노출하지 않고, 모든 Form을 다 적절하게 입력해야만 다음 화면을 노출하는 로직이 있다고 해봅시다. 각 Form 필드의 로직을 하나 하나 테스트하는 것도 좋은 방법이겠지만, 큰 틀에서 “Form전체”를 하나의 인풋으로 보고, 화면전환 여부를 하나의 아웃풋으로 보는 테스트를 작성한다면, 각 Form들에 관한 로직까지 한꺼번에 테스트 할 수 있습니다.

이처럼, Presentation로직 역시 우리가 자주 만들어야 하는 테스트케이스이고, 또한 언제나 같은 형식의 출력을 가지게 되므로 RxTestCase를 상속하여 별도의 Subclass로 만들어 관리하고 있습니다.

open class PresentableLogicTestCase: RxTestCase<Presentable> {
    open var resultPresentableEvents: [Recorded<Event<String?>>] {
        resultEvents.map { event in
            guard let element = event.value.element,
                let description = (element as? CustomDebugStringConvertible)?.debugDescription else {
                return Recorded<Event<String?>>.next(event.time, nil)
            }
            return Recorded<Event<String?>>.next(event.time, description)
        }
    }
}

참고로 뱅크샐러드에서는 모든 화면들을 enum으로 관리하고 있고, 따라서 ViewModel의 출력값은 이 enum이 됩니다. 그런데 enum 그 자체는 기본적으로 Equatable하지 않습니다. 따라서 테스트코드에서는 손쉽게 “비교”를 할 수 있도록, 각 enum별로 debugDescription을 선언해 주고 이것을 비교합니다. 이 debugDsecription안에는, 해당 화면에서 가장 중요한 정보, 예컨대 그 화면에서 표현해야 할 객체의 식별자와 같은 정보가 포함될 수 있도록 합니다. 예컨대, 카드상세 화면이라면, 다음과 같이 debugDescription을 작성 할 수 있습니다.

extension PresentableView: CustomDebugStringConvertible {
    public var debugDescription: String {
        switch self {
        case let .cardSectionDetail(section):
            return "cardSectionDetail for section.\(section.id)"
        }
    }
}

이와 같은 String기반의 debugDescription을 바탕으로 우리는 다음과 같이 테스트코드를 작성 할 수 있습니다.

class SamplePushableTestCase: PushableLogicTestCase {

    func test이름입력하고_제출버튼_누르면_디테일화면_푸시() {
        given {
            viewModel = SampleViewModel(data: "Hello")
        }

        when {
            createEvents([.next(0, "MyName")], to: viewModel.textEdited)
            createEvents([.next(0, Void())], to: viewModel.submitButtonClicked)
        }

        then {
            XCTAssert(resultPushableEvents == [.next(0, "detailView titled Hello")])
        }
    }

    func test이름입력_안하고_제출버튼_누르면_화면이동__() {
        given {
            viewModel = SampleViewModel(data: "Hello")
        }

        when {
            createEvents([.next(0, Void())], to: viewModel.submitButtonClicked)
        }

        then {
            XCTAssert(resultPushableEvents.isEmpty)
        }
    }
}

이와 같이 뱅크샐러드에서는, 테스트코드를 더 쉽게 작성하기 위해 여러 장치들을 만들고 다듬고 있습니다. 테스트코드가 더 쉽게 작성 될 수록, 테스트코드는 더 많이, 그리고 즐겁게 만들어질 수 있기 때문입니다.

테스트코드의 기쁨을 알게된 동료의 주간 회고록. 한 번 그 기쁨을 알게 되면 돌아올 수 없어요!

TDD를 향해서

테스트코드는 사실 구현 전에 작성되었을 때 그 진가를 발휘한다고 생각합니다. 미리 작성된 테스트는 내가 만들 수 있는 코드가 아닌, 내가 만들어야 하는 코드를 작성하도록 강제하니까요. 하지만 막상 TDD로 실제 iOS앱을 개발하려고보면 상당히 막막합니다. 무엇을 어떻게 테스트해야할지 감이 안오는 스펙들이 상당히 많기 때문입니다. 그래서 처음부터 모든 것을 TDD로 개발하려다보면, 이런 늪에 빠져 체력을 소진하고, TDD에 대해 부정적인 인식을 하게 되기 쉽다고 생각합니다.

하지만 처음부터 모든 것을 TDD로 개발 할 필요는 없습니다. TDD로 개발하기 쉬운 영역은 iOS 앱에서도 분명히 존재합니다. 위에서 언급한 EventLogging, Navigation 로직등이 그 예입니다. 맥락과 구현에 따라서, 아마 여러분의 프로젝트에도 상대적으로 테스트하기 쉬운 영역들이 분명 있을 것입니다. 그리고 우리는 그런 영역부터, TDD로 구현하는 연습을 시작 할 수 있습니다. 예컨대 “EventLogging 로직 만큼은, 반드시 TDD로 작성한다”는 규칙을 만들 수 있습니다. EventLogging을 TDD로 구현하는 것에 익숙해지면, 그 다음에는 Navigation로직을 TDD로 작성하고, 그 다음에는 또 다른 영역으로 TDD의 영역을 확장해 나갈 수 있습니다. 그리고 그렇게 몇 번만 영역을 넓히다보면, 어느 새 대부분의 스펙들을 TDD로 작성 할 수 있게 됩니다.

마치며

자동화 테스트는 살아 숨쉬는, 가장 신뢰할 수 있는 스펙문서입니다. 가장 중요한 스펙들일 수록, 또 가장 놓치기 쉬운 스펙들일 수록 테스트 코드로 표현되어야 하며, 그 표현은 극단적으로 쉬워야 합니다. 실천은 쉽지 않으면 발생하지 않기 때문입니다. 뱅크샐러드에서 많은 시행착오를 거쳐 쌓은 이 작은 노하우들이, 여러분들의 실천에 작게나마 도움이 되었으면 좋겠습니다.

보다 빠르게 뱅크샐러드에 도달하는 방법 🚀

지원하기