관리 메뉴

김종권의 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:30

1. 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))
    }
}

(완성)

* 참고

https://github.com/RxSwiftCommunity/RxNimble

https://github.com/JK0369/ExTesting_quick_nimble

Comments