💻🏃‍♀️🏋️‍♀️
개발 아카이브
💻🏃‍♀️🏋️‍♀️
전체 방문자
오늘
어제
  • 분류 전체보기 (10)
    • 🍎 iOS (10)
      • 📱 iOS (4)
      • ⚡️ RxSwift (2)
      • 🚀 CI CD (1)
      • ♻️ 모듈화 (3)
    • 💻 Computer Science (0)
    • 💬 회고 (0)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • ReactiveX
  • alamofire
  • network
  • 프로젝트관리
  • tuist
  • RequestInterceptor
  • SwiftGen
  • SWIFT
  • Quick
  • merge
  • 반응형
  • XcodeGen
  • 비동기
  • Ribs
  • 라이브러리
  • Moya
  • RxSwift
  • operator
  • 모듈화
  • MVVM
  • CI/CD
  • MVVM-C
  • conflict
  • cleanarchitecture
  • Github Actions
  • 앱개발
  • Clean Architecture
  • Modular Architecture
  • kingfisher
  • IOS

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
💻🏃‍♀️🏋️‍♀️

개발 아카이브

🍎 iOS/📱 iOS

BDD로 테스트하기(feat. Quick)

2023. 10. 29. 00:22

도입 계기

아래와 같은 이유를 통해서 BDD(Behavior-Driven Development)를 도입하기로 결정하였다.

  1. 명확한 요구 사항 이해:
    개발과 기획에서 모두가 이해하기 쉬운 플로우로 요구사항을 명확히 할 수 있다.
    이를 통해 높은 수준의 통일성과 명확성을 유지할 수 있다.
  2. 문서화와 테스팅의 통합:
    BDD는 자연어 스타일의 표현을 사용하여 테스트 케이스를 작성한다. 테스트 케이스는 동시에 문서 역할도 하므로, 코드의 기능이 어떻게 동작해야 하는지 명확하게 알려주는 동시에, 추후에 변경이나 확장이 이루어질 때 문서의 역할도 할 수 있다.
  3. 코드 품질 향상:
    BDD는 TDD(Test-Driven Development)의 원칙을 기반으로 하기 때문에, 테스팅이 중요한 부분을 차지한다. 따라서 코드 품질을 높이고 버그를 미리 잡을 수 있다.
  4. 개발 및 유지보수 속도 향상:
  5. 테스트 케이스가 명확하고, 그에 따른 코드 구현이 검증되므로 개발 과정에서 발생할 수 있는 오류나 불명확한 부분을 빠르게 파악하고 수정할 수 있다.

BDD를 도입함으로써, 비즈니스 로직의 정확성을 높이고, 개발 및 유지보수 과정에서 시간과 노력을 절약이 기대되었다.

또한 BDD를 통해서 같은 팀의 iOS 개발자가 아닌 서버 개발자 또한 이해하기 좋은 환경을 제공하여, 팀의 생산성을 전반적으로 향상시킬 수 있을 것으로 예상되었다.

구현 과정

좋은 코드에서는 내가 테스트하고 싶은 부분만 독립적으로 테스트를 할 수 있어야 한다고 생각을 했다. 그러기 위해서 MVVM-C와 Clean Architecture를 사용해서 의존성을 최대한 분리하고 각각의 Layer를 정확하게 나눠서 각각의 객체가 본인의 역할에 맞게 일을 할 수 있도록 해주었다.

테스트를 진행을 하면서 Domain Layer와 Data Layer는 Clean Architecture를 통해서 간단하게 로직을 테스트해볼 수 있었다.

하지만 Presentation Layer의 경우는 뷰 로직이 복잡해지는 경우를 대비해서 테스트가 어렵게 되었고, Presentation Layer의 테스트 코드의 획일화를 Input Output 구조를 통해서 간단하게 이룰 수 있었다.

또한 모든 코드에 추상화를 해두었기 때문에 Mock을 구현하여 간편하게 로직을 테스트해볼 수 있었다.

Input, Output 구조를 통한 테스트

QuestionViewModel을 예시로 들어서 설명해보겠다.

QuestionViewModel 코드

@testable import Brainterview

final class QuestionViewModelSpec: QuickSpec {
    override func spec() {

        var subject: QuestionViewModel!
        var output: QuestionViewModel.Output!

        var disposeBag: DisposeBag!
        var scheduler: TestScheduler!

        var fetchQuestionUseCase: MockFetchQuestionUseCase!
        var skipQuestionUseCase: MockSkipQuestionUseCase!
        var transcribeUseCase: MockTranscribeUseCase!
        var postAnswerUseCase: MockPostAnswerUseCase!
        var focusOnScreenUseCase: MockFocusOnScreenUseCase!
        var interviewID: InterviewId!
        var coordinator: InterviewCoordinator!

        var fetchedCurrentQuestion: Question!
        var fetchedCurrentSkipQuestion: Question!
        describe("QuestionViewModel") {

            beforeEach {
                fetchQuestionUseCase = MockFetchQuestionUseCase()
                skipQuestionUseCase = MockSkipQuestionUseCase()
                transcribeUseCase = MockTranscribeUseCase()
                postAnswerUseCase = MockPostAnswerUseCase()
                focusOnScreenUseCase = MockFocusOnScreenUseCase()
                interviewID = InterviewId(Int64(1))
                coordinator = InterviewCoordinator(UINavigationController(nibName: nil, bundle: nil))

                subject = QuestionViewModel(
                    interviewID: interviewID,
                    fetchQuestionUseCase: fetchQuestionUseCase,
                    skipQuestionUseCase: skipQuestionUseCase,
                    transcribeUseCase: transcribeUseCase,
                    postAnswerUseCase: postAnswerUseCase,
                    focusOnScreenUseCase: focusOnScreenUseCase,
                    coordinator: coordinator
                )

                scheduler = TestScheduler(initialClock: 0)
                disposeBag = DisposeBag()
            }

            context("뷰가 로드되었을 때") {
                beforeEach {
                    fetchedCurrentQuestion = Question(questionId: Int64(1), content: "question1")
                }

                it("현재 질문을 받아온다") {
                    let viewWillAppear = scheduler.createHotObservable([.next(5, ())])
                    let answerStringTrigger = scheduler.createHotObservable([.next(10, "answer1")])


                    let output = subject.transform(input: .init(
                        viewWillAppear: viewWillAppear.asObservable(),
                        answerString: Observable.never(),
                        skipButtonTapped: Observable.never(),
                        submitButtonTapped: Observable.never(),
                        recordButtonTapped: Observable.never(),
                        eyePoints: Observable.never()
                    ))

                    let fetchedQuestionObserver = scheduler.createObserver(String.self)

                    output.questionString
                        .asObservable()
                        .bind(to: fetchedQuestionObserver)
                        .disposed(by: disposeBag)

                    scheduler.start()

                    let emittedEvent = fetchedQuestionObserver.events.map { $0.value.element! }.first!
                    expect(emittedEvent).to(equal(fetchedCurrentQuestion.content))
                }

                context("질문에 대한 답변을 진행했을 때") {
                    it("다음 질문을 받아온다") {
                        let viewWillAppear = scheduler.createHotObservable([.next(5, ())])
                        let answerString = scheduler.createHotObservable([.next(7, "answer1")])

                        let output = subject.transform(input: .init(
                            viewWillAppear: viewWillAppear.asObservable(),
                            answerString: answerString.asObservable(),
                            skipButtonTapped: Observable.never(),
                            submitButtonTapped: Observable.never(),
                            recordButtonTapped: Observable.never(),
                            eyePoints: Observable.never()
                        ))

                        let fetchedQuestionObserver = scheduler.createObserver(String.self)

                        output.questionString
                            .asObservable()
                            .bind(to: fetchedQuestionObserver)
                            .disposed(by: disposeBag)

                        scheduler.start()

                        let emittedEvent = fetchedQuestionObserver.events.map { $0.value.element! }.first!
                        expect(emittedEvent).to(equal(fetchedCurrentQuestion.content))
                    }
                }

                context("질문을 스킵했을 때") {
                    beforeEach {
                        fetchedCurrentSkipQuestion = Question(questionId: Int64(2), content: "question2")

                        let viewWillAppear = scheduler.createHotObservable([.next(5, ())])
                        let skipButtonTapped = scheduler.createHotObservable([.next(7, ())])

                        output = subject.transform(input: .init(
                            viewWillAppear: viewWillAppear.asObservable(),
                            answerString: Observable.never(),
                            skipButtonTapped: skipButtonTapped.asObservable(),
                            submitButtonTapped: Observable.never(),
                            recordButtonTapped: Observable.never(),
                            eyePoints: Observable.never()
                        ))
                    }

                    it("다음 질문을 받아온다") {
                        let fetchedQuestionObserver = scheduler.createObserver(String.self)

                        output.questionString
                            .asObservable()
                            .bind(to: fetchedQuestionObserver)
                            .disposed(by: disposeBag)

                        scheduler.start()

                        let emittedEvent = fetchedQuestionObserver.events.map { $0.value.element! }.last!
                        expect(emittedEvent).to(equal(fetchedCurrentQuestion.content))
                    }

                    it("questionProgress가 올라간다") {
                        let progressObserver = scheduler.createObserver(Float.self)

                        output.questionProgress
                            .asObservable()
                            .bind(to: progressObserver)
                            .disposed(by: disposeBag)

                        scheduler.start()

                        let emittedEvent = progressObserver.events.map { $0.value.element! }.first!
                        expect(emittedEvent).to(equal(0.1))
                    }
                }

                context("eyePoint 값이 방출됐을 때") {
                    beforeEach {
                        let viewWillAppear = scheduler.createHotObservable([.next(5, ())])
                        let eyePointTrigger = scheduler.createHotObservable([.next(7, (Float(60.0),Float(600.0)))])

                        output = subject.transform(input: .init(
                            viewWillAppear: viewWillAppear.asObservable(),
                            answerString: Observable.never(),
                            skipButtonTapped: Observable.never(),
                            submitButtonTapped: Observable.never(),
                            recordButtonTapped: Observable.never(),
                            eyePoints: eyePointTrigger.asObservable().debug()
                        ))


                    }

                    it("toast 메시지가 온다") {
                        let toastMessageObservable = scheduler.createObserver(String.self)

                        output.toastMessage
                            .asObservable()
                            .bind(to: toastMessageObservable)
                            .disposed(by: disposeBag)

                        scheduler.start()

                        let emittedEvent = toastMessageObservable.events.map { $0.value.element! }.first!
                        expect(emittedEvent).to(equal("인터뷰에 집중해주세요"))

                    }
                }
            }
        }
    }
}

Quick 라이브러리를 사용해서 다양한 DFD를 문서화하듯이 테스트를 할 수 있다.

코드에서 보는 것과 마찬가지로,

  1. 뷰가 로드될 때
    1. 현재 질문을 받아온다
  2. 질문에 대한 답변을 진행했을 때
    1. 다음 질문을 받아온다
  3. 질문을 스킵했을 때
    1. 다음 질문을 받아온다
    2. questionProgress가 올라간다
  4. 시선이 화면 밖으로 나갔을 대 (eyePoint 값이 방출됐을 때)
    1. toastMessage를 전달한다

위와 같은 경우의 수를 행동 기반으로 테스트할 수 있었다.

화면 전환 로직 테스트

화면 전환 로직을 테스트하기 위해서 Coordinator 또한 추상화가 필요했다.

protocol RootCoordinator: Coordinator {

    func connectHistoryFlow(with id: Int?)
    func connectNewInterviewFlow()
}

위와 같이 기존의 RootCoordinator를 한 단계 추상화해서 작동되는 함수를 넣어주었다.

그리고 나서 MockRootCoordinator를 구현해주었다.

final class MockRootCoordinator: RootCoordinator {

    var delegate: CoordinatorDelegate?

    var navigationController: UINavigationController

    var childCoordinators: [Coordinator]

    var startCalled = false
    var connectNewInterviewFlowCalled = false
    var connectHistoryFlowCalled = false

    required init(_ navigationController: UINavigationController) {
        self.navigationController = navigationController
        self.childCoordinators = []
    }

    func start() {
        startCalled = true
    }

    func connectNewInterviewFlow() {
        connectNewInterviewFlowCalled = true
    }

    func connectHistoryFlow(with id: Int?) {
        connectHistoryFlowCalled = true
    }

}

다음과 같이 구현해서 connectNewInterviewFlow 와 connectHistoryFlow 가 불리면 flag를 true로 변경하여 테스트할 수 있었다.

final class RootViewModelSpec: QuickSpec {

    override func spec() {
        describe("RootViewModel") {
            var subject: RootViewModel!
            var mockCoordinator: MockRootCoordinator!

            var scheduler: TestScheduler!
            var disposeBag: DisposeBag!

            beforeEach {
                scheduler = TestScheduler(initialClock: 0)
                disposeBag = DisposeBag()

                mockCoordinator = MockRootCoordinator(UINavigationController(nibName: nil, bundle: nil))
                subject = RootViewModel(coordinator: mockCoordinator)
            }

            context("면접 시작하기 버튼이 눌렸을 떄") {
                it("connectNewInterviewFlow 함수가 실행된다.") {
                    // Arrange
                    let newInterviewButtonTrigger = scheduler.createHotObservable([.next(5, ())])

                    _ = subject.transform(input: .init(
                        newInterviewButtonDidTap: newInterviewButtonTrigger.asObservable(),
                        interviewHistoryButtonDidTap: Observable.never()
                    ))

                    // Act
                    scheduler.start()

                    // Assert
                    expect(mockCoordinator.connectNewInterviewFlowCalled).to(beTrue())
                }
            }

            context("history 버튼이 눌렸을 때") {
                it("connectHistoryFlow 함수가 실행된다.") {
                    // Arrange
                    let interviewHistoryButtonTrigger = scheduler.createHotObservable([.next(5, ())])

                    _ = subject.transform(input: .init(
                        newInterviewButtonDidTap: Observable.never(),
                        interviewHistoryButtonDidTap: interviewHistoryButtonTrigger.asObservable()
                    ))

                    // Act
                    scheduler.start()

                    // Assert
                    expect(mockCoordinator.connectHistoryFlowCalled).to(beTrue())
                }
            }
        }
    }
}

위처럼 해당 버튼이 눌렸을 때 coordinator의 함수가 불리는지를 확인할 수 있었다.

결과

위와 같이 어떤 ViewModel에서 어떤 상황에서 어떤 결과가 정상적인지, 정상적이지 않은 지를 확실하게 확인할 수 있고, 이를 통해서 코드의 로직이 잘못되었을 때 바로바로 확인을 할 수 있었다.

메시지 또한 ~~ 에서 ~~ 상황일 때 ~~이 된다로 명확하게 볼 수 있어서 기능을 문서화한 것 처럼 볼 수 있다.

CI/CD 파이프라인에서도 develop 브랜치에 pr이 올라갈 경우 테스트를 진행하고, 모든 테스트가 통과되어야 머지를 할 수 있도록 하였다.

'🍎 iOS > 📱 iOS' 카테고리의 다른 글

Custom Network Module에서 토큰 관리하기(RequestInterceptor)  (0) 2023.02.01
Queenfisher 이미지 캐시 개선기  (0) 2022.12.30
MVVM-C + Clean Architecture 도입기  (0) 2022.12.14
    '🍎 iOS/📱 iOS' 카테고리의 다른 글
    • Custom Network Module에서 토큰 관리하기(RequestInterceptor)
    • Queenfisher 이미지 캐시 개선기
    • MVVM-C + Clean Architecture 도입기
    💻🏃‍♀️🏋️‍♀️
    💻🏃‍♀️🏋️‍♀️

    티스토리툴바