iOS Architecture Patterns 뿌시기: View Interactor Presenter Entity Router (VIPER)

33 분 소요

Thanks to Radu Dan for allowing the translation.

Reference: Battle of the iOS Architecture Patterns: View Interactor Presenter Entity Router (VIPER)

본 글은 위의 내용에 대한 번역본입니다.

순서는 다음과 같습니다.

  1. MVC
  2. MVVM
  3. MVP
  4. MVP-C
  5. VIPER
  6. VIP

iOS Architecture Patterns 뿌시기: View Interactor Presenter Entity Router (VIPER)

아키텍처 시리즈 — View Interactor Presenter Entity Router (VIPER)

아키텍처 시리즈 — View Interactor Presenter Entity Router (VIPER)

동기 (Motivation)

iOS 앱을 개발하기 전에 프로젝트의 구조에 대해 생각해야 합니다. 나중에 앱의 일부분을 다시 볼 때 코드의 조각을 추가하는 방법과 다른 개발자들과 “언어” 라고 알려진 형식을 고려해야 합니다.

이 글에서 Football Gather 앱을 VIPER 코드베이스 아키텍처로 변환합니다.

다른 글을 놓쳤다면 아래 내용을 확인하거나 이 글 마지막에 링크에서 확인할 수 있습니다.

  • Model View Controller (MVC) — link here
  • Model View ViewModel (MVVM) — link here
  • Model View Presenter (MVP) — link here
  • Model View Presenter with Coordinators (MVP-C) — link here

바로 코드가 보고 싶으신가요? 걱정하지 마세요! GitHub 에서 코드를 확인할 수 있습니다.

다른 글에서와 동일하게 먼저 이 패턴에 대한 약간의 설명과 왜 유용한지에 대해 살펴 보겠습니다. 그런 다음에 실제 구현을 해보도록 합니다. 마지막으로 컴파일과 빌드 시간에 대해 몇가지 정보를 보여주고 유닛 테스트를 작성하는게 얼마나 쉬운지 확인하고 결론을 설명합니다.

iOS 앱에 아키텍처 패턴이 필요한 이유? (Why an Architecture Pattern for Your iOS App?)

고려해야 할 가장 중요한 것은 유지될 수 있는 앱을 가지는 것입니다. View 가 있다는 것을 알고 View Controller 가 Y 가 아닌 X 를 수행해야 한다는 것도 알고 있습니다. 더 중요한 것은 다른 사람들도 알고 있다는 것입니다.

좋은 아키텍처 패턴을 고르는 것에 대한 이점은 다음과 같습니다:

  • 유지보수가 쉬움
  • 비지니스 로직을 테스트하기 쉬움
  • 다른 팀원들과 공통 언어로 개발
  • 역할 분리 (각 기능별 분리)
  • 더 적은 버그

요구사항 정의 (Defining the Requirements)

6개 또는 7개의 화면을 가진 iOS 애플리케이션이 주어지고 iOS 세계에서 가장 유명한 아키텍처 패턴을 사용하여 개발할 것입니다: MVC, MVVM, MVP, VIPER, VIP, 그리고 Coordinators.

데모앱은 Football Gather 라고 불리며 아마추어 축구 경기의 점수를 추적하는 간단한 앱입니다.

주요 요소 (Main features)

가능한 것:

  • 앱에 선수 추가
  • 선수에게 팀 할당
  • 선수 수정
  • 경기에 대한 카운트다운 타이머 설정

화면 목업 (Screen mockups)

iOS 앱인 "Football Gather" 에 화면 목업

iOS 앱인 “Football Gather” 에 화면 목업

벡엔드 (Backend)

이 앱은 Vapor web framework 로 개발된 웹 앱으로 구동됩니다. Vapor 3 initial articlearticle about Migrating to Vapor 4 에서 앱을 확인할 수 있습니다.

친구, 내 VIPER 는 어디에? (Dude, where’s my VIPER?)

VIPER 는 View-Interactor-Presenter-Entity-Router 를 의미합니다.

MVP 에서 Presenter 레이어는 무엇이고 무엇을 수행하는지 살펴보았습니다. 이런 개념은 VIPER 에도 적용 되지만 Interactor 에서 데이터를 얻고 규칙에 따라 View 를 업데이트 / 구성하는 새로운 책임을 가집니다.

View

최대한 멍청해야 합니다. 모든 이벤트를 Presenter 에 전달하고 대부분 Presenter 가 지시한 대로 수동적으로 수행해야 합니다.

Interactor

새로운 레이어가 도입되고 여기에 비지니스 규칙과 로직이 관련된 모든 것을 포함해야 합니다.

Presenter

사용자의 액션을 기반으로 Interactor 에서 데이터를 가져오고 그 다음에 View 업데이트를 처리할 책임을 가집니다.

Entity

Model 레이어 이며 데이터를 캡슐화 하기 위해 사용됩니다.

Router

애플리케이션에 대한 모든 네비게이션 로직을 가집니다. 비지니스 로직이 없이는 Coordinator 처럼 보입니다.

통신 (Communication)

예를 들어 사용자가 액션을 시작할 때와 같이 View 레이어에서 어떤한 것이 일어나면 Presenter 로 전달됩니다.

Presenter 는 사용자가 필요로 하는 데이터에 대해 Interactor 에 요청합니다. Interactor 는 데이터를 제공합니다.

Presenter 는 해당 데이터를 표시하는데 필요한 UI 변환을 적용합니다.

Model / 데이터가 변경되면 InteractorPresenter 에 알립니다.

Presenter 는 수신한 데이터를 기반으로 View 를 구성 또는 새로고침 합니다.

사용자가 앱 내에서 다른 화면을 통해 이동하거나 플로우를 변경하는 다른 경로를 가지면 View 는 Presenter 에 알립니다.

Presenter 는 새로운 화면을 로드하거나 새로운 플로우를 로드하기 위해 Router 에 알립니다 (예를 들어 새로운 View Controller 를 push).

확장된 VIPER (Extended VIPER)

VIPER 아키텍처 패턴에서 사용되는 일반적인 몇가지 개념이 있습니다.

모듈 (Modules)

Router 에서 VIPER 레이어 생성을 분리하고 모듈 어셈블리에 대한 새로운 처리를 도입하는 것은 좋은 아이디어 입니다. 이것은 Factory 메서드 패턴처럼 수행됩니다.

protocol AppModule {
    func assemble() -> UIViewController?
}

protocol ModuleFactoryProtocol {
    func makeLogin(using navigationController: UINavigationController) -> LoginModule
    func makePlayerList(using navigationController: UINavigationController) -> PlayerListModule
}

그리고 앱에 대한 구체적인 구현은 다음과 같습니다:

struct ModuleFactory: ModuleFactoryProtocol {
    func makeLogin(using navigationController: UINavigationController = UINavigationController()) -> LoginModule {
        let router = LoginRouter(navigationController: navigationController, moduleFactory: self)
        let view: LoginViewController = Storyboard.defaultStoryboard.instantiateViewController()
        return LoginModule(view: view, router: router)
    }
    
    func makePlayerList(using navigationController: UINavigationController = UINavigationController()) -> PlayerListModule {
        let router = PlayerListRouter(navigationController: navigationController, moduleFactory: self)
        let view: PlayerListViewController = Storyboard.defaultStoryboard.instantiateViewController()
        return PlayerListModule(view: view, router: router)
    }
}

더 많은 소스 코드는 다음에 확인해 봅시다.

TDD

이 접근방식은 Clean Code 관점에서 좋은 작업을 수행하며 문제를 분리하고 SOLID 원칙을 더 잘 따르도록 레이어를 개발합니다.

따라서 TDD 는 VIPER 를 사용하여 더 쉽게 달성할 수 있습니다.

  • 모듈은 분리되어 있습니다.
  • 문제에 명확한 구분이 있습니다.
  • 모듈은 코딩 관점에서 깔끔하고 명확합니다.

코드 생성 툴 (Code generation tool)

애플리케이션에 더 많은 모듈, 플로우 그리고 기능을 추가하면 많은 코드를 작성하고 대부분 반복적이라는 것을 깨닫습니다.

VIPER 모듈에 대한 코드 생성 툴을 가지는 것은 좋은 아이디어 입니다.

심각한 문제 해결 (Solving the back problem)

Coordinator 패턴을 적용하면 스택에서 특정 View Controller 로 이동 할 때 문제가 있다는 것을 확인했습니다. 이러한 경우에 앱에서 다른 VIPER 모듈간에 데이터를 전송하거나 백해야 될 경우에 대한 방법을 생각해 볼 필요가 있습니다.

이 문제는 Delegation 으로 쉽게 해결될 수 있습니다.

예를 들어:

protocol PlayerDetailsDelegate: AnyObject {
    func didUpdatePlayer(_ player: Player)
}

// we make the Presenter the delegate of PlayerDetailsPresenter so we can refresh the UI when a player has been changed.
extension PlayerListPresenter: PlayerDetailsDelegate {
    func didUpdatePlayer(_ player: Player) {
        viewState = .list
        configureView()
        view?.reloadData()
    }
}

코드에 적용 (Applying to our code) 섹션에서 더 많은 예제를 살펴볼 것입니다.

VIPER 는 언제 사용할까 (When to use VIPER)

VIPER 는 Swift 와 iOS 프로그래밍에 대한 지식이 있거나 팀 내에서 경험이 많거나 시니어 개발자가 있을 때 사용되어야 합니다.

확장되지 않고 작은 프로젝트의 부분이라면 VIPER 는 무리일 수 있습니다. MVC 가 더 낫습니다.

앱에 더 높은 코드 커버리지를 제공하는 모듈화와 유닛 테스트에 관심이 많다면 사용하십시오. 초급 개발자 이거나 iOS 개발에 경험이 많지 않으면 사용하지 마시기 바랍니다. 더 많은 코드를 작성하기 위해 준비합시다.

내 관점에서 VIPER 는 깔끔한 코드를 보여주기 때문에 좋아합니다. 테스트가 쉽고 클래스는 분리되고 코드는 SOLID 를 따릅니다.

앱에서 View 레이어를 2개의 컴포넌트로 분리합니다: ViewController 와 실제 View. ViewControllerCoordinator / Router 로 수행하고 대게 IBOutlet 으로 설정된 View 에 참조를 유지합니다.

장점 (Advantages)

  • 코드는 깔끔하고, SRP 가 핵심입니다.
  • 유닛 테스트는 작성하기 쉽습니다.
  • 코드는 분리됩니다.
  • 특히, TDD 를 사용하면 버그가 더 적습니다.
  • 비지니스 로직을 간결하게 하는 복잡한 프로젝트에 매우 유용합니다.
  • 모듈은 재사용 될 수 있습니다.
  • 새로운 기능은 추가하기 쉽습니다.

단점 (Disadvantages)

  • 보일러플레이트 코드를 많이 작성해야 합니다.
  • 작은 앱에는 좋지 않습니다.
  • 큰 코드베이스와 많은 클래스로 마무리 됩니다.
  • 컴포넌트의 일부분은 앱 사용 케이스에 따라 중복될 수 있습니다.
  • 앱 시작이 약간 증가합니다.

코드에 적용 (Applying to our code)

VIPER 적용하여 앱에 큰 변화가 있을 겁니다.

ViewViewController 중 하나의 레이어는 더 가벼워지고 많은 목적을 달성하지 못하므로 두 레이어를 분리하는 것을 유지하지 않기로 했습니다.

모든 Coordinator 는 삭제될 것입니다.

먼저 첫번째 모듈인 Login 을 로드하기 위한 AppLoader 생성으로 시작합니다.

struct AppLoader {
    private let window: UIWindow
    private let navigationController: UINavigationController
    private let moduleFactory: ModuleFactoryProtocol
    
    init(window: UIWindow = UIWindow(frame: UIScreen.main.bounds),
         navigationController: UINavigationController = UINavigationController(),
         moduleFactory: ModuleFactoryProtocol = ModuleFactory()) {
        self.window = window
        self.navigationController = navigationController
        self.moduleFactory = moduleFactory
    }
    
    // This function is similar with the one we had for Coordinators, start().
    func build() {
        let module = moduleFactory.makeLogin(using: navigationController)
        let viewController = module.assemble()
        setRootViewController(viewController)
    }
    
    private func setRootViewController(_ viewController: UIViewController?) {
        window.rootViewController = navigationController
        
        if let viewController = viewController {
            navigationController.pushViewController(viewController, animated: true)
        }
        
        window.makeKeyAndVisible()
    }
}

AppDelegate 에서 AppLoader 를 할당하고 앱 런칭이 끝나면 build() 함수를 호출합니다.

class AppDelegate: UIResponder, UIApplicationDelegate {
    
    private lazy var loader = AppLoader()
    
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        loader.build()
        return true
    }
    
    ..
}

VIPER 모듈을 생성하기 위해 ModuleFactory 를 어떻게 사용하는지 이전에 보았습니다. 앱에서 조립이 필요한 모든 모듈에 대한 인터페이스를 제공합니다.

protocol ModuleFactoryProtocol {
    func makeLogin(using navigationController: UINavigationController) -> LoginModule
    func makePlayerList(using navigationController: UINavigationController) -> PlayerListModule
    func makePlayerDetails(using navigationController: UINavigationController,
                           for player: PlayerResponseModel,
                           delegate: PlayerDetailDelegate) -> PlayerDetailModule
    func makePlayerEdit(using navigationController: UINavigationController,
                        for playerEditable: PlayerEditable,
                        delegate: PlayerEditDelegate) -> PlayerEditModule
    func makePlayerAdd(using navigationController: UINavigationController, delegate: PlayerAddDelegate) -> PlayerAddModule
    func makeConfirmPlayers(using navigationController: UINavigationController,
                            playersDictionary: [TeamSection: [PlayerResponseModel]],
                            delegate: ConfirmPlayersDelegate) -> ConfirmPlayersModule
    func makeGather(using navigationController: UINavigationController,
                    gather: GatherModel,
                    delegate: GatherDelegate) -> GatherModule
}

위 프로토콜의 구체적인 구현인 ModuleFactory 구조체를 가집니다.

struct ModuleFactory: ModuleFactoryProtocol {
    func makeLogin(using navigationController: UINavigationController = UINavigationController()) -> LoginModule {
        let router = LoginRouter(navigationController: navigationController, moduleFactory: self)
        let view: LoginViewController = Storyboard.defaultStoryboard.instantiateViewController()
        return LoginModule(view: view, router: router)
    }
    /// other functions
    
}

LoginModule 이 어떻게 생성되는지 살펴봅시다.

final class LoginModule {
    
    // Set the dependencies
    private var view: LoginViewProtocol
    private var router: LoginRouterProtocol
    private var interactor: LoginInteractorProtocol
    private var presenter: LoginPresenterProtocol
    
    // Optionally, provide default implementation for your protocols with concrete classes
    init(view: LoginViewProtocol = LoginViewController(),
         router: LoginRouterProtocol = LoginRouter(),
         interactor: LoginInteractorProtocol = LoginInteractor(),
         presenter: LoginPresenterProtocol = LoginPresenter()) {
        self.view = view
        self.router = router
        self.interactor = interactor
        self.presenter = presenter
    }
    
}

// Reference your layers
extension LoginModule: AppModule {
    func assemble() -> UIViewController? {
        presenter.view = view
        presenter.interactor = interactor
        presenter.router = router
        
        interactor.presenter = presenter
        
        view.presenter = presenter
        
        return view as? UIViewController
    }
}

모든 모듈은 AppModule 프로토콜을 구현할 때 필요한 assemble() 함수를 가집니다.

여기에서 VIPER 레이어 간에 참조를 생성합니다:

  • View 를 Presenter 로 설정합니다 (약한 링크 (weak link)).
  • PresenterInteractor 에 강한 참조를 유지합니다.
  • PresenterRouter 에 강한 참조를 유지합니다.
  • InteractorPresenter 에 약한 참조를 유지합니다.
  • ViewPresetner 에 강한 참조를 유지합니다.

물론 메모리 누수를 야기시키는 순환 참조 (retain cycle) 를 피하기 위해 약한 참조를 설정합니다.

앱에 모든 VIPER 모듈은 같은 방법으로 조립됩니다.

LoginRouter 는 간단한 작업을 가집니다: 사용자가 로그인 한 후에 선수를 나타냅니다.

final class LoginRouter {
    
    private let navigationController: UINavigationController
    private let moduleFactory: ModuleFactoryProtocol
    
    // We inject the module factory so we can create and assemble the next screen module (PlayerList).
    init(navigationController: UINavigationController = UINavigationController(),
         moduleFactory: ModuleFactoryProtocol = ModuleFactory()) {
        self.navigationController = navigationController
        self.moduleFactory = moduleFactory
    }
    
}

// When the user logged in, route to PlayerList
extension LoginRouter: LoginRouterProtocol {
    func showPlayerList() {
        let module = moduleFactory.makePlayerList(using: navigationController)
        
        if let viewController = module.assemble() {
            navigationController.pushViewController(viewController, animated: true)
        }
    }
}

코드에 MVP 를 적용할 때 놓친 중요한 것은 View 를 수동적으로 만들지 않았다는 것입니다. Presenter 는 어떤 경우에 ViewModel 처럼 동작했습니다.

이를 수정하고 View 를 최대한 수동적이고 멍청하게 만들어 봅시다.

우리가 한 또다른 일은 LoginViewProtocol 을 여러개 작은 프로토콜로 나누어 특정 요구사항을 해결하는 것이었습니다:

typealias LoginViewProtocol = LoginViewable & Loadable & LoginViewConfigurable & ErrorHandler

protocol LoginViewable: AnyObject {
    var presenter: LoginPresenterProtocol { get set }
}

protocol LoginViewConfigurable: AnyObject {
    var rememberMeIsOn: Bool { get }
    var usernameText: String? { get }
    var passwordText: String? { get }
    
    func setRememberMeSwitch(isOn: Bool)
    func setUsername(_ username: String?)
}

프로코톨 구성을 사용하여 모두 결합하고 typealias 를 사용하요 이름을 지정했습니다. 모든 VIPER 프로토콜에 대해 동일한 접근방식을 사용합니다.

LoginViewController 은 아래 자세히 나와있습니다:

final class LoginViewController: UIViewController, LoginViewable {
    
    // MARK: - Properties
    @IBOutlet weak var usernameTextField: UITextField!
    @IBOutlet weak var passwordTextField: UITextField!
    @IBOutlet weak var rememberMeSwitch: UISwitch!
    lazy var loadingView = LoadingView.initToView(view)
    
    // We can remove the default implementation of LoginPresenter() and force-unwrap the presenter in the protocol definition. We used this approach for some modules.
    var presenter: LoginPresenterProtocol = LoginPresenter()
    
    // MARK: - View life cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        presenter.viewDidLoad()
    }
    
    // MARK: - IBActions
    @IBAction private func login(_ sender: Any) {
        presenter.performLogin()
    }
    
    @IBAction private func register(_ sender: Any) {
        presenter.performRegister()
    }
    
}

// MARK: - Configuration
extension LoginViewController: LoginViewConfigurable {
    // UIKit is not allowed to be referenced in the Presenter. We expose the value of our outlets by using abstraction.
    var rememberMeIsOn: Bool { rememberMeSwitch.isOn }
    
    var usernameText: String? { usernameTextField.text }
    
    var passwordText: String? { passwordTextField.text }
    
    func setRememberMeSwitch(isOn: Bool) {
        rememberMeSwitch.isOn = isOn
    }
    
    func setUsername(_ username: String?) {
        usernameTextField.text = username
    }
}

// MARK: - Loadable
extension LoginViewController: Loadable {}

// MARK: - Error Handler
extension LoginViewController: ErrorHandler {}

Loadable 은 코드베이스의 이전 버전에서 사용한 것과 동일한 프로토콜 헬퍼 입니다. 이것은 간다하게 보이고 일부 네트워크 요청을 수행할 때 응답이 오면 로딩 화면을 숨깁니다. 타입 UIViewUIViewController 의 클래스에 대한 기본 구현을 가집니다 (예: extension Loadable where Self: UIViewController).

ErrorHandler 는 하나의 메서드를 가지는 새로운 프로토콜 헬퍼 입니다:

protocol ErrorHandler {
    func handleError(title: String, message: String)
}

extension ErrorHandler where Self: UIViewController {
    func handleError(title: String, message: String) {
        AlertHelper.present(in: self, title: title, message: message)
    }
}

Alert Controller 를 나타내기 위해 AlertHelper 로 부터 static 메서드를 사용하여 기본 구현을 합니다. 네트워크 에러를 나타내기 위해 사용합니다.

아래에서 Presenter 레이어를 계속 살펴봅시다:

final class LoginPresenter: LoginPresentable {
    
    // MARK: - Properties
    weak var view: LoginViewProtocol?
    var interactor: LoginInteractorProtocol
    var router: LoginRouterProtocol
    
    // MARK: - Public API
    init(view: LoginViewProtocol? = nil,
         interactor: LoginInteractorProtocol = LoginInteractor(),
         router: LoginRouterProtocol = LoginRouter()) {
        self.view = view
        self.interactor = interactor
        self.router = router
    }
    
}

초기화 구문을 통해 주입 되도록 의존성을 설정합니다. 이제 Presenter 는 두 개의 새로운 의존성을 가집니다: InteractorRouter.

ViewControllerView 를 로드를 완료한 후에 Presenter 에 알립니다. View 가 더 수동적으로 되기 위해 PresenterInteractor 로 부터 얻은 정보로 UI 요소로 구성하기 위한 방법을 View 에 지정합니다:

// MARK: - View Configuration
extension LoginPresenter: LoginPresenterViewConfiguration {
    func viewDidLoad() {
        // Fetch the UserDefaults and Keychain values by asking the Interactor. Configure the UI elements based on the values we got.
        let rememberUsername = interactor.rememberUsername
        
        view?.setRememberMeSwitch(isOn: rememberUsername)
        
        if rememberUsername {
            view?.setUsername(interactor.username)
        }
    }
}

서비스 API 가 로그인과 등록을 위한 호출은 유사합니다:

extension LoginPresenter: LoginPresenterServiceInteractable {
    func performLogin() {
        guard validateCredentials() else { return }
        
        view?.showLoadingView()
        
        interactor.login(username: username!, password: password!)
    }
    
    func performRegister() {
        guard validateCredentials() else { return }
        
        view?.showLoadingView()
        
        interactor.register(username: username!, password: password!)
    }
    
    private func validateCredentials() -> Bool {
        guard credentialsAreValid else {
            view?.handleError(title: "Error", message: "Both fields are mandatory.")
            return false
        }
        
        return true
    }
    
    private var credentialsAreValid: Bool {
        username?.isEmpty == false && password?.isEmpty == false
    }
    
    private var username: String? {
        view?.usernameText
    }
    
    private var password: String? {
        view?.passwordText
    }
}

API 호출이 끝나면 InteractorPresenter 로 부터 다음 메서드를 호출합니다:

// MARK: - Service Handler
extension LoginPresenter: LoginPresenterServiceHandler {
    func serviceFailedWithError(_ error: Error) {
        view?.hideLoadingView()
        view?.handleError(title: "Error", message: String(describing: error))
    }
    
    func didLogin() {
        handleAuthCompletion()
    }
    
    func didRegister() {
        handleAuthCompletion()
    }
    
    private func handleAuthCompletion() {
        storeUsernameAndRememberMe()
        view?.hideLoadingView()
        router.showPlayerList()
    }
    
    private func storeUsernameAndRememberMe() {
        let rememberMe = view?.rememberMeIsOn ?? true
        
        if rememberMe {
            interactor.setUsername(view?.usernameText)
        } else {
            interactor.setUsername(nil)
        }
    }
}

Interactor 는 이제 비지니스 로직을 유지합니다:

final class LoginInteractor: LoginInteractable {
    
    weak var presenter: LoginPresenterProtocol?
    
    private let loginService: LoginService
    private let usersService: StandardNetworkService
    private let userDefaults: FootballGatherUserDefaults
    private let keychain: FootbalGatherKeychain
    
    init(loginService: LoginService = LoginService(),
         usersService: StandardNetworkService = StandardNetworkService(resourcePath: "/api/users"),
         userDefaults: FootballGatherUserDefaults = .shared,
         keychain: FootbalGatherKeychain = .shared) {
        self.loginService = loginService
        self.usersService = usersService
        self.userDefaults = userDefaults
        self.keychain = keychain
    }
    
}

rememberMeusername 에 대한 실제 값을 Public API 에서 노출합니다:

// MARK: - Credentials handler
extension LoginInteractor: LoginInteractorCredentialsHandler {
    
    var rememberUsername: Bool { userDefaults.rememberUsername ?? true }
    
    var username: String? { keychain.username }
    
    func setRememberUsername(_ value: Bool) {
        userDefaults.rememberUsername = value
    }
    
    func setUsername(_ username: String?) {
        keychain.username = username
    }
}

서비스 처리는 이전 아키텍처 패턴보다 가볍습니다:

// MARK: - Services
extension LoginInteractor: LoginInteractorServiceRequester {
    func login(username: String, password: String) {
        let requestModel = UserRequestModel(username: username, password: password)
        loginService.login(user: requestModel) { [weak self] result in
            DispatchQueue.main.async {
                switch result {
                case .failure(let error):
                    self?.presenter?.serviceFailedWithError(error)
                    
                case .success(_):
                    self?.presenter?.didLogin()
                }
            }
        }
    }
    
    func register(username: String, password: String) {
        guard let hashedPasssword = Crypto.hash(message: password) else {
            fatalError("Unable to hash password")
        }
        
        let requestModel = UserRequestModel(username: username, password: hashedPasssword)
        usersService.create(requestModel) { [weak self] result in
            DispatchQueue.main.async {
                switch result {
                case .failure(let error):
                    self?.presenter?.serviceFailedWithError(error)
                    
                case .success(let resourceId):
                    print("Created user: \(resourceId)")
                    self?.presenter?.didRegister()
                }
            }
        }
    }
}

선수를 수정할 때 PlayerList 모듈에서 선수의 목록을 새로고침 하기 위해 deletation 을 사용합니다.

struct ModuleFactory: ModuleFactoryProtocol {
    func makePlayerDetails(using navigationController: UINavigationController = UINavigationController(),
                           for player: PlayerResponseModel,
                           delegate: PlayerDetailDelegate) -> PlayerDetailModule {
        let router = PlayerDetailRouter(navigationController: navigationController, moduleFactory: self)
        let view: PlayerDetailViewController = Storyboard.defaultStoryboard.instantiateViewController()
        let interactor = PlayerDetailInteractor(player: player)
        let presenter = PlayerDetailPresenter(interactor: interactor, delegate: delegate)
        
        return PlayerDetailModule(view: view, router: router, interactor: interactor, presenter: presenter)
    }
    
    func makePlayerEdit(using navigationController: UINavigationController = UINavigationController(),
                        for playerEditable: PlayerEditable,
                        delegate: PlayerEditDelegate) -> PlayerEditModule {
        let router = PlayerEditRouter(navigationController: navigationController, moduleFactory: self)
        let view: PlayerEditViewController = Storyboard.defaultStoryboard.instantiateViewController()
        let interactor = PlayerEditInteractor(playerEditable: playerEditable)
        let presenter = PlayerEditPresenter(interactor: interactor, delegate: delegate)
        
        return PlayerEditModule(view: view, router: router, interactor: interactor, presenter: presenter)
    }
    
}

수정 화면으로 이동 (Navigating to Edit screen)

PlayerListPresenter 에서 Router 를 호출하여 PlayerDetailsView 화면을 보았습니다:

func selectRow(at index: Int) {
    guard playersCollectionIsEmpty == false else {
        return
    }
    
    if isInListViewMode {
        let player = interactor.players[index]
        showDetailsView(for: player)
    } else {
        toggleRow(at: index)
        updateSelectedRows(at: index)
        reloadViewAfterRowSelection(at: index)
    }
}

private func showDetailsView(for player: PlayerResponseModel) {
    router.showDetails(for: player, delegate: self)
}

PlayerListRouter 는 아래와 같습니다:

extension PlayerListRouter: PlayerListRouterProtocol {
    func showDetails(for player: PlayerResponseModel, delegate: PlayerDetailDelegate) {
        let module = moduleFactory.makePlayerDetails(using: navigationController, for: player, delegate: delegate)
        
        if let viewController = module.assemble() {
            navigationController.pushViewController(viewController, animated: true)
        }
    }
}

이제 Detail 화면에서 Edit 화면까지 동일한 접근방식을 사용합니다:

func selectRow(at indexPath: IndexPath) {
    let player = interactor.player
    let rowDetails = sections[indexPath.section].rows[indexPath.row]
    let items = self.items(for: rowDetails.editableField)
    let selectedItemIndex = items.firstIndex(of: rowDetails.value.lowercased())
    let editablePlayerDetails = PlayerEditable(player: player,
                                               items: items,
                                               selectedItemIndex: selectedItemIndex,
                                               rowDetails: rowDetails)
    
    router.showEditView(with: editablePlayerDetails, delegate: self)
}

그리고 Router 입니다:

extension PlayerDetailRouter: PlayerDetailRouterProtocol {
    func showEditView(with editablePlayerDetails: PlayerEditable, delegate: PlayerEditDelegate) {
        let module = moduleFactory.makePlayerEdit(using: navigationController, for: editablePlayerDetails, delegate: delegate)
        
        if let viewController = module.assemble() {
            navigationController.pushViewController(viewController, animated: true)
        }
    }
}

목록 화면으로 돌아가기 (Navigating back to the List screen)

사용자가 선수 변경을 확인하면 Presenter delegate 를 호출합니다.

extension PlayerEditPresenter: PlayerEditPresenterServiceHandler {
    func playerWasUpdated() {
        view?.hideLoadingView()
        delegate?.didUpdate(player: interactor.playerEditable.player)
        router.dismissEditView()
    }
}

delegate 는 PlayerDetailsPresenter 입니다:

// MARK: - PlayerEditDelegate
extension PlayerDetailPresenter: PlayerEditDelegate {
    func didUpdate(player: PlayerResponseModel) {
        interactor.updatePlayer(player)
        delegate?.didUpdate(player: player)
    }
}

마지막으로 PlayerListPresenter 에 할당된 PlayerDetailDelegate 을 호출하고 선수의 목록을 새로고침 합니다:

// MARK: - PlayerEditDelegate
extension PlayerListPresenter: PlayerDetailDelegate {
    func didUpdate(player: PlayerResponseModel) {
        interactor.updatePlayer(player)
    }
}

ConfirmAdd 모듈에 대해 동일한 접근방식을 따릅니다:

func confirmOrAddPlayers() {
    if isInListViewMode {
        showAddPlayerView()
    } else {
        showConfirmPlayersView()
    }
}

private var isInListViewMode: Bool {
    viewState == .list
}

private func showAddPlayerView() {
    router.showAddPlayer(delegate: self)
}

private func showConfirmPlayersView() {
    router.showConfirmPlayers(with: interactor.selectedPlayers(atRows: selectedRows), delegate: self)
}

Router 클래스는 아래와 같습니다:

extension PlayerListRouter: PlayerListRouterProtocol {
    func showAddPlayer(delegate: PlayerAddDelegate) {
        let module = moduleFactory.makePlayerAdd(using: navigationController, delegate: delegate)
        
        if let viewController = module.assemble() {
            navigationController.pushViewController(viewController, animated: true)
        }
    }
    
    func showConfirmPlayers(with playersDictionary: [TeamSection: [PlayerResponseModel]], delegate: ConfirmPlayersDelegate) {
        let module = moduleFactory.makeConfirmPlayers(using: navigationController, playersDictionary: playersDictionary, delegate: delegate)
        
        if let viewController = module.assemble() {
            navigationController.pushViewController(viewController, animated: true)
        }
    }
}

PlayerAddPresenter 에 서비스 처리를 구현합니다:

extension PlayerAddPresenter: PlayerAddPresenterServiceHandler {
    func playerWasAdded() {
        view?.hideLoadingView()
        delegate?.didAddPlayer()
        router.dismissAddView()
    }
}

마지막으로 선수 목록에 위임 (delegation) 합니다:

// MARK: - PlayerAddDelegate
extension PlayerListPresenter: PlayerAddDelegate {
    func didAddPlayer() {
        loadPlayers()
    }
}

// MARK: - ConfirmPlayersDelegate
extension PlayerListPresenter: ConfirmPlayersDelegate {
    func didEndGather() {
        viewState = .list
        configureView()
        view?.reloadData()
    }
}

이 아키텍처 패턴에서 MVP 에 적용된 개념과 마찬가지로 최대한 View 가 수동적으로 만들고 싶었습니다. table row 에 대한 CellViewPresenter 을 생성하였습니다:

protocol PlayerTableViewCellPresenterProtocol: AnyObject {
    var view: PlayerTableViewCellProtocol? { get set }
    var viewState: PlayerListViewState { get set }
    var isSelected: Bool { get set }
    
    func setupView()
    func configure(with player: PlayerResponseModel)
    func toggle()
}

구체적인 클래스는 아래에 나와있습니다:

final class PlayerTableViewCellPresenter: PlayerTableViewCellPresenterProtocol {
    
    var view: PlayerTableViewCellProtocol?
    var viewState: PlayerListViewState
    var isSelected = false
    
    init(view: PlayerTableViewCellProtocol? = nil,
         viewState: PlayerListViewState = .list) {
        self.view = view
        self.viewState = viewState
    }
    
    func setupView() {
        if viewState == .list {
            view?.setupDefaultView()
        } else {
            view?.setupViewForSelection(isSelected: isSelected)
        }
    }
    
    func toggle() {
        isSelected.toggle()
        
        if viewState == .selection {
            view?.setupCheckBoxImage(isSelected: isSelected)
        }
    }
    
    func configure(with player: PlayerResponseModel) {
        view?.set(nameDescription: player.name)
        setPositionDescription(for: player)
        setSkillDescription(for: player)
    }
    
    private func setPositionDescription(for player: PlayerResponseModel) {
        let position = player.preferredPosition?.rawValue
        view?.set(positionDescription: "Position: \(position ?? "-")")
    }
    
    private func setSkillDescription(for player: PlayerResponseModel) {
        let skill = player.skill?.rawValue
        view?.set(skillDescription: "Skill: \(skill ?? "-")")
    }
    
}

Presenter 는 CellView 를 업데이트 합니다:

final class PlayerTableViewCell: UITableViewCell, PlayerTableViewCellProtocol {
    @IBOutlet weak var checkboxImageView: UIImageView!
    @IBOutlet weak var playerCellLeftConstraint: NSLayoutConstraint!
    @IBOutlet weak var nameLabel: UILabel!
    @IBOutlet weak var positionLabel: UILabel!
    @IBOutlet weak var skillLabel: UILabel!
    
    private enum Constants {
        static let playerContentLeftPadding: CGFloat = 10.0
        static let playerContentAndIconLeftPadding: CGFloat = -20.0
    }
    
    func setupDefaultView() {
        playerCellLeftConstraint.constant = Constants.playerContentAndIconLeftPadding
        setupCheckBoxImage(isSelected: false)
        checkboxImageView.isHidden = true
    }
    
    func setupViewForSelection(isSelected: Bool) {
        playerCellLeftConstraint.constant = Constants.playerContentLeftPadding
        checkboxImageView.isHidden = false
        setupCheckBoxImage(isSelected: isSelected)
    }
    
    func setupCheckBoxImage(isSelected: Bool) {
        let imageName = isSelected ? "ticked" : "unticked"
        checkboxImageView.image = UIImage(named: imageName)
    }
    
    func set(nameDescription: String) {
        nameLabel.text = nameDescription
    }
    
    func set(positionDescription: String) {
        positionLabel.text = positionDescription
    }
    
    func set(skillDescription: String) {
        skillLabel.text = skillDescription
    }
    
}

PlayerViewController 에서 cellForRowAt 메서드를 가집니다:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard let cell: PlayerTableViewCell = tableView.dequeueReusableCell(withIdentifier: "PlayerTableViewCell") as? PlayerTableViewCell else {
        return UITableViewCell()
    }
    
    let index = indexPath.row
    let cellPresenter = presenter.cellPresenter(at: index)
    let player = presenter.player(at: index)
    
    cellPresenter.view = cell
    cellPresenter.setupView()
    cellPresenter.configure(with: player)
    
    return cell
}

Presenter 내에서 기존 셀 Presenter 를 캐시합니다:

func cellPresenter(at index: Int) -> PlayerTableViewCellPresenterProtocol {
    if let cellPresenter = cellPresenters[index] {
        cellPresenter.viewState = viewState
        return cellPresenter
    }
    
    let cellPresenter = PlayerTableViewCellPresenter(viewState: viewState)
    cellPresenters[index] = cellPresenter
    
    return cellPresenter
}

마지막으로 기본 앱 모듈인 Gather 를 나타냅니다.

GatherViewController 는 간소화되고 더 보기 좋습니다:

// MARK: - GatherViewController
final class GatherViewController: UIViewController, GatherViewable {
    
    // MARK: - Properties
    @IBOutlet weak var playerTableView: UITableView!
    @IBOutlet weak var scoreLabelView: ScoreLabelView!
    @IBOutlet weak var scoreStepper: ScoreStepper!
    @IBOutlet weak var timerLabel: UILabel!
    @IBOutlet weak var timerView: UIView!
    @IBOutlet weak var timePickerView: UIPickerView!
    @IBOutlet weak var actionTimerButton: UIButton!
    
    lazy var loadingView = LoadingView.initToView(view)
    
    var presenter: GatherPresenterProtocol!
    
    // MARK: - View life cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        presenter.viewDidLoad()
    }
    
    // MARK: - IBActions
    @IBAction private func endGather(_ sender: Any) {
        presenter.requestToEndGather()
    }
    
    @IBAction private func setTimer(_ sender: Any) {
        presenter.setTimer()
    }
    
    @IBAction private func cancelTimer(_ sender: Any) {
        presenter.cancelTimer()
    }
    
    @IBAction private func actionTimer(_ sender: Any) {
        presenter.actionTimer()
    }
    
    @IBAction private func timerCancel(_ sender: Any) {
        presenter.timerCancel()
    }
    
    @IBAction private func timerDone(_ sender: Any) {
        presenter.timerDone()
    }
    
}

GatherViewConfigurable 프로토콜을 사용하여 Public API 를 노출합니다:

// MARK: - Configuration
extension GatherViewController: GatherViewConfigurable {
    var scoreDescription: String {
        scoreLabelView.scoreDescription
    }
    
    var winnerTeamDescription: String {
        scoreLabelView.winnerTeamDescription
    }
    
    func configureTitle(_ title: String) {
        self.title = title
    }
    
    func setActionButtonTitle(_ title: String) {
        actionTimerButton.setTitle(title, for: .normal)
    }
    
    func setupScoreStepper() {
        scoreStepper.delegate = self
    }
    
    func setTimerViewVisibility(isHidden: Bool) {
        timerView.isHidden = isHidden
    }
    
    func selectRow(_ row: Int, inComponent component: Int, animated: Bool = false) {
        timePickerView.selectRow(row, inComponent: component, animated: animated)
    }
    
    func selectedRow(in component: Int) -> Int {
        timePickerView.selectedRow(inComponent: component)
    }
    
    func setTimerLabelText(_ text: String) {
        timerLabel.text = text
    }
    
    func setTeamALabelText(_ text: String) {
        scoreLabelView.teamAScoreLabel.text = text
    }
    
    func setTeamBLabelText(_ text: String) {
        scoreLabelView.teamBScoreLabel.text = text
    }
}

GatherViewReloadablereloadData 메서드를 정의합니다. 여기서 모든 picker 컴포넌트와 tableView 데이터를 새로고침 합니다.

// MARK: - Reload
extension GatherViewController: GatherViewReloadable {
    func reloadData() {
        timePickerView.reloadAllComponents()
        playerTableView.reloadData()
    }
}

더이상 ViewControllerView 로 나눠진 두 개의 레이어가 없습니다. alert controller 는 View 레이어 내에서 수행됩니다:

// MARK: - Confirmation
extension GatherViewController: GatherViewConfirmable {
    func displayConfirmationAlert() {
        let alertController = UIAlertController(title: "End Gather", message: "Are you sure you want to end the gather?", preferredStyle: .alert)
        let confirmAction = UIAlertAction(title: "Yes", style: .default) { [weak self] _ in
            self?.presenter.endGather()
        }
        alertController.addAction(confirmAction)
        
        let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
        alertController.addAction(cancelAction)
        
        present(alertController, animated: true, completion: nil)
    }
}

별도의 레이러를 사용하고 table 과 picker 의 DataSourceDelegate 에 대한 다른 객체를 생성할 수도 있지만 연습을 위해 ViewController 내부에 메서드를 구현하는 것을 선호했습니다:

// MARK: - UITableViewDelegate | UITableViewDataSource
extension GatherViewController: UITableViewDelegate, UITableViewDataSource {
    func numberOfSections(in tableView: UITableView) -> Int {
        presenter.numberOfSections
    }
    
    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        presenter.titleForHeaderInSection(section)
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        presenter.numberOfRowsInSection(section)
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "GatherCellId") else {
            return UITableViewCell()
        }
        
        let cellPresenter = GatherTableViewCellPresenter(view: cell)
        cellPresenter.configure(title: presenter.rowTitle(at: indexPath), descriptionDetails: presenter.rowDescription(at: indexPath))
        
        return cell
    }
}

// MARK: - UIPickerViewDataSource
extension GatherViewController: UIPickerViewDataSource, UIPickerViewDelegate {
    func numberOfComponents(in pickerView: UIPickerView) -> Int {
        presenter.numberOfPickerComponents
    }
    
    func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
        presenter.numberOfRowsInPickerComponent(component)
    }
    
    func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
        presenter.titleForPickerRow(row, forComponent: component)
    }
}

팀 슬라이더의 UI 업데이트를 Presenter 에 전달하기 위해 ScoreStepperDelegate 도 구현했습니다.

그리고 마지막으로 커스텀 셀에 대한 기능을 추가하고 로딩 스피너를 보이고 숨기고 에러를 처리하기 위해 헬퍼 프로토콜이 있습니다.

// MARK: - UITableViewCell
extension UITableViewCell: GatherTableViewCellProtocol {}

// MARK: - Loadable
extension GatherViewController: Loadable {}

// MARK: - Error Handler
extension GatherViewController: ErrorHandler {}

비지니스 로직 테스트 (Testing our business logic)

VIPER 에서 비지니스 로직을 처리하는 Interactor 를 가집니다. 이것은 테스트 되어야 합니다.

그러나 아키텍처의 중심은 View 를 업데이트 하고 RouterInteractor 모두 통신하는 Presenter 입니다. 이것도 테스트 되어야 합니다.

Presenter 테스트 (Testing the Presenter)

Presenter 유닛 테스트에 대한 클래스는 GatherPresenterTests 입니다:

final class GatherPresenterTests: XCTestCase {
    
    // MARK: - GatherPresenterViewConfiguration
    func testViewDidLoad_whenPresenterIsAllocated_configuresView() {
        // given
        let mockView = GatherMockView()
        let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
        let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
        
        // when
        sut.viewDidLoad()
        
        // then
        XCTAssertEqual(mockView.title, "Gather in progress")
        XCTAssertTrue(mockView.timerViewIsHidden)
        XCTAssertEqual(mockView.selectionDictionary[mockInteractor.minutesComponent!.rawValue], mockInteractor.selectedTime.minutes)
        XCTAssertEqual(mockView.selectionDictionary[mockInteractor.secondsComponent!.rawValue], mockInteractor.selectedTime.seconds)
        XCTAssertEqual(mockView.timerLabelText, "10:00")
        XCTAssertEqual(mockView.actionButtonTitle, "Start")
        XCTAssertTrue(mockView.scoreStepperWasSetup)
        XCTAssertTrue(mockView.viewWasReloaded)
    }
    
    func testViewDidLoad_whenTimeComponentsAreEmpty_minutesComponentIsNil() {
        // given
        let mockView = GatherMockView()
        let mockGather = GatherModel(players: [], gatherUUID: UUID())
        let mockInteractor = GatherInteractor(gather: mockGather, timeComponents: [])
        let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
        
        // when
        sut.viewDidLoad()
        
        // then
        XCTAssertNil(mockView.selectionDictionary[0])
        XCTAssertNil(mockView.selectionDictionary[1])
    }
    
}

table view 와 picker view 의 Data Source 를 테스트 합니다:

// MARK: - Table Data Source
func testNumberOfSections_whenPresenterIsAllocated_equalsTeamSectionsCount() {
    // given
    let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
    let sut = GatherPresenter(interactor: mockInteractor)
    
    // when
    let numberOfSections = sut.numberOfSections
    
    // then
    XCTAssertEqual(mockInteractor.teamSections.count, numberOfSections)
}

func testNumberOfRowsInSection_whenSectionIsZero_equalsNumberOfPlayersInTeamSection() {
    // given
    let mockGather = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    let expectedNumberOfPlayers = mockGather.players.filter { $0.team == .teamA }.count
    let mockInteractor = GatherInteractor(gather: mockGather)
    let sut = GatherPresenter(interactor: mockInteractor)
    
    // when
    let numberOfRowsInSection = sut.numberOfRowsInSection(0)
    
    // then
    XCTAssertEqual(numberOfRowsInSection, expectedNumberOfPlayers)
}

func testNumberOfRowsInSection_whenSectionIsOne_equalsNumberOfPlayersInTeamSection() {
    // given
    let mockGather = ModelsMockFactory.makeGatherModel(numberOfPlayers: 5)
    let expectedNumberOfPlayers = mockGather.players.filter { $0.team == .teamB }.count
    let mockInteractor = GatherInteractor(gather: mockGather)
    let sut = GatherPresenter(interactor: mockInteractor)
    
    // when
    let numberOfRowsInSection = sut.numberOfRowsInSection(1)
    
    // then
    XCTAssertEqual(numberOfRowsInSection, expectedNumberOfPlayers)
}

func testRowTitle_whenInteractorHasPlayers_equalsPlayerName() {
    // given
    let mockGather = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    let expectedRowTitle = mockGather.players.filter { $0.team == .teamA }.first?.player.name
    let mockInteractor = GatherInteractor(gather: mockGather)
    let sut = GatherPresenter(interactor: mockInteractor)
    
    // when
    let rowTitle = sut.rowTitle(at: IndexPath(row: 0, section: 0))
    
    // then
    XCTAssertEqual(rowTitle, expectedRowTitle)
}

func testRowDescription_whenInteractorHasPlayers_equalsPlayerPreferredPositionAcronym() {
    // given
    let mockGather = ModelsMockFactory.makeGatherModel(numberOfPlayers: 1)
    let expectedRowDescription = mockGather.players.filter { $0.team == .teamB }.first?.player.preferredPosition?.acronym
    let mockInteractor = GatherInteractor(gather: mockGather)
    let sut = GatherPresenter(interactor: mockInteractor)
    
    // when
    let rowDescription = sut.rowDescription(at: IndexPath(row: 0, section: 1))
    
    // then
    XCTAssertEqual(rowDescription, expectedRowDescription)
}

func testTitleForHeaderInSection_whenSectionIsTeamA_equalsTeamSectionHeaderTitle() {
    // given
    let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
    let expectedTitle = TeamSection.teamA.headerTitle
    let sut = GatherPresenter(interactor: mockInteractor)
    
    // when
    let titleForHeader = sut.titleForHeaderInSection(0)
    
    // then
    XCTAssertEqual(titleForHeader, expectedTitle)
}

func testTitleForHeaderInSection_whenSectionIsTeamB_equalsTeamSectionHeaderTitle() {
    // given
    let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
    let expectedTitle = TeamSection.teamB.headerTitle
    let sut = GatherPresenter(interactor: mockInteractor)
    
    // when
    let titleForHeader = sut.titleForHeaderInSection(1)
    
    // then
    XCTAssertEqual(titleForHeader, expectedTitle)
}

// MARK: - Picker Data Source
func testNumberOfPickerComponents_whenTimeComponentsAreGiven_equalsInteractorTimeComponents() {
    // given
    let mockTimeComponents: [GatherTimeHandler.Component] = [.minutes]
    let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()), timeComponents: mockTimeComponents)
    let sut = GatherPresenter(interactor: mockInteractor)
    
    // when
    let numberOfPickerComponents = sut.numberOfPickerComponents
    
    // then
    XCTAssertEqual(numberOfPickerComponents, mockTimeComponents.count)
}

func testNumberOfRowsInPickerComponent_whenComponentIsMinutes_equalsNumberOfSteps() {
    // given
    let mockTimeComponents: [GatherTimeHandler.Component] = [.minutes]
    let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()), timeComponents: mockTimeComponents)
    let sut = GatherPresenter(interactor: mockInteractor)
    
    // when
    let numberOfRowsInPickerComponent = sut.numberOfRowsInPickerComponent(0)
    
    // then
    XCTAssertEqual(numberOfRowsInPickerComponent, GatherTimeHandler.Component.minutes.numberOfSteps)
}

func testNumberOfRowsInPickerComponent_whenComponentIsSeconds_equalsNumberOfSteps() {
    // given
    let mockTimeComponents: [GatherTimeHandler.Component] = [.seconds]
    let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()), timeComponents: mockTimeComponents)
    let sut = GatherPresenter(interactor: mockInteractor)
    
    // when
    let numberOfRowsInPickerComponent = sut.numberOfRowsInPickerComponent(0)
    
    // then
    XCTAssertEqual(numberOfRowsInPickerComponent, GatherTimeHandler.Component.seconds.numberOfSteps)
}

func testTitleForPickerRow_whenComponentsAreNotEmpty_containsTimeComponentShort() {
    // given
    let mockTimeComponents: [GatherTimeHandler.Component] = [.seconds]
    let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()), timeComponents: mockTimeComponents)
    let sut = GatherPresenter(interactor: mockInteractor)
    
    // when
    let titleForPickerRow = sut.titleForPickerRow(0, forComponent: 0)
    
    // then
    XCTAssertTrue(titleForPickerRow.contains(GatherTimeHandler.Component.seconds.short))
}

스테퍼 핸들러를 테스트 합니다:

// MARK: - Stepper Handler
func testUpdateValue_whenTeamIsA_viewSetsTeamALabelTextWithNewValue() {
    // given
    let mockValue = 15.0
    let mockView = GatherMockView()
    let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
    let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
    
    // when
    sut.updateValue(for: .teamA, with: mockValue)
    
    // then
    XCTAssertEqual(mockView.teamALabelText, "\(Int(mockValue))")
}

func testUpdateValue_whenTeamIsB_viewSetsTeamBLabelTextWithNewValue() {
    // given
    let mockValue = 15.0
    let mockView = GatherMockView()
    let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
    let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
    
    // when
    sut.updateValue(for: .teamB, with: mockValue)
    
    // then
    XCTAssertEqual(mockView.teamBLabelText, "\(Int(mockValue))")
}

IBActions 을 테스트 합니다:

// MARK: - Actions
func testRequestToEndGather_whenPresenterIsAllocated_viewDisplaysConfirmationAlert() {
    // given
    let mockView = GatherMockView()
    let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
    let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
    
    // when
    sut.requestToEndGather()
    
    // then
    XCTAssertTrue(mockView.confirmationAlertWasDisplayed)
}

func testSetTimer_whenPresenterIsAllocated_selectsRowAndSetsTimerViewVisibile() {
    // given
    let mockView = GatherMockView()
    let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
    let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
    
    // when
    sut.setTimer()
    
    // then
    XCTAssertNotNil(mockView.selectionDictionary[0])
    XCTAssertNotNil(mockView.selectionDictionary[1])
    XCTAssertFalse(mockView.timerViewIsHidden)
}

func testCancelTimer_whenPresenterIsAllocated_resetsTimerAndUpdatesView() {
    // given
    let mockView = GatherMockView()
    let mockInteractor = GatherMockInteractor()
    let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
    
    // when
    sut.cancelTimer()
    
    // then
    XCTAssertTrue(mockInteractor.timerWasStopped)
    XCTAssertTrue(mockInteractor.timerWasResetted)
    XCTAssertNotNil(mockView.timerLabelText)
    XCTAssertNotNil(mockView.actionButtonTitle)
    XCTAssertTrue(mockView.timerViewIsHidden)
}

func testActionTimer_whenPresenterIsAllocated_togglesTimerAndUpdatesActionButtonTitle() {
    // given
    let mockView = GatherMockView()
    let mockInteractor = GatherMockInteractor()
    let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
    
    // when
    sut.actionTimer()
    
    // then
    XCTAssertTrue(mockInteractor.timerWasToggled)
    XCTAssertNotNil(mockView.actionButtonTitle)
}

func testTimerCancel_whenPresenterIsAllocated_timerViewIsHidden() {
    // given
    let mockView = GatherMockView()
    let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
    let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
    
    // when
    sut.timerCancel()
    
    // then
    XCTAssertTrue(mockView.timerViewIsHidden)
}

func testTimerDone_whenPresenterIsAllocated_stopsTimerUpdatesTimeAndConfiguresView() {
    // given
    let mockView = GatherMockView()
    let mockInteractor = GatherMockInteractor()
    let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
    
    // when
    sut.timerDone()
    
    // then
    XCTAssertTrue(mockInteractor.timerWasStopped)
    XCTAssertTrue(mockInteractor.timeWasUpdated)
    XCTAssertNotNil(mockView.timerLabelText)
    XCTAssertNotNil(mockView.actionButtonTitle)
    XCTAssertTrue(mockView.timerViewIsHidden)
}

func testTimerDone_whenViewIsNil_stopsTimerUpdatesTimeAndConfiguresView() {
    // given
    let mockInteractor = GatherMockInteractor()
    let sut = GatherPresenter(interactor: mockInteractor)
    
    // when
    sut.timerDone()
    
    // then
    XCTAssertFalse(mockInteractor.timeWasUpdated)
}

func testEndGather_whenViewIsNotNil_showsLoadingViewAndEndsGather() {
    // given
    let mockView = GatherMockView()
    let mockInteractor = GatherMockInteractor()
    let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
    
    // when
    sut.endGather()
    
    // then
    XCTAssertTrue(mockView.loadingViewWasShown)
    XCTAssertTrue(mockInteractor.gatherWasEnded)
}

func testEndGather_whenViewIsNil_returns() {
    // given
    let mockInteractor = GatherMockInteractor()
    let sut = GatherPresenter(interactor: mockInteractor)
    
    // when
    sut.endGather()
    
    // then
    XCTAssertFalse(mockInteractor.gatherWasEnded)
}

func testGatherEnded_whenPresenterIsAllocated_hidesLoadingViewEndGathersAndPopsToPlayerList() {
    // given
    let mockView = GatherMockView()
    let mockInteractor = GatherMockInteractor()
    let mockDelegate = GatherMockDelegate()
    let mockRouter = GatherMockRouter()
    let sut = GatherPresenter(view: mockView, interactor: mockInteractor, router: mockRouter, delegate: mockDelegate)
    
    // when
    sut.gatherEnded()
    
    // then
    XCTAssertTrue(mockView.loadingViewWasHidden)
    XCTAssertTrue(mockDelegate.gatherWasEnded)
    XCTAssertTrue(mockRouter.poppedToPlayerList)
}

func testServiceFailedToEndGather_whenPresenterIsAllocated_hidesLoadingViewAndHandlesError() {
    // given
    let mockView = GatherMockView()
    let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
    let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
    
    // when
    sut.serviceFailedToEndGather()
    
    // then
    XCTAssertTrue(mockView.loadingViewWasHidden)
    XCTAssertTrue(mockView.errorWasHandled)
}

func testTimerDecremented_whenPresenterIsAllocated_setsTimerLabelText() {
    // given
    let mockView = GatherMockView()
    let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
    let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
    
    // when
    sut.timerDecremented()
    
    // then
    XCTAssertNotNil(mockView.timerLabelText)
}

func testActionButtonTitle_whenTimerStateIsPaused_isResume() {
    // given
    let mockView = GatherMockView()
    let mockInteractor = GatherMockInteractor(timerState: .paused)
    let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
    
    // when
    sut.actionTimer()
    
    // then
    XCTAssertEqual(mockView.actionButtonTitle, "Resume")
}

func testActionButtonTitle_whenTimerStateIsRunning_isPause() {
    // given
    let mockView = GatherMockView()
    let mockInteractor = GatherMockInteractor(timerState: .running)
    let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
    
    // when
    sut.actionTimer()
    
    // then
    XCTAssertEqual(mockView.actionButtonTitle, "Pause")
}

Mock 은 다른 파일에 정의됩니다:

// MARK: - View
final class GatherMockView: GatherViewProtocol {
    var presenter: GatherPresenterProtocol!
    var loadingView = LoadingView()
    
    private(set) var title: String?
    private(set) var timerViewIsHidden = false
    private(set) var selectionDictionary: [Int: Int] = [:]
    private(set) var timerLabelText: String?
    private(set) var actionButtonTitle: String?
    private(set) var scoreStepperWasSetup = false
    private(set) var viewWasReloaded = false
    private(set) var teamALabelText: String?
    private(set) var teamBLabelText: String?
    private(set) var confirmationAlertWasDisplayed = false
    private(set) var loadingViewWasShown = false
    private(set) var loadingViewWasHidden = false
    private(set) var errorWasHandled = false
    
    var scoreDescription: String { "" }
    
    var winnerTeamDescription: String { "" }
    
    func configureTitle(_ title: String) {
        self.title = title
    }
    
    func setTimerViewVisibility(isHidden: Bool) {
        timerViewIsHidden = isHidden
    }
    
    func selectRow(_ row: Int, inComponent component: Int, animated: Bool) {
        selectionDictionary[component] = row
    }
    
    func setTimerLabelText(_ text: String) {
        timerLabelText = text
    }
    
    func setActionButtonTitle(_ title: String) {
        actionButtonTitle = title
    }
    
    func setupScoreStepper() {
        scoreStepperWasSetup = true
    }
    
    func reloadData() {
        viewWasReloaded = true
    }
    
    func setTeamALabelText(_ text: String) {
        teamALabelText = text
    }
    
    func setTeamBLabelText(_ text: String) {
        teamBLabelText = text
    }
    
    func displayConfirmationAlert() {
        confirmationAlertWasDisplayed = true
    }
    
    func showLoadingView() {
        loadingViewWasShown = true
    }
    
    func hideLoadingView() {
        loadingViewWasHidden = true
    }
    
    func handleError(title: String, message: String) {
        errorWasHandled = true
    }
    
    func selectedRow(in component: Int) -> Int { 0 }
}

// MARK: - Interactor
final class GatherMockInteractor: GatherInteractorProtocol {
    
    var presenter: GatherPresenterServiceHandler?
    var teamSections: [TeamSection] = TeamSection.allCases
    
    private(set) var timerState: GatherTimeHandler.State
    private(set) var timerWasStopped = false
    private(set) var timerWasResetted = false
    private(set) var timerWasToggled = false
    private(set) var timeWasUpdated = false
    private(set) var gatherWasEnded = false
    
    init(timerState: GatherTimeHandler.State = .stopped) {
        self.timerState = timerState
    }
    
    func stopTimer() {
        timerWasStopped = true
    }
    
    func resetTimer() {
        timerWasResetted = true
    }
    
    func teamSection(at index: Int) -> TeamSection {
        teamSections[index]
    }
    
    func toggleTimer() {
        timerWasToggled = true
    }
    
    func updateTime(_ gatherTime: GatherTime) {
        timeWasUpdated = true
    }
    
    func endGather(score: String, winnerTeam: String) {
        gatherWasEnded = true
    }
    
    var selectedTime: GatherTime { .defaultTime }
    
    var minutesComponent: GatherTimeHandler.Component? { .minutes }
    
    var secondsComponent: GatherTimeHandler.Component? { .seconds }
    
    var timeComponents: [GatherTimeHandler.Component] = GatherTimeHandler.Component.allCases
    
    func timeComponent(at index: Int) -> GatherTimeHandler.Component {
        timeComponents[index]
    }
    
    func players(in team: TeamSection) -> [PlayerResponseModel] { [] }
    
}

// MARK: - Delegate
final class GatherMockDelegate: GatherDelegate {
    private(set) var gatherWasEnded = false
    
    func didEndGather() {
        gatherWasEnded = true
    }
    
}

// MARK: - Router
final class GatherMockRouter: GatherRouterProtocol {
    private(set) var poppedToPlayerList = false
    
    func popToPlayerListView() {
        poppedToPlayerList = true
    }
    
}

Interactor 를 테스트 합니다:

import XCTest
@testable import FootballGather

final class GatherInteractorTests: XCTestCase {
    
    func testTeamSections_whenInteractorIsAllocated_equalsTeamAandTeamB() {
        // given
        let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
        
        // when
        let teamSections = sut.teamSections
        
        // then
        XCTAssertEqual(teamSections, [.teamA, .teamB])
    }
    
    func testTeamSection_whenIndexIsZero_equalsTeamA() {
        // given
        let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
        
        // when
        let teamSection = sut.teamSection(at: 0)
        
        // then
        XCTAssertEqual(teamSection, .teamA)
    }
    
    func testTeamSection_whenIndexIsOne_equalsTeamB() {
        // given
        let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
        
        // when
        let teamSection = sut.teamSection(at: 1)
        
        // then
        XCTAssertEqual(teamSection, .teamB)
    }
    
    func testPlayersInTeam_whenInteractorHasPlayers_returnsPlayersForTheGivenTeam() {
        // given
        let mockGather = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
        let expectedPlayers = mockGather.players.filter { $0.team == .teamA }.compactMap { $0.player }
        let sut = GatherInteractor(gather: mockGather)
        
        // when
        let players = sut.players(in: .teamA)
        
        // then
        XCTAssertEqual(players, expectedPlayers)
    }
    
    func testEndGather_whenScoreIsSet_updatesGather() {
        // given
        let appKeychain = AppKeychainMockFactory.makeKeychain()
        appKeychain.token = ModelsMock.token
        let session = URLSessionMockFactory.makeSession()
        
        let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
        let mockEndpoint = EndpointMockFactory.makeSuccessfulEndpoint(path: "/api/gathers")
        let mockService = StandardNetworkService(session: session, urlRequest: AuthURLRequestFactory(endpoint: mockEndpoint, keychain: appKeychain))
        let mockPresenter = GatherMockPresenter()
        let exp = expectation(description: "Update gather expectation")
        mockPresenter.expectation = exp
        let sut = GatherInteractor(gather: mockGatherModel, updateGatherService: mockService)
        sut.presenter = mockPresenter
        
        // when
        sut.endGather(score: "1-1", winnerTeam: "None")
        
        // then
        waitForExpectations(timeout: 5) { _ in
            XCTAssertTrue(mockPresenter.gatherEndedCalled)
            appKeychain.storage.removeAll()
        }
    }
    
    func testEndGather_whenScoreIsNotSet_updatesGather() {
        // given
        let appKeychain = AppKeychainMockFactory.makeKeychain()
        appKeychain.token = ModelsMock.token
        let session = URLSessionMockFactory.makeSession()
        
        let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
        let mockEndpoint = EndpointMockFactory.makeSuccessfulEndpoint(path: "/api/gathers")
        let mockService = StandardNetworkService(session: session, urlRequest: AuthURLRequestFactory(endpoint: mockEndpoint, keychain: appKeychain))
        let mockPresenter = GatherMockPresenter()
        let exp = expectation(description: "Update gather expectation")
        mockPresenter.expectation = exp
        let sut = GatherInteractor(gather: mockGatherModel, updateGatherService: mockService)
        sut.presenter = mockPresenter
        
        // when
        sut.endGather(score: "", winnerTeam: "")
        
        // then
        waitForExpectations(timeout: 5) { _ in
            XCTAssertTrue(mockPresenter.serviceFailedCalled)
            appKeychain.storage.removeAll()
        }
    }
    
    func testMinutesComponent_whenInteractorIsAllocated_isMinutes() {
        // given
        let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
        
        // when
        let minutesComponent = sut.minutesComponent
        
        // then
        XCTAssertEqual(minutesComponent, .minutes)
    }
    
    func testSecondsComponent_whenInteractorIsAllocated_isSeconds() {
        // given
        let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
        
        // when
        let secondsComponent = sut.secondsComponent
        
        // then
        XCTAssertEqual(secondsComponent, .seconds)
    }
    
    func testSelectedTime_whenInteractorIsAllocated_isDefaultTime() {
        // given
        let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
        
        // when
        let selectedTime = sut.selectedTime
        
        // then
        XCTAssertEqual(selectedTime.minutes, GatherTime.defaultTime.minutes)
        XCTAssertEqual(selectedTime.seconds, GatherTime.defaultTime.seconds)
    }
    
    func testTimerState_whenInteractorIsAllocated_isStopped() {
        // given
        let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
        
        // when
        let timerState = sut.timerState
        
        // then
        XCTAssertEqual(timerState, .stopped)
    }
    
    func testTimeComponent_whenIndexIsZero_isMinutes() {
        // given
        let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
        
        // when
        let timeComponent = sut.timeComponent(at: 0)
        
        // then
        XCTAssertEqual(timeComponent, .minutes)
    }
    
    func testTimeComponent_whenIndexIsOne_isSeconds() {
        // given
        let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
        
        // when
        let timeComponent = sut.timeComponent(at: 1)
        
        // then
        XCTAssertEqual(timeComponent, .seconds)
    }
    
    func testStopTimer_whenStateIsRunning_isStopped() {
        // given
        let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()), timeHandler: GatherTimeHandler(state: .running))
        
        // when
        sut.stopTimer()
        
        // then
        XCTAssertEqual(sut.timerState, .stopped)
    }
    
    func testUpdateTimer_whenGatherTimeIsDifferent_updatesSelectedTime() {
        // given
        let mockSelectedTime = GatherTime(minutes: 99, seconds: 101)
        let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
        
        // when
        sut.updateTime(mockSelectedTime)
        
        // then
        XCTAssertEqual(sut.selectedTime.minutes, mockSelectedTime.minutes)
        XCTAssertEqual(sut.selectedTime.seconds, mockSelectedTime.seconds)
    }
    
    func testToggleTimer_whenTimeIsValid_decrementsTime() {
        // given
        let numberOfUpdateCalls = 2
        let mockSelectedTime = GatherTime(minutes: 0, seconds: numberOfUpdateCalls)
        let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()), timeHandler: GatherTimeHandler(selectedTime: mockSelectedTime))
        
        let mockPresenter = GatherMockPresenter()
        let exp = expectation(description: "Update gather expectation")
        mockPresenter.expectation = exp
        mockPresenter.numberOfUpdateCalls = numberOfUpdateCalls
        
        sut.presenter = mockPresenter
        
        // when
        sut.toggleTimer()
        
        // then
        waitForExpectations(timeout: 5) { _ in
            XCTAssertTrue(mockPresenter.timerDecrementedCalled)
            XCTAssertEqual(mockPresenter.actualUpdateCalls, numberOfUpdateCalls)
            sut.stopTimer()
        }
    }
    
    func testToggleTimer_whenTimeIsInvalid_returns() {
        // given
        let mockSelectedTime = GatherTime(minutes: -1, seconds: -1)
        let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()), timeHandler: GatherTimeHandler(selectedTime: mockSelectedTime))
        let mockPresenter = GatherMockPresenter()
        sut.presenter = mockPresenter
        
        // when
        sut.toggleTimer()
        
        // then
        XCTAssertFalse(mockPresenter.timerDecrementedCalled)
    }
    
    func testResetTimer_whenInteractorIsAllocated_resetsTime() {
        // given
        let numberOfUpdateCalls = 1
        let mockSelectedTime = GatherTime(minutes: 0, seconds: numberOfUpdateCalls)
        let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()), timeHandler: GatherTimeHandler(selectedTime: mockSelectedTime))
        
        let mockPresenter = GatherMockPresenter()
        let exp = expectation(description: "Update gather expectation")
        mockPresenter.expectation = exp
        mockPresenter.numberOfUpdateCalls = numberOfUpdateCalls
        
        sut.presenter = mockPresenter
        
        // when
        sut.toggleTimer()
        waitForExpectations(timeout: 5) { _ in
            XCTAssertTrue(mockPresenter.timerDecrementedCalled)
            XCTAssertEqual(mockPresenter.actualUpdateCalls, numberOfUpdateCalls)
            XCTAssertEqual(sut.selectedTime.minutes, mockSelectedTime.minutes)
            XCTAssertNotEqual(sut.selectedTime.seconds, mockSelectedTime.seconds)
            sut.resetTimer()
            
            // then
            XCTAssertEqual(sut.selectedTime.minutes, mockSelectedTime.minutes)
            XCTAssertEqual(sut.selectedTime.seconds, mockSelectedTime.seconds)
            sut.stopTimer()
        }
    }
    
}

Presenter 를 mock 합니다:

// MARK: - Presenter
final class GatherMockPresenter: GatherPresenterServiceHandler {
    private(set) var gatherEndedCalled = false
    private(set) var serviceFailedCalled = false
    private(set) var timerDecrementedCalled = false
    
    weak var expectation: XCTestExpectation? = nil
    
    var numberOfUpdateCalls = 1
    private(set) var actualUpdateCalls = 0
    
    func gatherEnded() {
        gatherEndedCalled = true
        expectation?.fulfill()
    }
    
    func serviceFailedToEndGather() {
        serviceFailedCalled = true
        expectation?.fulfill()
    }
    
    func timerDecremented() {
        timerDecrementedCalled = true
        
        actualUpdateCalls += 1
        
        if expectation != nil && numberOfUpdateCalls == actualUpdateCalls {
            expectation?.fulfill()
        }
    }
    
}

주요지표 (Key Metrics)

코드 라인 수 (Lines of code) — Protocols

Untitled

코드 라인 수 (Lines of code) — View Controllers and Views

Untitled

코드 라인 수 (Lines of code) — Modules

Untitled

코드 라인 수 (Lines of code) — Routers

Untitled

코드 라인 수 (Lines of code) — Presenters

Untitled

코드 라인 수 (Lines of code) — Interactors

Untitled

코드 라인 수 (Lines of code) — Local Models

Untitled

유닛 테스트 (Unit Tests)

Untitled

빌드 시간 (Build Times)

Untitled

테스트는 8-Core Intel Core i9, MacBook Pro, 2019. Xcode 버전: 12.5.1. macOS Big Sur.

결론 (Conclusion)

VIPER 는 클린 코드에 대해 찾고 있다면 아주 좋은 아키텍처 입니다. 단일 책임 원칙 (Single Responsibility Principle) 을 수용하고 싶다면 더 나아가 더 많은 레이어를 생성할 수 있습니다.

유닛 테스트는 작성하기 쉽습니다.

반면에 프로젝트에서 많은 파일, 프로토콜, 그리고 클래스를 가집니다. 어떤 것이 변경되거나 UI 에서 업데이트가 필요한 경우 많은 것을 변경해야 하고 오랜 시간이 걸립니다.

특히 우리 앺에서 기존 MVP-C 아키텍처에서 VIPER 로의 변환은 다른 패턴에 비해 더 어렵습니다. 먼저 View 와 ViewController 레이어를 합친 다음에 거의 모든 클래스를 수정하고 새로운 클래스 / 파일을 생성합니다.

함수는 작고 대부분 한가지 수행 하는 것에 집중합니다.

프로토콜 파일은 모듈을 메인 .xcodeproj 에서 분리하고 static 프레임워크를 사용하는 경우에 유용합니다.

모든 ViewController 를 상당히 줄여 코드 라인 수를 거의 800 라인으로 만들었습니다. 1627 라인 이었던 MVC ViewController 대비 두배 이상 줄였습니다.

반면에 이제 새로운 레이어를 가집니다:

  • Protocols — 모듈의 추상화일 뿐이며 레이어의 정의만 포함되어 있습니다.
  • Modules — VIPER 레이어의 한 단위 (assemble) 입니다. Router 의 부분이며 일반적으로 factory 에 의해 초기화 됩니다.
  • Interactors — 데이터 변경을 조정하는 비지니스 로직과 네트워크 호출을 포함합니다.

새로운 레이어는 코드에 1903 라인을 추가합니다.

유닛 테스트 작성하는 것은 재밌습니다. 모든 것이 분리되어 있고 다른 조건들에 대해 확인할 수 있어서 좋습니다. 100% 코드 커버리지를 달성할 수 있습니다.

그러나 빌드 시간이 가장 느립니다. Derived Data 폴더와 빌드 폴더를 지울 때마다 10.43 초가 걸립니다. 이것은 MVVM 또는 MVC 앱 보다 거의 1초 더 걸립니다. 그러나 잠재적인 버그를 고치는데 걸리는 시간이 얼마나 걸릴지 누가 알고 있겠습니까?!

폴더를 지운 후에 유닛 테스트를 실행하면 거의 20초가 걸립니다. 총 46개의 테스트를 더 가졌습니다.

더 많은 파일, 클래스 그리고 의존성은 컴파일러의 빌드 시간을 더 늘립니다.

다행히 유닛 테스트를 실행할 때마다 클린 빌드와 Derived Data 폴더를 삭제하지 않아도 됩니다. CI 서버에 이 책임을 남길 수 있습니다.

개인적으로 자주 바뀌지 않는 중 대형 애플리케이션에 VIPER 를 사용하는 것을 좋아하고 기존 기능 위에 새로운 기능을 추가합니다.

그러나 VIPER 를 적용할 때 눈에 띄는 몇가지 단점이 존재합니다. 먼저 많은 코드를 작성해야 하고 ViewController 에서 바로 수행하는 대신에 왜 세 레이어를 통해야 하는지 생각하게 됩니다.

두번째로 작은 애플리케이션에는 적합하지 않습니다. 간단한 작업에 상용구 (boilerplate) 를 추가하여 중복된 파일을 생성할 필요가 없습니다.

마지막으로 앱 컴파일 시간과 앱 시작 시간이 늘어납니다.

끝까지 읽어주셔서 감사합니다!

유용한 링크 (Useful Links)