도입 계기
아래와 같은 이유를 통해서 BDD(Behavior-Driven Development)를 도입하기로 결정하였다.
- 명확한 요구 사항 이해:
개발과 기획에서 모두가 이해하기 쉬운 플로우로 요구사항을 명확히 할 수 있다.
이를 통해 높은 수준의 통일성과 명확성을 유지할 수 있다. - 문서화와 테스팅의 통합:
BDD는 자연어 스타일의 표현을 사용하여 테스트 케이스를 작성한다. 테스트 케이스는 동시에 문서 역할도 하므로, 코드의 기능이 어떻게 동작해야 하는지 명확하게 알려주는 동시에, 추후에 변경이나 확장이 이루어질 때 문서의 역할도 할 수 있다. - 코드 품질 향상:
BDD는 TDD(Test-Driven Development)의 원칙을 기반으로 하기 때문에, 테스팅이 중요한 부분을 차지한다. 따라서 코드 품질을 높이고 버그를 미리 잡을 수 있다. - 개발 및 유지보수 속도 향상:
- 테스트 케이스가 명확하고, 그에 따른 코드 구현이 검증되므로 개발 과정에서 발생할 수 있는 오류나 불명확한 부분을 빠르게 파악하고 수정할 수 있다.
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를 문서화하듯이 테스트를 할 수 있다.
코드에서 보는 것과 마찬가지로,
- 뷰가 로드될 때
- 현재 질문을 받아온다
- 질문에 대한 답변을 진행했을 때
- 다음 질문을 받아온다
- 질문을 스킵했을 때
- 다음 질문을 받아온다
- questionProgress가 올라간다
- 시선이 화면 밖으로 나갔을 대 (eyePoint 값이 방출됐을 때)
- 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 |