Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
Tags
- UITextView
- Refactoring
- 스위프트
- clean architecture
- 애니메이션
- map
- Xcode
- ios
- uiscrollview
- 리팩토링
- ribs
- collectionview
- RxCocoa
- combine
- HIG
- swiftUI
- 리펙토링
- MVVM
- UICollectionView
- Observable
- 리펙터링
- rxswift
- swift documentation
- tableView
- Protocol
- Human interface guide
- SWIFT
- Clean Code
- 클린 코드
- uitableview
Archives
- Today
- Total
김종권의 iOS 앱 개발 알아가기
[iOS - swift] 2. Quick, Nimble으로 테스트 쉽게하는 방법 - describe, context, it, beforeEach 실제 코드에 테스트하는 방법 (RxNimble, 비동기 테스트 방법) 본문
iOS 응용 (swift)
[iOS - swift] 2. Quick, Nimble으로 테스트 쉽게하는 방법 - describe, context, it, beforeEach 실제 코드에 테스트하는 방법 (RxNimble, 비동기 테스트 방법)
jake-kim 2023. 6. 25. 01:301. Quick, Nimble으로 테스트 쉽게하는 방법 - Quick, Nimble 개념
2. Quick, Nimble으로 테스트 쉽게하는 방법 - describe, context, it, beforeEach 실제 코드에 테스트하는 방법 (RxNimble, 비동기 테스트 방법)
Quick의 beforeEach 개념
* Quick의 decsribe, context, it 개념은 이전 포스팅 글 참고
- beforeEach를 잘 활용하면 각 테스트 케이스마다 쉽게 데이터를 변경해가며 테스트할 수 있으므로 beforeEach를 먼저 이해하기
- beforeEach: it() {} 블락이 실행되기전에 모든곳에서 호출됨
- afterEach: it() {} 블락이 실행되고난 후 모든곳에서 호출됨
- 즉, it() 실행 기준으로 before, after를 의미
- beforeEach는 해당 코드를 감싸고 있는 블락에서만 계속 호출됨
- 예를 들어 바깥에 beforeEach를 작성하면 안에 있는 it을 실행할때마다 해당 코드가 동작
- 중복되면 defer 키워드와는 반대로 queue형태로 쌓임 (코드 순서상으로 먼저 만난 before가 먼저 실행)
testable한 MVVM 준비
- cocoapod 의존성 준비
target 'ExTesting' do
use_frameworks!
pod 'RxSwift'
pod 'RxCocoa'
target 'ExTestingTests' do
inherit! :search_paths
pod 'Quick'
pod 'Nimble'
end
target 'ExTestingUITests' do
end
end
- 테스트가 가능하능한 MVVM구조 준비
- 테스트할 대상은 ViewModel이므로, ViewModel이 아닌 View는 Mock으로 바꾸고 ViewModel은 그대로 사용
- View는 Mock으로 만들기가 가능해야하므로 Presentable을 따르는 무언가로 정의
(ViewController 부분)
import UIKit
import RxCocoa
import RxSwift
enum Action {
case viewDidLoad
}
protocol Presentable {
var viewModel: ViewModelable { get }
var stateObservable: Observable<State> { get }
}
class ViewController: UIViewController, Presentable {
// MARK: UI
private let label: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 20, weight: .regular)
label.text = "0"
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
// MARK: Properties
private let disposeBag = DisposeBag()
private let stateSubject = PublishSubject<State>()
let viewModel: ViewModelable
var stateObservable: Observable<State> {
stateSubject
}
// MARK: Init
init(viewModel: ViewModelable) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError()
}
override func viewDidLoad() {
super.viewDidLoad()
configureUI()
bind()
viewModel.input(.viewDidLoad)
}
private func configureUI() {
view.backgroundColor = .white
view.addSubview(label)
NSLayoutConstraint.activate([
label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
])
}
private func bind() {
viewModel.output
.observe(on: MainScheduler.instance)
.bind(to: stateSubject)
.disposed(by: disposeBag)
stateSubject
.observe(on: MainScheduler.instance)
.bind(with: self) { ss, state in
ss.handleOutput(state)
}
.disposed(by: disposeBag)
}
private func handleOutput(_ state: State) {
switch state {
case let .updateCountOfViewDidLoad(count):
label.text = "\(count)"
case let .updateCountOfViewDidLoadOnMemory(count):
print("memory:\(count)")
}
}
}
(ViewModel 부분)
import RxSwift
import RxCocoa
enum State {
case updateCountOfViewDidLoad(count: Int)
case updateCountOfViewDidLoadOnMemory(count: Int)
}
protocol ViewModelable {
var output: Observable<State> { get }
func input(_ action: Action)
}
final class ViewModel: ViewModelable {
struct Dependency {
let count: Int?
}
private let dependency: Dependency
init(dependency: Dependency) {
self.dependency = dependency
guard let count = dependency.count else { return }
countOfViewDidLoadAtDisk = count
countOfViewDidLoadAtMemory = count
}
// MARK: Output
var output: RxSwift.Observable<State> {
outputSubject
}
private var outputSubject = PublishSubject<State>()
private var countOfViewDidLoadAtDisk: Int {
get {
UserDefaults.standard.integer(forKey: "countOfViewDidLoad")
}
set {
UserDefaults.standard.setValue(newValue, forKey: "countOfViewDidLoad")
}
}
var countOfViewDidLoadAtMemory = 0
// MARK: Input
func input(_ action: Action) {
switch action {
case .viewDidLoad:
countOfViewDidLoadAtDisk += 1
countOfViewDidLoadAtMemory += 1
outputSubject.onNext(.updateCountOfViewDidLoad(count: countOfViewDidLoadAtDisk))
outputSubject.onNext(.updateCountOfViewDidLoadOnMemory(count: countOfViewDidLoadAtMemory))
}
}
}
테스트 Mock 생성
- ViewModel을 테스트할 것이므로, ViewModel을 제외한 다른것들은 Mock으로 넣고, ViewModel에 값을 넣어보며 ViewControllerMock에 데이터를 잘 전달하나 확인에 사용
- 주의) stateObservable을 통해, ViewModel에서 데이터를 잘 처리해서 해당 View에 잘 전달해주는지 확인해주는 코드이며 ReplaySubject로 이벤트를 가지고 있어야함 (Cold Observable)
- 이벤트가 발생한 후에 stateObservable을 구독했을때도 이벤트를 받아볼수 있어야 테스트가 쉬우므로 ReplaySubject를 사용
- ReplaySubject<State>.createUnbounded()를 사용해야 이전에 방출된 값을 알 수 있으므로 초기화시에도 주의
- 만약 PublishSubject를 사용하면, 이벤트가 발생한 후에 stateObservable을 구독하면 emit이 안되므로 주의
import UIKit
@testable import RxSwift
@testable import ExTesting
// MARK: - ViewController
final class ViewControllerMock: Presentable {
var viewModel: ViewModelable
var stateObservable: Observable<State> {
stateReplay
}
// 주의: ReplaySubject<State>.createUnbounded()와 ReplaySubject<State>()은 다름
private let stateReplay = ReplaySubject<State>.createUnbounded()
init(viewModel: ViewModelable) {
self.viewModel = viewModel
viewModel.output
.observe(on: MainScheduler.instance)
.bind(to: stateReplay)
}
}
extension State: Equatable {
public static func == (lhs: Self, rhs: Self) -> Bool {
switch (lhs, rhs) {
case let (.updateCountOfViewDidLoad(lhsCount), .updateCountOfViewDidLoad(rhsCount)):
return lhsCount == rhsCount
case let (.updateCountOfViewDidLoadOnMemory(lhsCount), .updateCountOfViewDidLoadOnMemory(rhsCount)):
return lhsCount == rhsCount
default:
return false
}
}
}
Quick와 Nimble을 사용하여 테스트
- Nimble을 사용하는데, Rx와 관련된 테스트를 하고 싶은 경우 RxNimble을 x사용하면 매우 편리
target 'ExTesting' do
use_frameworks!
pod 'RxSwift'
pod 'RxCocoa'
target 'ExTestingTests' do
inherit! :search_paths
pod 'Quick'
pod 'Nimble'
pod 'RxNimble' // <- 추가
end
target 'ExTestingUITests' do
end
end
- QuickSpec을 상속받는 클래스로 테스트 코드 구현
// ExTestingTests.swift
import Quick
import Nimble
import RxSwift
import RxNimble
@testable import ExTesting
final class ExTestingTests: QuickSpec {
override class func spec() {
}
}
- spec()내부 구현
- quick을 사용하면 closure가 많이 생기므로 이 안에서 변수나 메소드 접근할 때 self없이 쉽게 접근하기 위해서 nested형태로 필요한 property와 method를 선언
- 테스트하려는 대상인 ViewModel은 Mock이 아닌 실제 ViewModel을 삽입
- 테스트하려는 대상이 아니고 단순히 ViewModel이 데이터를 잘 전달하는지 볼때 사용되는 viewController는 Mock을 주입
- setUp(dependency:)는 각 테스트 케이스마다 다른 조건을 주기 위해서 따로 메소드를 사용
var viewModel: ViewModelable!
var viewController: Presentable!
let timeoutSeconds = TimeInterval(3)
func setUp(dependency: ViewModel.Dependency) {
viewModel = ViewModel(dependency: dependency)
viewController = ViewControllerMock(viewModel: viewModel)
}
- Quick을 사용하여 상황을 기술 describe - context - it
describe("ExTesting 모듈의 ViewController에서") {
context("viewDidLoad가 발생하면") {
it("디스크에 저장되는 count값 +1하여 viewController에게 전달") {
}
it("메모리에 저장되는 count값 +1하여 viewController에게 전달") {
}
}
}
- 각 조건을 다르게 하기 위해서 beforeEach를 적당히 선언
describe("ExTesting 모듈의 ViewController에서") {
beforeEach {
setUp(dependency: .init(count: 7))
}
context("viewDidLoad가 발생하면") {
beforeEach {
viewModel.input(.viewDidLoad)
}
it("디스크에 저장되는 count값 +1하여 viewController에게 전달") {
}
it("메모리에 저장되는 count값 +1하여 viewController에게 전달") {
}
}
- it도 구현
- viewModel에서 액션이 이루어지면 viewModel에서 계산하여 viewController에게 넘겨주기 때문에, viewController의 stateObservable과 기대 결과를 비교하여 테스트 작성이 가능
- 중간에 나오는 first(timeout:)이 코드는 RxNimble에 연관된 코드
it("디스크에 저장되는 count값 +1하여 viewController에게 전달") {
let expectedResult = State.updateCountOfViewDidLoad(count: 8)
expect(viewController.stateObservable)
.first(timeout: timeoutSeconds)
.toEventually(equal(expectedResult))
}
it("메모리에 저장되는 count값 +1하여 viewController에게 전달") {
let expectedResult = State.updateCountOfViewDidLoadOnMemory(count: 8)
expect(viewController.stateObservable.skip(1))
.first(timeout: timeoutSeconds)
.toEventually(equal(expectedResult))
}
- count7로 설정 후 다시 앱을 재가동 시키는 경우, memory count는 1이 되어야 하고 disk count는 8로 되는 테스트 케이스도 작성
context("viewModel이 메모리 해제된 후 다시 만들어지면") {
beforeEach {
setUp(dependency: .init(count: nil))
viewModel.input(.viewDidLoad)
}
it("디스크에 저장되는 count값은 이전에 저장된 값이 적용되며 이를 viewController에게 전달") {
let expectedResult = State.updateCountOfViewDidLoad(count: 8)
expect(viewController.stateObservable)
.first(timeout: timeoutSeconds)
.toEventually(equal(expectedResult))
}
it("count값을 1로하여 viewController에게 전달") {
let expectedResult = State.updateCountOfViewDidLoadOnMemory(count: 1)
expect(viewController.stateObservable.skip(1))
.first(timeout: timeoutSeconds)
.toEventually(equal(expectedResult))
}
}
(완성)
* 참고
'iOS 응용 (swift)' 카테고리의 다른 글
Comments