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 |
Tags
- RxCocoa
- swiftUI
- collectionview
- HIG
- Xcode
- uitableview
- UICollectionView
- 리펙터링
- clean architecture
- ribs
- map
- Refactoring
- SWIFT
- 리팩토링
- tableView
- MVVM
- 애니메이션
- combine
- Protocol
- Human interface guide
- Clean Code
- 스위프트
- uiscrollview
- Observable
- 클린 코드
- UITextView
- ios
- swift documentation
- rxswift
- 리펙토링
Archives
- Today
- Total
김종권의 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: VM.type"가 있는 이유: 함수 제네릭을 사용할 때 메소드 시그니처에 사용하지 않으면 컴파일 오류
- 핵심코드: func build<VM: LoginVM>(dependency: LoginDependency, viewModelType: VM.type) -> LoginVC
- 파라미터에 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)
}
}
* 전체 소스 코드: https://github.com/JK0369/ExTestableMVVM
'iOS 응용 (swift)' 카테고리의 다른 글
Comments