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