Notice
Recent Posts
Recent Comments
Link
관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] MVVM 구조 - ViewModel을 testable하도록 구현 방법 (DI, DIP, 추상화) 본문

iOS 응용 (swift)

[iOS - swift] MVVM 구조 - ViewModel을 testable하도록 구현 방법 (DI, DIP, 추상화)

jake-kim 2021. 11. 26. 01:50

* 알아야하는 기본 지식

- 테스트 코드를 작성해야 하는 이유 - 클린 코드 (창발성)

- DIP(Dependency Inversion Principle)

설계 전에 필요한 프레임워크 설치

  • RxSwift
  • RxCocoa

ViewModel을 testable되도록 만드는 이유

  • viewModel에는 UI 인풋에 따라 UseCase를 통해 비즈니스 로직을 실행
  • viewModel은 어떤값을 UI에 넘겨주어야하는지 정보를 담고 있는 컴포넌트
  • UI의 인풋부터 시작하여, 비즈니스 로직과 아웃풋까지 동시에 모두 테스트할 수 있는 컴포넌트는 ViewModel

ViewModel을 testable하게 구현하는 아이디어

ex) LoginVM (로그인 ViewModel)을 만드는 예시

  • LoginVM 프로토콜을 만들어서 테스트할 때 이 프로토콜을 준수하면 언제든 구현체를 변경하여 테스트가능하도록 설계
  • LoginVM 프로토콜은 UI의 인풋부분을 담당하는 LoginVMInput, UI에 결과를 돌려주는 LoginVMOutput 프로토콜을 준수
  • LoginDependency 구조체를 선언하여 이 구조체에 LoginVM에서 필요한 값들을 받을 수 있도록 설계
// LoginVM.swift

import RxSwift
import RxCocoa

protocol LoginVMInput {
    func didTapLoginButton()
}

protocol LoginVMOutput {
    var finishLogin: PublishRelay<Void> { get }
}

struct LoginDependency {
    
}

protocol LoginVM: LoginVMInput, LoginVMOutput {
    var dependency: LoginDependency { get }
    init(dependency: LoginDependency)
}

final class LoginVMImpl: LoginVM {
    
    let dependency: LoginDependency
    
    init(dependency: LoginDependency) {
        self.dependency = dependency
    }
    
    // MARK: - Input
    
    func didTapLoginButton() {
        print("로그인 완료 !!!")
        finishLogin.accept(())
    }
    
    // MARK: - Output
    
    var finishLogin: PublishRelay<Void> = .init()
    
    // MARK: - Private
    
    
}
  • LoginBuilder
    • 핵심코드: func build<VM: LoginVM>(dependency: LoginDependency, viewModelType: VM.type) -> LoginVC
      • "viewModelType: VM.type"가 있는 이유: 함수 제네릭을 사용할 때 메소드 시그니처에 사용하지 않으면 컴파일 오류 
        (LoginVM을 따르는 mock용도의 구현체 주입이 가능하도록 설계)

  • 파라미터에 viewModelType을 따로 받게하여, 이 타입에서 dependency를 받아서 초기화 가능하도록 설계
    (dependency도 mock 데이터 주입이 가능하도록 설계)
  • 각 ViewController들이 준수하고 있는 ViewControllerInit에서 static func instantiate(viewModel: VM) -> Self로 정의
class LoginBuilder {
    static func build<VM: LoginVM>(dependency: LoginDependency, viewModelType: VM.Type) -> LoginVC {
        let vm = VM(dependency: dependency)
        return LoginVC.instantiate(viewModel: vm)
    }
}

// 핵심 코드 - 각 ViewController들이 준수하고 있는 ViewControllerInit프로토콜 중 ...
protocol ViewControllerInit {
    associatedtype VM
    var viewModel: VM! { get set }
    
    static func instantiate(viewModel: VM) -> Self
    init(viewModel: VM)
}

extension ViewControllerInit where Self: UIViewController {

    static func instantiate(viewModel: VM) -> Self {
        let vc = Self(viewModel: viewModel)
        return vc
    }

    init(viewModel: VM) {
    	self.init(nibName: nil, bundle: nil)
        self.viewModel = viewModel
    }
}

나머지 컴포넌트 정의 - Base

  • BaseRouter 프로토콜: 각 Router마다 준수해야하는 프로토콜이고, 해당 프로토콜에는 공통적으로 들어가는 push, present, back이 구현되어 있으며 중복을 줄이기 위한 코드
protocol BaseRouter {
    var viewController: UIViewController? { get set }
    
    func present(_ viewController: UIViewController, animated: Bool, completion: (() -> Void)?)
    func pushViewController(_ viewController: UIViewController, animated: Bool)
    func back(animated: Bool)
}

extension BaseRouter {
    func present(_ viewController: UIViewController, animated: Bool, completion: (() -> Void)? = nil) {
        self.viewController?.present(viewController, animated: animated, completion: completion)
    }
    
    func pushViewController(_ viewController: UIViewController, animated: Bool) {
        guard let navigationController = self.viewController?.navigationController else { return }
        navigationController.pushViewController(viewController, animated: animated)
    }
    
    func back(animated: Bool) {
        if let lastViewController =  viewController?.presentingViewController {
            lastViewController.dismiss(animated: animated, completion: nil)
        } else {
            (viewController as? UINavigationController)?.popViewController(animated: animated)
        }
    }
}
  • BaseViewController 클래스: 중복 코드를 줄이기 위해, 각 ViewController들이 상속받는 클래스
// BaseViewController.swift

class BaseViewController: UIViewController {

    override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
        super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
        configure()
    }

    func configure() {
        setupBaseUI()
    }

    private func setupBaseUI() {
        view.backgroundColor = .systemBackground
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}
  • ViewControllerInit 프로토콜: 각 ViewController들은 해당 프로토콜을 준수하며, 해당 프로토콜을 통해 ViewModel과 Router를 초기화하기 쉽고 UI관련 작업에서 호출해야하는 코드가 존재
    • associatedtype으로 Router, VM을 선언하는데, Router는 구현체에서 초기화하고, VM은 instantiate(viewModel: VM)을 통하여 static하게 LoginBuilder에서 초기화되도록 설계
    • viewModel을 초기화할때는 protocol implementation을 통해 미리 초기화되게끔 구현 (중복 코드 방지)
// ViewControllerInit.swift

protocol ViewControllerInit {
    associatedtype Router
    associatedtype VM

    var viewModel: VM! { get set }
    var router: Router! { get set }

    static func instantiate(viewModel: VM) -> Self
    init(viewModel: VM)

    func setupViews()
    func addSubviews()
    func makeConstraints()
    func bindInputs()
    func bindOutputs()
}

extension ViewControllerInit where Self: UIViewController {

    static func instantiate(viewModel: VM) -> Self {
        let vc = Self(viewModel: viewModel)
        return vc
    }

    init(viewModel: VM) {
        self.init(nibName: nil, bundle: nil)
        self.viewModel = viewModel
        
        setupViews()
        addSubviews()
        makeConstraints()
        bindInputs()
        bindOutputs()
    }

}

나머지 컴포넌트 정의 - LoginRouter, LoginVC, LoginBuilder

  • LoginRouter
    • BaseRouter 프로토콜을 준수하고있는 구현체
    • push, present, back과 같은 메소드들은 BaseRouter의 protocol implementaion으로 구현 (중복 코드 방지)
    • viewController만 LoginVC에서 받도록 설계
class LoginRouter: BaseRouter {
    weak var viewController: UIViewController?
    
    init(viewController: UIViewController) {
        self.viewController = viewController
    }
}
  • LoginVC
    • BaseViewController에서 내부적으로 호출되는 configure()를 재정의하여 router 초기화
import UIKit
import RxSwift
import RxCocoa

final class LoginVC: BaseViewController, ViewControllerInit {
    
    var viewModel: LoginVM!
    var router: LoginRouter!
    
    private lazy var loginButton: UIButton = {
        let button = UIButton()
        button.setTitle("로그인", for: .normal)
        button.setTitleColor(.systemBlue, for: .normal)
        button.setTitleColor(.blue, for: .highlighted)
        return button
    }()
    
    override func configure() {
        super.configure()
        router = LoginRouter(viewController: self)
    }
    
    func setupViews() {

    }
    
    func addSubviews() {
        view.addSubview(loginButton)
    }
    
    func makeConstraints() {
        loginButton.translatesAutoresizingMaskIntoConstraints = false
        loginButton.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
        loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
    }
    
    func bindInputs() {
        loginButton.rx.tap
            .asDriver()
            .drive(onNext: { [weak self] in
                self?.viewModel.didTapLoginButton()
            }).disposed(by: disposeBag)
    }
    
    func bindOutputs() {
        viewModel.finishLogin
            .asDriver { _ in .never() }
            .drive(onNext: {
                print("finish login !!!")
            }).disposed(by: disposeBag)
    }
}

유닛 테스트 코드 작성

  • 기존에 구현된 LoginVMImpl 테스트
    • (LoginVM을 준수하는 Mock 용도의 클래스를 따로 생성하여, input, output 테스트도 가능)
import XCTest
@testable import ExTestableMVVM

class LoginVMTest: XCTestCase {
    
    let dependency = LoginDependency()
    var loginVM: LoginVMImpl!

    override func setUpWithError() throws {
        loginVM = LoginVMImpl(dependency: dependency)
    }
    
    func test_whenDidTapLoginButton_thenStateChange() {
        loginVM.didTapLoginButton()
        XCTAssertTrue(loginVM.state == .login)
    }

}

Unit test 성공

* 전체 소스 코드: https://github.com/JK0369/ExTestableMVVM

Comments