iOS Architecture Patterns 뿌시기: View Interactor Presenter (VIP)

26 분 소요

Thanks to Radu Dan for allowing the translation.

Reference: Battle of the iOS Architecture Patterns: View Interactor Presenter (VIP)

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

순서는 다음과 같습니다.

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

iOS Architecture Patterns 뿌시기: View Interactor Presenter (VIP)

아키텍처 시리즈 — View Interactor Presenter (VIP)

아키텍처 시리즈 — View Interactor Presenter (VIP)

동기 (Motivation)

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

“아키텍처 패턴” 시리즈의 마지막 글이고 Football Gather 애플리케이션을 어떻게 VIP 로 구현할 수 있는지 살펴 보겠습니다.

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

  • 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
  • View Interactor Presenter Entity Router (VIPER) — 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 에서 앱을 확인할 수 있습니다.

VIP 처럼 코드 정리하기 (Cleaning your code like a VIP)

VIP 는 일반적으로 사용되는 아키텍처 패턴이 아닙니다. Raymond Law 에 의해 개발되었으며 iOS 프로젝트에 적용되는 Uncle Bob 의 Clean Architecture 버전입니다. 더 많은 정보는 여기를 참고하세요: https://clean-swift.com/.

VIP 의 주 목표는 MVC 에서 가지고 있는 무거운 ViewController 문제를 해결하는 것입니다. VIP 는 다른 아키텍처 패턴의 문제에 대한 대안도 제공하려고 합니다. 예를 들어 VIPER 는 중심에 Presenter 를 가집니다. VIP 는 단방향 제어를 사용하여 플로우를 단순화 하고 레이어를 통해 메서드를 더 쉽게 호출할 수 있습니다.

VIP 는 앱 제어를 VIP 사이클로 변환하고 단방향 제어 플로우를 제공합니다.

VIP 적용 시나리오의 예제입니다:

  1. 사용자는 선수의 목록을 조회하기 위해 버튼을 탭합니다. 이것은 ViewController 에 있습니다.
  2. IBActionInteractor 에서 메서드를 호출합니다.
  3. Interactor 는 요청을 변환하고 서버에서 선수의 목록을 가져오는 것과 같은 약간의 비지니스 로직을 수행하고 사용자에게 나타낼 수 있는 응답으로 변환하기 위해 Presenter 를 호출합니다.
  4. Presenter 는 화면에 선수를 표시하기 위해 ViewController 를 호출합니다.

아키텍처 컴포넌트는 아래 자세히 나와있습니다.

View/ViewController

두가지 기능을 가집니다: Interactor 에 요청을 보내고 Presenter 로 부터 온 정보를 수행하고 표시합니다.

Interactor

“새로운” Presenter. 이 레이어는 데이터를 조회하고, 에러를 처리하고, 항목을 계산하기 위해 네트워크 호출과 같은 작업을 수행하는 VIP 아키텍처의 핵심입니다.

Worker

Football Gather 에서는 “Service” 라는 이름으로 사용되지만 기본적으로 같습니다. WorkerInteractor 의 일부 책임을 가지고 네트워크 호출 또는 데이터베이스 요청을 처리합니다.

Presenter

Interactor 로 부터 온 데이터를 처리하고 View 에 표시하기 위해 적절한 ViewModel 로 변환합니다.

Router

VIPER 에서와 동일한 역할을 가지며 화면 이동을 처리합니다.

Models

다른 패턴과 유사하게 Model 레이어는 데이터를 캡슐화 하기위해 사용됩니다.

통신 (Communication)

ViewControllerRouterInteractor 와 통신합니다.

InteractorPresenter 로 데이터를 보냅니다. Worker 로 부터 이벤트를 보내고 받을 수도 있습니다.

PresenterInteractor 로 부터 온 응답을 ViewModel 로 변환하고 View/ViewController 로 전달합니다.

장점 (Advantages)

  • MVC 에 있는 무거운 ViewController 문제를 더이상 가지지 않습니다.
  • MVVM 을 잘못 사용하면 대신 무거운 ViewModel 을 가질 수 있습니다.
  • VIP 사이클로 VIPER 의 제어 문제를 해결합니다.
  • VIPER 를 잘못 사용하면 무거운 Presenter 를 가질 수 있습니다.
  • 저자들은 이것이 Clean Architecture 원칙을 따른다고 말합니다.
  • 복잡한 비지니스 로직을 가지는 경우 Worker 컴포넌트로 이동할 수 있습니다.
  • 유닛 테스트와 TDD 사용이 매우 쉽습니다.
  • 모듈화 하기에 좋습니다.
  • 디버그 하기에 쉽습니다.

단점 (Disadvantages)

  • 레이어가 너무 많아 코드 생성기를 사용하지 않으면 지루해 질 수 있습니다.
  • 간단한 액션에도 많은 코드를 작성합니다.
  • 작은 앱에서는 적합하지 않습니다.
  • 일부 컴포넌트는 앱 사용 케이스에 따라 중복될 수 있습니다.
  • 앱 시작이 약간 증가합니다.

VIP vs VIPER

  • VIP 에서 Interactor 는 이제 ViewController 와 상호작용하는 레이어입니다.
  • ViewController 는 VIP 에서 Router 에 대한 참조를 유지합니다.
  • 잘못 사용하면 VIPER 는 무거운 Presenter 가 생길 수 있습니다.
  • VIP 는 제어의 단방향 플로우를 가집니다.
  • 서비스는 VIP 에서 Worker 라고 합니다.

코드에 적용하기 (Applying to our code)

VIPER 에서 VIP 로 앱을 변환하는 것은 생각하는 것만큼 쉽지 않을 수 있습니다. PresenterInteractor 로 변환하는 것으로 시작할 수 있습니다. 다음으로 Presenter 에서 Router 를 추출하고 ViewController 에 통합할 수 있습니다.

VIPER 에서 수행했던 모듈 조립 로직을 유지합니다.

Login scene

화면으로 이동합니다. Login 화면으로 시작합시다.

final class LoginViewController: UIViewController, LoginViewable {
    
    // MARK: - Properties
    @IBOutlet private weak var usernameTextField: UITextField!
    @IBOutlet private weak var passwordTextField: UITextField!
    @IBOutlet private weak var rememberMeSwitch: UISwitch!
    lazy var loadingView = LoadingView.initToView(view)
    
    var interactor: LoginInteractorProtocol = LoginInteractor()
    var router: LoginRouterProtocol = LoginRouter()
    
    // MARK: - View life cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        loadCredentials()
    }
    
    private func loadCredentials() {
        let request = Login.LoadCredentials.Request()
        interactor.loadCredentials(request: request)
    }
    
    // ...
}

보시다 시피 View 가 로드 되었음을 Presenter 에 더이상 말하지 않습니다. 이제 자격증명을 로드하기 위해 Interactor 로 요청합니다.

IBAction 는 아래와 같이 수정됩니다:

final class LoginViewController: UIViewController, LoginViewable {
    
    // ...
    @IBAction private func login(_ sender: Any) {
        showLoadingView()
        let request = Login.Authenticate.Request(username: usernameTextField.text,
                                                 password: passwordTextField.text,
                                                 storeCredentials: rememberMeSwitch.isOn)
        interactor.login(request: request)
    }
    
    @IBAction private func register(_ sender: Any) {
        showLoadingView()
        let request = Login.Authenticate.Request(username: usernameTextField.text,
                                                 password: passwordTextField.text,
                                                 storeCredentials: rememberMeSwitch.isOn)
        interactor.register(request: request)
    }
    
    // ...
}

로딩 View 를 시작하고 text field 의 사용자 이름, 암호와 사용자 이름 기억에 대한 UISwitch 의 상태를 포함하여 Interactor 에 요청을 구성합니다.

다음으로 viewDidLoad UI 업데이트 처리를 LoginViewConfigurable 프로토콜을 통해 처리합니다:

extension LoginViewController: LoginViewConfigurable {
    func displayStoredCredentials(viewModel: Login.LoadCredentials.ViewModel) {
        rememberMeSwitch.isOn = viewModel.rememberMeIsOn
        usernameTextField.text = viewModel.usernameText
    }
}

마지막으로 로직 서비스 호출이 완료되면 Presenter 에서 다음 메서드를 호출합니다:

func loginCompleted(viewModel: Login.Authenticate.ViewModel) {
    hideLoadingView()
    
    if viewModel.isSuccessful {
        router.showPlayerList()
    } else {
        handleError(title: viewModel.errorTitle!, message: viewModel.errorDescription!)
    }
}

Interactor 는 VIPER 아키텍처의 Interactor 와 유사해 보입니다. 이것은 같은 의존성을 가집니다:

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

여기서 핵심은 초기화 구문을 통해 Presenter 를 주입하고 더이상 weak 변수가 아닙니다.

자격증명 로딩은 아래에 나타나 있습니다. 먼저 ViewController 로 부터 요청을 받습니다. Presenter 에 대한 응답을 생성하고 presentCredentials(response: response) 함수를 호출합니다.

func loadCredentials(request: Login.LoadCredentials.Request) {
    let rememberUsername = userDefaults.rememberUsername ?? true
    let username = keychain.username
    let response = Login.LoadCredentials.Response(rememberUsername: rememberUsername, username: username)
    presenter.presentCredentials(response: response)
}

로그인과 등록 메서드는 네트워크 서비스 (Worker) 를 제외하고 동일합니다.

func login(request: Login.Authenticate.Request) {
    guard let username = request.username, let password = request.password else {
        let response = Login.Authenticate.Response(error: .missingCredentials)
        presenter.authenticationCompleted(response: response)
        return
    }
    
    let requestModel = UserRequestModel(username: username, password: password)
    loginService.login(user: requestModel) { [weak self] result in
        DispatchQueue.main.async {
            switch result {
            case .failure(let error):
                let response = Login.Authenticate.Response(error: .loginFailed(error.localizedDescription))
                self?.presenter.authenticationCompleted(response: response)
                
            case .success(_):
                guard let self = self else { return }
                
                self.updateCredentials(username: username, shouldStore: request.storeCredentials)
                
                let response = Login.Authenticate.Response(error: nil)
                self.presenter.authenticationCompleted(response: response)
            }
        }
    }
}

private func updateCredentials(username: String, shouldStore: Bool) {
    keychain.username = shouldStore ? username : nil
    userDefaults.rememberUsername = shouldStore
}

PresenterRouter 또는 Interactor 에 참조를 유지하지 않습니다. VIP 사이클을 완료하기 위해 weak 로 가지며 순환 참조 (retain cycle) 을 가지지 않는 View 의 의존성만 유지합니다.

Presenter 는 매우 간단하고 public API 의 두개 메서드를 노출합니다:

func presentCredentials(response: Login.LoadCredentials.Response) {
    let viewModel = Login.LoadCredentials.ViewModel(rememberMeIsOn: response.rememberUsername,
                                                    usernameText: response.username)
    view?.displayStoredCredentials(viewModel: viewModel)
}

func authenticationCompleted(response: Login.Authenticate.Response) {
    guard response.error == nil else {
        handleServiceError(response.error)
        return
    }
    
    let viewModel = Login.Authenticate.ViewModel(isSuccessful: true, errorTitle: nil, errorDescription: nil)
    view?.loginCompleted(viewModel: viewModel)
}

private func handleServiceError(_ error: LoginError?) {
    switch error {
    case .missingCredentials:
        let viewModel = Login.Authenticate.ViewModel(isSuccessful: false,
                                                     errorTitle: "Error",
                                                     errorDescription: "Both fields are mandatory.")
        view?.loginCompleted(viewModel: viewModel)
        
    case .loginFailed(let message), .registerFailed(let message):
        let viewModel = Login.Authenticate.ViewModel(isSuccessful: false,
                                                     errorTitle: "Error",
                                                     errorDescription: String(describing: message))
        view?.loginCompleted(viewModel: viewModel)
        
    default:
        break
    }
}

Router 레이어는 동일하게 유지됩니다.

Module 조립에 약간의 업데이트를 적용합니다:

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

PlayerList scene

다음으로 PlayerList 화면으로 이동합니다.

ViewController 는 유사한 방법으로 변환됩니다 - PresenterInteractor 에 의해 대체되고 이제 Router 에 대한 참조를 유지합니다.

VIP 에 흥미로운 측면은 ViewController 내부에 ViewModel 의 배열을 가질 수 있다는 것입니다:

var interactor: PlayerListInteractorProtocol = PlayerListInteractor()
var router: PlayerListRouterProtocol = PlayerListRouter()

private var displayedPlayers: [PlayerList.FetchPlayers.ViewModel.DisplayedPlayer] = []

PresenterView 가 로드되었음을 더이상 말하지 않습니다. ViewController 는 초기화 상태에서 UI 요소를 구성합니다.

override func viewDidLoad() {
    super.viewDidLoad()
    
    setupView()
    fetchPlayers()
}

private func setupView() {
    configureTitle("Players")
    setupBarButtonItem(title: "Select")
    setBarButtonState(isEnabled: false)
    setupTableView()
}

유사하게 Login 에서 IBAction 은 요청을 구성하고 Interactor 내에서 메서드를 호출합니다.

// MARK: - Selectors
@objc private func selectPlayers() {
    let request = PlayerList.SelectPlayers.Request()
    interactor.selectPlayers(request: request)
}

@IBAction private func confirmOrAddPlayers(_ sender: Any) {
    let request = PlayerList.ConfirmOrAddPlayers.Request()
    interactor.confirmOrAddPlayers(request: request)
}

데이터를 조회해 표시할 준비가 되면 PresenterdisplayFetchedPlayers ViewController 에서 메서드를 호출합니다.

func displayFetchedPlayers(viewModel: PlayerList.FetchPlayers.ViewModel) {
    displayedPlayers = viewModel.displayedPlayers
    
    showEmptyViewIfRequired()
    setBarButtonState(isEnabled: !playersCollectionIsEmpty)
    reloadData()
}

Table View 의 데이터 소스는 아래와 같이 볼 수 있습니다:

extension PlayerListViewController: UITableViewDelegate, UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        displayedPlayers.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell: PlayerTableViewCell = tableView.dequeueReusableCell(withIdentifier: "PlayerTableViewCell") as? PlayerTableViewCell else {
            return UITableViewCell()
        }
        
        let displayedPlayer = displayedPlayers[indexPath.row]
        
        cell.set(nameDescription: displayedPlayer.name)
        cell.set(positionDescription: "Position: \(displayedPlayer.positionDescription ?? "-")")
        cell.set(skillDescription: "Skill: \(displayedPlayer.skillDescription ?? "-")")
        cell.set(isSelected: displayedPlayer.isSelected)
        cell.set(isListView: isInListViewMode)
        
        return cell
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let request = PlayerList.SelectPlayer.Request(index: indexPath.row)
        interactor.selectRow(request: request)
    }
    
    func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
        let request = PlayerList.CanEdit.Request()
        return interactor.canEditRow(request: request)
    }
    
    func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
        guard editingStyle == .delete else {
            return
        }
        
        let request = PlayerList.DeletePlayer.Request(index: indexPath.row)
        interactor.requestToDeletePlayer(request: request)
    }
}

아시다시피 셀은 더이상 Presenter 를 필요하지 않습니다. ViewController 에 ViewModel 의 배열과 같은 필요한 모든 것을 가지고 있습니다.

Interactor 는 아래에 자세히 나와있습니다:

// MARK: - PlayerListInteractor
final class PlayerListInteractor: PlayerListInteractable {
    
    var presenter: PlayerListPresenterProtocol
    
    private let playersService: StandardNetworkService
    private var players: [PlayerResponseModel] = []
    private static let minimumPlayersToPlay = 2
    
    init(presenter: PlayerListPresenterProtocol = PlayerListPresenter(),
         playersService: StandardNetworkService = StandardNetworkService(resourcePath: "/api/players", authenticated: true)) {
        self.presenter = presenter
        self.playersService = playersService
    }
    
}

// MARK: - PlayerListInteractorServiceRequester
extension PlayerListInteractor: PlayerListInteractorServiceRequester {
    func fetchPlayers(request: PlayerList.FetchPlayers.Request) {
        playersService.get { [weak self] (result: Result<[PlayerResponseModel], Error>) in
            DispatchQueue.main.async {
                guard let self = self else { return }
                
                switch result {
                case .success(let players):
                    self.players = players
                    let response = PlayerList.FetchPlayers.Response(players: players,
                                                                    minimumPlayersToPlay: Self.minimumPlayersToPlay)
                    self.presenter.presentFetchedPlayers(response: response)
                    
                case .failure(let error):
                    let errorResponse = PlayerList.ErrorResponse(error: .serviceFailed(error.localizedDescription))
                    self.presenter.presentError(response: errorResponse)
                }
            }
        }
    }
    
    func deletePlayer(request: PlayerList.DeletePlayer.Request) {
        let index = request.index
        let player = players[index]
        var service = playersService
        
        service.delete(withID: ResourceID.integer(player.id)) { [weak self] result in
            DispatchQueue.main.async {
                guard let self = self else { return }
                
                switch result {
                case .success(_):
                    self.players.remove(at: index)
                    
                    let response = PlayerList.DeletePlayer.Response(index: index)
                    self.presenter.playerWasDeleted(response: response)
                    
                case .failure(let error):
                    let errorResponse = PlayerList.ErrorResponse(error: .serviceFailed(error.localizedDescription))
                    self.presenter.presentError(response: errorResponse)
                }
            }
        }
    }
}

// MARK: - PlayerListInteractorActionable
extension PlayerListInteractor: PlayerListInteractorActionable {
    func requestToDeletePlayer(request: PlayerList.DeletePlayer.Request) {
        let response = PlayerList.DeletePlayer.Response(index: request.index)
        presenter.presentDeleteConfirmationAlert(response: response)
    }
    
    func selectPlayers(request: PlayerList.SelectPlayers.Request) {
        presenter.presentViewForSelection()
    }
    
    func confirmOrAddPlayers(request: PlayerList.ConfirmOrAddPlayers.Request) {
        let response = PlayerList.ConfirmOrAddPlayers.Response(teamPlayersDictionary: [.bench: players],
                                                               addDelegate: self,
                                                               confirmDelegate: self)
        presenter.confirmOrAddPlayers(response: response)
    }
}

// MARK: - Table Delegate
extension PlayerListInteractor: PlayerListInteractorTableDelegate {
    func canEditRow(request: PlayerList.CanEdit.Request) -> Bool {
        let response = PlayerList.CanEdit.Response()
        return presenter.canEditRow(response: response)
    }
    
    func selectRow(request: PlayerList.SelectPlayer.Request) {
        guard !players.isEmpty else {
            return
        }
        
        let response = PlayerList.SelectPlayer.Response(index: request.index,
                                                        player: players[request.index],
                                                        detailDelegate: self)
        presenter.selectPlayer(response: response)
    }
}

Detail, Add, Confirm 화면 delegate 는 이제 Presenter 에서 Interactor 로 이동됩니다:

// MARK: - PlayerDetailDelegate
extension PlayerListInteractor: PlayerDetailDelegate {
    func didUpdate(player: PlayerResponseModel) {
        guard let index = players.firstIndex(of: player) else {
            return
        }
        
        players[index] = player
        
        let response = PlayerList.FetchPlayers.Response(players: players,
                                                        minimumPlayersToPlay: Self.minimumPlayersToPlay)
        presenter.updatePlayers(response: response)
    }
}

// MARK: - AddDelegate
extension PlayerListInteractor: PlayerAddDelegate {
    func didAddPlayer() {
        fetchPlayers(request: PlayerList.FetchPlayers.Request())
    }
}

// MARK: - ConfirmDelegate
extension PlayerListInteractor: ConfirmPlayersDelegate {
    func didEndGather() {
        let response = PlayerList.ReloadViewState.Response(viewState: .list)
        presenter.reloadViewState(response: response)
    }
}

마지막으로 Presenter 입니다:

final class PlayerListPresenter: PlayerListPresentable {
    
    // MARK: - Properties
    weak var view: PlayerListViewProtocol?
    
    private var viewState: PlayerListViewState
    private var viewStateDetails: PlayerListViewStateDetails {
        PlayerListViewStateDetailsFactory.makeViewStateDetails(from: viewState)
    }
    
    private var selectedRows: Set<Int> = []
    private var minimumPlayersToPlay: Int
    
    // MARK: - Public API
    init(view: PlayerListViewProtocol? = nil,
         viewState: PlayerListViewState = .list,
         minimumPlayersToPlay: Int = 2) {
        self.view = view
        self.viewState = viewState
        self.minimumPlayersToPlay = minimumPlayersToPlay
    }
    
}

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

Gather 비지니스 기능을 유닛 테스트 할 때 VIPER 에서 VIP 로 전환은 보기보다 어렵지 않습니다. 기본적으로 Interactor 는 새로운 Presenter 입니다.

함수가 호출될 때마다 true 로 설정하는 불린 플래그를 사용하여 동일한 Mock 접근방식을 따릅니다:

import XCTest
@testable import FootballGather

// MARK: - Presenter
final class GatherMockPresenter: GatherPresenterProtocol {
    var view: GatherViewProtocol?
    
    weak var expectation: XCTestExpectation? = nil
    
    var numberOfUpdateCalls = 1
    private(set) var actualUpdateCalls = 0
    
    private(set) var selectedMinutesComponent: Int?
    private(set) var selectedMinutes: Int?
    private(set) var selectedSecondsComponent: Int?
    private(set) var selectedSeconds: Int?
    
    private(set) var timeWasFormatted = false
    private(set) var timerViewWasPresented = false
    private(set) var timerWasCancelled = false
    private(set) var timerWasToggled = false
    private(set) var timerIsHidden = false
    private(set) var timeWasUpdated = false
    private(set) var alertWasPresented = false
    private(set) var poppedToPlayerListView = false
    private(set) var errorWasPresented = false
    
    private(set) var timerState: GatherTimeHandler.State?
    private(set) var score: [TeamSection: Double] = [:]
    private(set) var error: Error?
    private(set) var numberOfSections = 0
    private(set) var numberOfRows = 0
    
    func presentSelectedRows(response: Gather.SelectRows.Response) {
        if let minutes = response.minutes {
            selectedMinutes = minutes
        }
        
        if let minutesComponent = response.minutesComponent {
            selectedMinutesComponent = minutesComponent
        }
        
        if let seconds = response.seconds {
            selectedSeconds = seconds
        }
        
        if let secondsComponent = response.secondsComponent {
            selectedSecondsComponent = secondsComponent
        }
    }
    
    func formatTime(response: Gather.FormatTime.Response) {
        selectedMinutes = response.selectedTime.minutes
        selectedSeconds = response.selectedTime.seconds
        timeWasFormatted = true
        
        actualUpdateCalls += 1
        
        if let expectation = expectation,
           numberOfUpdateCalls == actualUpdateCalls {
            expectation.fulfill()
        }
    }
    
    func presentActionButton(response: Gather.ConfigureActionButton.Response) {
        timerState = response.timerState
    }
    
    func displayTeamScore(response: Gather.UpdateValue.Response) {
        score[response.teamSection] = response.newValue
    }
    
    func presentTimerView(response: Gather.SetTimer.Response) {
        timerViewWasPresented = true
    }
    
    func cancelTimer(response: Gather.CancelTimer.Response) {
        selectedMinutes = response.selectedTime.minutes
        selectedSeconds = response.selectedTime.seconds
        timerState = response.timerState
        
        timerWasCancelled = true
    }
    
    func presentToggledTimer(response: Gather.ActionTimer.Response) {
        timerState = response.timerState
        timerWasToggled = true
    }
    
    func hideTimer() {
        timerIsHidden = true
    }
    
    func presentUpdatedTime(response: Gather.TimerDidFinish.Response) {
        selectedMinutes = response.selectedTime.minutes
        selectedSeconds = response.selectedTime.seconds
        timerState = response.timerState
        
        timeWasUpdated = true
    }
    
    func presentEndGatherConfirmationAlert(response: Gather.EndGather.Response) {
        alertWasPresented = true
    }
    
    func popToPlayerListView() {
        poppedToPlayerListView = true
        expectation?.fulfill()
    }
    
    func presentError(response: Gather.ErrorResponse) {
        errorWasPresented = true
        error = response.error
        expectation?.fulfill()
    }
    
    func numberOfSections(response: Gather.SectionsCount.Response) -> Int {
        numberOfSections = response.teamSections.count
        return numberOfSections
    }
    
    func numberOfRowsInSection(response: Gather.RowsCount.Response) -> Int {
        numberOfRows = response.players.count
        return numberOfRows
    }
    
    func rowDetails(response: Gather.RowDetails.Response) -> Gather.RowDetails.ViewModel {
        Gather.RowDetails.ViewModel(titleLabelText: response.player.name,
                                    descriptionLabelText: response.player.preferredPosition?.acronym ?? "-")
    }
    
    func titleForHeaderInSection(response: Gather.SectionTitle.Response) -> Gather.SectionTitle.ViewModel {
        Gather.SectionTitle.ViewModel(title: response.teamSection.headerTitle)
    }
    
    func numberOfPickerComponents(response: Gather.PickerComponents.Response) -> Int {
        response.timeComponents.count
    }
    
    func numberOfPickerRows(response: Gather.PickerRows.Response) -> Int {
        response.timeComponent.numberOfSteps
    }
    
    func titleForRow(response: Gather.PickerRowTitle.Response) -> Gather.PickerRowTitle.ViewModel {
        let title = "\(response.row) \(response.timeComponent.short)"
        return Gather.PickerRowTitle.ViewModel(title: title)
    }
    
}

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

// MARK: - View
final class GatherMockView: GatherViewProtocol {
    var interactor: GatherInteractorProtocol!
    var router: GatherRouterProtocol = GatherRouter()
    var loadingView = LoadingView()
    
    private(set) var pickerComponent: Int?
    private(set) var pickerRow: Int?
    private(set) var animated: Bool?
    private(set) var formattedTime: String?
    private(set) var actionButtonTitle: String?
    private(set) var timerViewIsVisible: Bool?
    private(set) var teamAText: String?
    private(set) var teamBText: String?
    
    private(set) var selectedRowWasDisplayed = false
    private(set) var timeWasFormatted = false
    private(set) var confirmationAlertDisplayed = false
    private(set) var updatedTimerIsDisplayed = false
    private(set) var cancelTimerIsDisplayed = false
    private(set) var loadingViewIsVisible = false
    private(set) var poppedToPlayerListView = false
    private(set) var errorWasHandled = true
    
    func displaySelectedRow(viewModel: Gather.SelectRows.ViewModel) {
        pickerComponent = viewModel.pickerComponent
        pickerRow = viewModel.pickerRow
        animated = viewModel.animated
        
        selectedRowWasDisplayed = true
    }
    
    func displayTime(viewModel: Gather.FormatTime.ViewModel) {
        formattedTime = viewModel.formattedTime
        timeWasFormatted = true
    }
    
    func displayActionButtonTitle(viewModel: Gather.ConfigureActionButton.ViewModel) {
        actionButtonTitle = viewModel.title
    }
    
    func displayEndGatherConfirmationAlert() {
        confirmationAlertDisplayed = true
    }
    
    func configureTimerViewVisibility(viewModel: Gather.SetTimer.ViewModel) {
        timerViewIsVisible = viewModel.timerViewIsVisible
    }
    
    func displayUpdatedTimer(viewModel: Gather.TimerDidFinish.ViewModel) {
        actionButtonTitle = viewModel.actionTitle
        formattedTime = viewModel.formattedTime
        timerViewIsVisible = viewModel.timerViewIsVisible
        
        updatedTimerIsDisplayed = true
    }
    
    func showLoadingView() {
        loadingViewIsVisible = true
    }
    
    func hideLoadingView() {
        loadingViewIsVisible = false
    }
    
    func popToPlayerListView() {
        poppedToPlayerListView = true
    }
    
    func handleError(title: String, message: String) {
        errorWasHandled = true
    }
    
    func displayTeamScore(viewModel: Gather.UpdateValue.ViewModel) {
        if let teamAText = viewModel.teamAText {
            self.teamAText = teamAText
        }
        
        if let teamBText = viewModel.teamBText {
            self.teamBText = teamBText
        }
    }
    
    func displayCancelTimer(viewModel: Gather.CancelTimer.ViewModel) {
        actionButtonTitle = viewModel.actionTitle
        formattedTime = viewModel.formattedTime
        timerViewIsVisible = viewModel.timerViewIsVisible
        
        cancelTimerIsDisplayed = true
    }
    
}

다음은 Interactor 의 유닛 테스트 일부분입니다:

import XCTest
@testable import FootballGather

final class GatherInteractorTests: XCTestCase {
    
    // MARK: - Configure
    func testSelectRows_whenRequestIsGiven_presentsSelectedTime() {
        // given
        let mockSelectedTime = GatherTime(minutes: 25, seconds: 54)
        let mockTimeHandler = GatherTimeHandler(selectedTime: mockSelectedTime)
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()),
                                   timeHandler: mockTimeHandler)
        
        // when
        sut.selectRows(request: Gather.SelectRows.Request())
        
        // then
        XCTAssertEqual(mockPresenter.selectedMinutes, mockSelectedTime.minutes)
        XCTAssertEqual(mockPresenter.selectedMinutesComponent, sut.minutesComponent?.rawValue)
        XCTAssertEqual(mockPresenter.selectedSeconds, mockSelectedTime.seconds)
        XCTAssertEqual(mockPresenter.selectedSecondsComponent, sut.secondsComponent?.rawValue)
    }
    
    func testSelectRows_whenComponentsAreNil_selectedTimeIsNil() {
        // given
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()),
                                   timeComponents: [])
        
        // when
        sut.selectRows(request: Gather.SelectRows.Request())
        
        // then
        XCTAssertNil(mockPresenter.selectedMinutes)
        XCTAssertNil(mockPresenter.selectedMinutesComponent)
        XCTAssertNil(mockPresenter.selectedSeconds)
        XCTAssertNil(mockPresenter.selectedSecondsComponent)
    }
    
    func testFormatTime_whenRequestIsGiven_formatsTime() {
        // given
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()))
        
        // when
        sut.formatTime(request: Gather.FormatTime.Request())
        
        // then
        XCTAssertNotNil(mockPresenter.selectedMinutes)
        XCTAssertNotNil(mockPresenter.selectedSeconds)
        XCTAssertTrue(mockPresenter.timeWasFormatted)
    }
    
    func testConfigureActionButton_whenRequestIsGiven_() {
        // given
        let mockState = GatherTimeHandler.State.running
        let mockTimeHandler = GatherTimeHandler(state: mockState)
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()),
                                   timeHandler: mockTimeHandler)
        
        // when
        sut.configureActionButton(request: Gather.ConfigureActionButton.Request())
        
        // then
        XCTAssertEqual(mockPresenter.timerState, mockState)
    }
    
    func testUpdateValue_whenRequestIsGiven_displaysTeamScore() {
        // given
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()))
        
        // when
        sut.updateValue(request: Gather.UpdateValue.Request(teamSection: .teamA, newValue: 15))
        sut.updateValue(request: Gather.UpdateValue.Request(teamSection: .teamB, newValue: 16))
        
        // then
        XCTAssertEqual(mockPresenter.score[.teamA], 15)
        XCTAssertEqual(mockPresenter.score[.teamB], 16)
    }
    
    // MARK: - Time Handler
    func testSetTimer_whenRequestIsGiven_selectsTime() {
        // given
        let mockSelectedTime = GatherTime(minutes: 5, seconds: 0)
        let mockTimeHandler = GatherTimeHandler(selectedTime: mockSelectedTime)
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()),
                                   timeHandler: mockTimeHandler)
        
        // when
        sut.setTimer(request: Gather.SetTimer.Request())
        
        // then
        XCTAssertEqual(mockPresenter.selectedMinutes, mockSelectedTime.minutes)
        XCTAssertEqual(mockPresenter.selectedMinutesComponent, sut.minutesComponent?.rawValue)
        XCTAssertEqual(mockPresenter.selectedSeconds, mockSelectedTime.seconds)
        XCTAssertEqual(mockPresenter.selectedSecondsComponent, sut.secondsComponent?.rawValue)
    }
    
    func testSetTimer_whenRequestIsGiven_presentsTimerView() {
        // given
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()))
        
        // when
        sut.setTimer(request: Gather.SetTimer.Request())
        
        // then
        XCTAssertTrue(mockPresenter.timerViewWasPresented)
    }
    
    func testCancelTimer_whenRequestIsGiven_cancelsTimer() {
        // given
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()))
        
        // when
        sut.cancelTimer(request: Gather.CancelTimer.Request())
        
        // then
        XCTAssertNotNil(mockPresenter.selectedMinutes)
        XCTAssertNotNil(mockPresenter.selectedSeconds)
        XCTAssertNotNil(mockPresenter.timerState)
        XCTAssertTrue(mockPresenter.timerWasCancelled)
    }
    
    func testActionTimer_whenRequestIsGiven_presentsToggledTime() {
        // given
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()))
        
        // when
        sut.actionTimer(request: Gather.ActionTimer.Request())
        
        // then
        XCTAssertNotNil(mockPresenter.timerState)
        XCTAssertTrue(mockPresenter.timerWasToggled)
    }
    
    func testActionTimer_whenTimeIsInvalid_presentsToggledTime() {
        // given
        let mockSelectedTime = GatherTime(minutes: -1, seconds: -1)
        let mockTimeHandler = GatherTimeHandler(selectedTime: mockSelectedTime)
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()),
                                   timeHandler: mockTimeHandler)
        
        // when
        sut.actionTimer(request: Gather.ActionTimer.Request())
        
        // then
        XCTAssertNotNil(mockPresenter.timerState)
        XCTAssertTrue(mockPresenter.timerWasToggled)
    }
    
    func testActionTimer_whenTimeIsValid_updatesTimer() {
        // given
        let numberOfUpdateCalls = 2
        let mockSelectedTime = GatherTime(minutes: 0, seconds: numberOfUpdateCalls)
        let mockTimeHandler = GatherTimeHandler(selectedTime: mockSelectedTime)
        
        let exp = expectation(description: "Update timer expectation")
        let mockPresenter = GatherMockPresenter()
        mockPresenter.expectation = exp
        mockPresenter.numberOfUpdateCalls = numberOfUpdateCalls
        
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()),
                                   timeHandler: mockTimeHandler)
        
        // when
        sut.actionTimer(request: Gather.ActionTimer.Request())
        
        // then
        waitForExpectations(timeout: 5) { _ in
            XCTAssertEqual(mockPresenter.actualUpdateCalls, numberOfUpdateCalls)
            sut.cancelTimer(request: Gather.CancelTimer.Request())
        }
    }
    
    func testTimerDidCancel_whenRequestIsGiven_hidesTimer() {
        // given
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()))
        
        // when
        sut.timerDidCancel(request: Gather.TimerDidCancel.Request())
        
        // then
        XCTAssertTrue(mockPresenter.timerIsHidden)
    }
    
    func testTimerDidFinish_whenRequestIsGiven_updatesTime() {
        // given
        let mockSelectedTime = GatherTime(minutes: 1, seconds: 13)
        let mockTimeHandler = GatherTimeHandler(selectedTime: mockSelectedTime)
        let mockRequest = Gather.TimerDidFinish.Request(selectedMinutes: 0, selectedSeconds: 25)
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()),
                                   timeHandler: mockTimeHandler)
        
        // when
        sut.timerDidFinish(request: mockRequest)
        
        // then
        XCTAssertEqual(mockPresenter.selectedMinutes, mockRequest.selectedMinutes)
        XCTAssertEqual(mockPresenter.selectedSeconds, mockRequest.selectedSeconds)
        XCTAssertNotNil(mockPresenter.timerState)
        XCTAssertTrue(mockPresenter.timeWasUpdated)
    }
    
    // MARK: - GatherInteractorActionable
    func testRequestToEndGather_whenRequestIsGiven_presentsAlert() {
        // given
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()))
        
        // when
        sut.requestToEndGather(request: Gather.EndGather.Request())
        
        // then
        XCTAssertTrue(mockPresenter.alertWasPresented)
    }
    
    func testEndGather_whenScoreDescriptionIsNil_returns() {
        // given
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()))
        
        // when
        sut.endGather(request: Gather.EndGather.Request(winnerTeamDescription: "win"))
        
        // then
        XCTAssertFalse(mockPresenter.poppedToPlayerListView)
        XCTAssertFalse(mockPresenter.errorWasPresented)
    }
    
    func testEndGather_whenWinnerTeamDescriptionIsNil_returns() {
        // given
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()))
        
        // when
        sut.endGather(request: Gather.EndGather.Request(scoreDescription: "score"))
        
        // then
        XCTAssertFalse(mockPresenter.poppedToPlayerListView)
        XCTAssertFalse(mockPresenter.errorWasPresented)
    }
    
    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 mockDelegate = GatherMockDelegate()
        
        let sut = GatherInteractor(presenter: mockPresenter,
                                   delegate: mockDelegate,
                                   gather: mockGatherModel,
                                   updateGatherService: mockService)
        
        // when
        sut.endGather(request: Gather.EndGather.Request(winnerTeamDescription: "None", scoreDescription: "1-1"))
        
        // then
        waitForExpectations(timeout: 5) { _ in
            XCTAssertTrue(mockPresenter.poppedToPlayerListView)
            XCTAssertTrue(mockDelegate.gatherWasEnded)
            
            appKeychain.storage.removeAll()
        }
    }
    
    func testEndGather_whenScoreIsNotSet_errorIsPresented() {
        // 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 mockDelegate = GatherMockDelegate()
        
        let sut = GatherInteractor(presenter: mockPresenter,
                                   delegate: mockDelegate,
                                   gather: mockGatherModel,
                                   updateGatherService: mockService)
        
        // when
        sut.endGather(request: Gather.EndGather.Request(winnerTeamDescription: "", scoreDescription: ""))
        
        // then
        waitForExpectations(timeout: 5) { _ in
            XCTAssertTrue(mockPresenter.errorWasPresented)
            XCTAssertTrue(mockPresenter.error is EndGatherError)
            appKeychain.storage.removeAll()
        }
    }
    
    // MARK: - Table Delegate
    func testNumberOfSections_whenRequestIsGiven_returnsNumberOfTeamSections() {
        // given
        let mockTeamSections: [TeamSection] = [.teamA]
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()),
                                   teamSections: mockTeamSections)
        
        // when
        let numberOfSections = sut.numberOfSections(request: Gather.SectionsCount.Request())
        
        // then
        XCTAssertEqual(mockPresenter.numberOfSections, mockTeamSections.count)
        XCTAssertEqual(mockPresenter.numberOfSections, numberOfSections)
    }
    
    func testNumberOfRowsInSection_whenSectionIsZero_equalsNumberOfPlayersInTeamSection() {
        // given
        let mockGather = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
        let expectedNumberOfPlayers = mockGather.players.filter { $0.team == .teamA }.count
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter, gather: mockGather)
        
        // when
        let numberOfRowsInSection = sut.numberOfRowsInSection(request: Gather.RowsCount.Request(section: 0))
        
        // then
        XCTAssertEqual(mockPresenter.numberOfRows, expectedNumberOfPlayers)
        XCTAssertEqual(numberOfRowsInSection, expectedNumberOfPlayers)
    }
    
    func testNumberOfRowsInSection_whenSectionIsOne_equalsNumberOfPlayersInTeamSection() {
        // given
        let mockGather = ModelsMockFactory.makeGatherModel(numberOfPlayers: 5)
        let expectedNumberOfPlayers = mockGather.players.filter { $0.team == .teamB }.count
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter, gather: mockGather)
        
        // when
        let numberOfRowsInSection = sut.numberOfRowsInSection(request: Gather.RowsCount.Request(section: 1))
        
        // then
        XCTAssertEqual(mockPresenter.numberOfRows, expectedNumberOfPlayers)
        XCTAssertEqual(numberOfRowsInSection, expectedNumberOfPlayers)
    }
    
    func testRowDetails_whenInteractorHasPlayers_equalsPlayerNameAndPreferredPositionAcronym() {
        // given
        let mockGather = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
        
        let firstTeamAPlayer = mockGather.players.filter { $0.team == .teamA }.first?.player
        let expectedRowTitle = firstTeamAPlayer?.name
        let expectedRowDescription = firstTeamAPlayer?.preferredPosition?.acronym
        
        let mockIndexPath = IndexPath(row: 0, section: 0)
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter, gather: mockGather)
        
        // when
        let rowDetails = sut.rowDetails(request: Gather.RowDetails.Request(indexPath: mockIndexPath))
        
        // then
        XCTAssertEqual(rowDetails.titleLabelText, expectedRowTitle)
        XCTAssertEqual(rowDetails.descriptionLabelText, expectedRowDescription)
    }
    
    func testTitleForHeaderInSection_whenSectionIsTeamA_equalsTeamSectionHeaderTitle() {
        // given
        let expectedTitle = TeamSection.teamA.headerTitle
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()))
        
        // when
        let titleForHeader = sut.titleForHeaderInSection(request: Gather.SectionTitle.Request(section: 0)).title
        
        // then
        XCTAssertEqual(titleForHeader, expectedTitle)
    }
    
    func testTitleForHeaderInSection_whenSectionIsTeamB_equalsTeamSectionHeaderTitle() {
        // given
        let expectedTitle = TeamSection.teamB.headerTitle
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()))
        
        // when
        let titleForHeader = sut.titleForHeaderInSection(request: Gather.SectionTitle.Request(section: 1)).title
        
        // then
        XCTAssertEqual(titleForHeader, expectedTitle)
    }
    
    // MARK: - Picker Delegate
    func testNumberOfPickerComponents_whenTimeComponentsAreGiven_equalsInteractorTimeComponents() {
        // given
        let mockTimeComponents: [GatherTimeHandler.Component] = [.minutes]
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()),
                                   timeComponents: mockTimeComponents)
        
        // when
        let numberOfPickerComponents = sut.numberOfPickerComponents(request: Gather.PickerComponents.Request())
        
        // then
        XCTAssertEqual(numberOfPickerComponents, mockTimeComponents.count)
    }
    
    func testNumberOfRowsInPickerComponent_whenComponentIsMinutes_equalsNumberOfSteps() {
        // given
        let mockTimeComponents: [GatherTimeHandler.Component] = [.minutes]
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()),
                                   timeComponents: mockTimeComponents)
        
        // when
        let numberOfRowsInPickerComponent = sut.numberOfRowsInPickerComponent(request: Gather.PickerRows.Request(component: 0))
        
        // then
        XCTAssertEqual(numberOfRowsInPickerComponent, GatherTimeHandler.Component.minutes.numberOfSteps)
    }
    
    func testNumberOfRowsInPickerComponent_whenComponentIsSeconds_equalsNumberOfSteps() {
        // given
        let mockTimeComponents: [GatherTimeHandler.Component] = [.seconds]
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()),
                                   timeComponents: mockTimeComponents)
        
        // when
        let numberOfRowsInPickerComponent = sut.numberOfRowsInPickerComponent(request: Gather.PickerRows.Request(component: 0))
        
        // then
        XCTAssertEqual(numberOfRowsInPickerComponent, GatherTimeHandler.Component.seconds.numberOfSteps)
    }
    
    func testTitleForPickerRow_whenComponentsAreNotEmpty_containsTimeComponentShort() {
        // given
        let mockTimeComponents: [GatherTimeHandler.Component] = [.seconds]
        let mockPresenter = GatherMockPresenter()
        let sut = GatherInteractor(presenter: mockPresenter,
                                   gather: GatherModel(players: [], gatherUUID: UUID()),
                                   timeComponents: mockTimeComponents)
        
        // when
        let titleForPickerRow = sut.titleForPickerRow(request: Gather.PickerRowTitle.Request(row: 0, component: 0)).title
        
        // then
        XCTAssertTrue(titleForPickerRow.contains(GatherTimeHandler.Component.seconds.short))
    }
    
}

그리고 Presenter 유닛 테스트 입니다:

import XCTest
@testable import FootballGather

final class GatherPresenterTests: XCTestCase {
    
    // MARK: - View Configuration
    func testPresentSelectedRows_whenResponseHasMinutes_displaysSelectedRow() {
        // given
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        sut.presentSelectedRows(response: Gather.SelectRows.Response(minutes: 1, minutesComponent: 1))
        
        // then
        XCTAssertTrue(mockView.selectedRowWasDisplayed)
        XCTAssertEqual(mockView.pickerRow, 1)
        XCTAssertEqual(mockView.pickerComponent, 1)
    }
    
    func testPresentSelectedRows_whenResponseHasSeconds_displaysSelectedRow() {
        // given
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        sut.presentSelectedRows(response: Gather.SelectRows.Response(seconds: 15, secondsComponent: 45))
        
        // then
        XCTAssertTrue(mockView.selectedRowWasDisplayed)
        XCTAssertEqual(mockView.pickerRow, 15)
        XCTAssertEqual(mockView.pickerComponent, 45)
    }
    
    func testFormatTime_whenResponseIsGiven_formatsTime() {
        // given
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        sut.formatTime(response: Gather.FormatTime.Response(selectedTime: GatherTime(minutes: 1, seconds: 21)))
        
        // then
        XCTAssertTrue(mockView.timeWasFormatted)
        XCTAssertEqual(mockView.formattedTime, "01:21")
    }
    
    func testPresentActionButton_whenStateIsPaused_displaysResumeActionButtonTitle() {
        // given
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        sut.presentActionButton(response: Gather.ConfigureActionButton.Response(timerState: .paused))
        
        // then
        XCTAssertEqual(mockView.actionButtonTitle, "Resume")
    }
    
    func testPresentActionButton_whenStateIsRunning_displaysPauseActionButtonTitle() {
        // given
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        sut.presentActionButton(response: Gather.ConfigureActionButton.Response(timerState: .running))
        
        // then
        XCTAssertEqual(mockView.actionButtonTitle, "Pause")
    }
    
    func testPresentActionButton_whenStateIsStopped_displaysStartActionButtonTitle() {
        // given
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        sut.presentActionButton(response: Gather.ConfigureActionButton.Response(timerState: .stopped))
        
        // then
        XCTAssertEqual(mockView.actionButtonTitle, "Start")
    }
    
    func testPresentEndGatherConfirmationAlert_whenResponseIsGiven_alertIsDisplayed() {
        // given
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        sut.presentEndGatherConfirmationAlert(response: Gather.EndGather.Response())
        
        // then
        XCTAssertTrue(mockView.confirmationAlertDisplayed)
    }
    
    func testPresentTimerView_whenResponseIsGive_timerViewIsVisible() {
        // given
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        sut.presentTimerView(response: Gather.SetTimer.Response())
        
        // then
        XCTAssertTrue(mockView.timerViewIsVisible!)
    }
    
    func testDisplayCancelTimer_whenSelectedTimeIsGiven_displaysCancelledTimer() {
        // given
        let mockGatherTime = GatherTime(minutes: 21, seconds: 32)
        let mockResponse = Gather.CancelTimer.Response(selectedTime: mockGatherTime,
                                                       timerState: .paused)
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        sut.cancelTimer(response: mockResponse)
        
        // then
        XCTAssertEqual(mockView.actionButtonTitle, "Resume")
        XCTAssertEqual(mockView.formattedTime, "21:32")
        XCTAssertFalse(mockView.timerViewIsVisible!)
        XCTAssertTrue(mockView.cancelTimerIsDisplayed)
    }
    
    func testPresentToggleTimer_whenResponseIsGiven_displaysActionButtonTitle() {
        // given
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        sut.presentToggledTimer(response: Gather.ActionTimer.Response(timerState: .running))
        
        // then
        XCTAssertEqual(mockView.actionButtonTitle, "Pause")
    }
    
    func testHideTimer_whenPresenterIsAllocated_timerViewIsNotVisible() {
        // given
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        sut.hideTimer()
        
        // then
        XCTAssertFalse(mockView.timerViewIsVisible!)
    }
    
    func testPresentUpdatedTime_whenSelectedTimeIsGiven_displaysUpdatedTimer() {
        // given
        let mockGatherTime = GatherTime(minutes: 1, seconds: 5)
        let mockResponse = Gather.TimerDidFinish.Response(selectedTime: mockGatherTime,
                                                          timerState: .stopped)
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        sut.presentUpdatedTime(response: mockResponse)
        
        // then
        XCTAssertEqual(mockView.actionButtonTitle, "Start")
        XCTAssertEqual(mockView.formattedTime, "01:05")
        XCTAssertFalse(mockView.timerViewIsVisible!)
        XCTAssertTrue(mockView.updatedTimerIsDisplayed)
    }
    
    func testPopToPlayerListView_whenPresenterIsAllocated_hidesLoadingViewAndPopsToPlayerListView() {
        // given
        let mockView = GatherMockView()
        mockView.showLoadingView()
        
        let sut = GatherPresenter(view: mockView)
        
        // when
        sut.popToPlayerListView()
        
        // then
        XCTAssertFalse(mockView.loadingViewIsVisible)
        XCTAssertTrue(mockView.poppedToPlayerListView)
    }
    
    func testPresentError_whenResponseIsGiven_displaysError() {
        // given
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        sut.presentError(response: Gather.ErrorResponse(error: .endGatherError))
        
        // then
        XCTAssertTrue(mockView.errorWasHandled)
    }
    
    func testDisplayTeamScore_when_displaysScore() {
        // given
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        sut.displayTeamScore(response: Gather.UpdateValue.Response(teamSection: .teamA, newValue: 1))
        sut.displayTeamScore(response: Gather.UpdateValue.Response(teamSection: .teamB, newValue: 15))
        
        // then
        XCTAssertEqual(mockView.teamAText, "1")
        XCTAssertEqual(mockView.teamBText, "15")
    }
    
    // MARK: - Table Delegate
    func testNumberOfSections_whenResponseIsGiven_returnsTeamSectionsCount() {
        // given
        let mockTeamSections: [TeamSection] = [.bench, .teamB, .teamA]
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        let numberOfSections = sut.numberOfSections(response: Gather.SectionsCount.Response(teamSections: mockTeamSections))
        
        // then
        XCTAssertEqual(numberOfSections, mockTeamSections.count)
    }
    
    func testNumberOfRowsInSection_whenResponseIsGiven_returnsPlayersCount() {
        // given
        let mockPlayerResponseModel = PlayerResponseModel(id: -1, name: "mock-name")
        let mockResponse = Gather.RowsCount.Response(players: [mockPlayerResponseModel])
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        let numberOfRows = sut.numberOfRowsInSection(response: mockResponse)
        
        // then
        XCTAssertEqual(numberOfRows, 1)
    }
    
    func testRowDetails_whenResponseIsGiven_returnsPlayerNameAndPreferredPositionAcronym() {
        // given
        let mockPlayerResponseModel = PlayerResponseModel(id: -1,
                                                          name: "mock-name",
                                                          preferredPosition: .goalkeeper)
        let mockResponse = Gather.RowDetails.Response(player: mockPlayerResponseModel)
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        let rowDetails = sut.rowDetails(response: mockResponse)
        
        // then
        XCTAssertEqual(rowDetails.titleLabelText, mockPlayerResponseModel.name)
        XCTAssertEqual(rowDetails.descriptionLabelText, mockPlayerResponseModel.preferredPosition!.acronym)
    }
    
    func testRowDetails_whenPositionIsNil_descriptionLabelIsDash() {
        // given
        let mockPlayerResponseModel = PlayerResponseModel(id: -1,
                                                          name: "mock-name")
        let mockResponse = Gather.RowDetails.Response(player: mockPlayerResponseModel)
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        let rowDetails = sut.rowDetails(response: mockResponse)
        
        // then
        XCTAssertEqual(rowDetails.descriptionLabelText, "-")
    }
    
    func testTitleForHeaderInSection_whenTeamSectionIsA_returnsTeamAHeaderTitle() {
        // given
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        let title = sut.titleForHeaderInSection(response: Gather.SectionTitle.Response(teamSection: .teamA)).title
        
        // then
        XCTAssertEqual(title, TeamSection.teamA.headerTitle)
    }
    
    func testTitleForHeaderInSection_whenTeamSectionIsB_returnsTeamBHeaderTitle() {
        // given
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        let title = sut.titleForHeaderInSection(response: Gather.SectionTitle.Response(teamSection: .teamB)).title
        
        // then
        XCTAssertEqual(title, TeamSection.teamB.headerTitle)
    }
    
    // MARK: - Picker Delegate
    func testNumberOfPickerComponents_whenResponseIsGiven_returnsTimeComponentsCount() {
        // given
        let mockTimeComponents: [GatherTimeHandler.Component] = [.minutes, .seconds]
        let mockResponse = Gather.PickerComponents.Response(timeComponents: mockTimeComponents)
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        let numberOfPickerComponents = sut.numberOfPickerComponents(response: mockResponse)
        
        // then
        XCTAssertEqual(numberOfPickerComponents, mockTimeComponents.count)
    }
    
    func testNumberOfPickerRows_whenComponentIsMinutes_returnsNumberOfSteps() {
        // given
        let mockResponse = Gather.PickerRows.Response(timeComponent: .minutes)
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        let numberOfPickerRows = sut.numberOfPickerRows(response: mockResponse)
        
        // then
        XCTAssertEqual(numberOfPickerRows, GatherTimeHandler.Component.minutes.numberOfSteps)
    }
    
    func testNumberOfPickerRows_whenComponentIsSeconds_returnsNumberOfSteps() {
        // given
        let mockResponse = Gather.PickerRows.Response(timeComponent: .seconds)
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        let numberOfPickerRows = sut.numberOfPickerRows(response: mockResponse)
        
        // then
        XCTAssertEqual(numberOfPickerRows, GatherTimeHandler.Component.seconds.numberOfSteps)
    }
    
    func testTitleForRow_whenTimeComponentIsMinutes_containsRowAndTimeComponentShort() {
        // given
        let mockResponse = Gather.PickerRowTitle.Response(timeComponent: .minutes, row: 5)
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        let titleForRow = sut.titleForRow(response: mockResponse).title
        
        // then
        XCTAssertTrue(titleForRow.contains("\(mockResponse.row)"))
        XCTAssertTrue(titleForRow.contains("\(mockResponse.timeComponent.short)"))
    }
    
    func testTitleForRow_whenTimeComponentIsSeconds_containsRowAndTimeComponentShort() {
        // given
        let mockResponse = Gather.PickerRowTitle.Response(timeComponent: .seconds, row: 11)
        let mockView = GatherMockView()
        let sut = GatherPresenter(view: mockView)
        
        // when
        let titleForRow = sut.titleForRow(response: mockResponse).title
        
        // then
        XCTAssertTrue(titleForRow.contains("\(mockResponse.row)"))
        XCTAssertTrue(titleForRow.contains("\(mockResponse.timeComponent.short)"))
    }
    
}

주요지표 (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 로 된 애플리케이션을 VIP 로 적용하였고 가장 먼저 눈에 띄는 것은 Presenter 가 더 간소화 되고 깔끔해 졌습니다. MVC 앱에서 부터 왔다면 ViewController 는 상당히 줄어들었을 것입니다.

VIP 는 단방향 제어를 사용하여 플로우를 간소화 하고 레이어를 통해 메서드 호출을 더 쉽게 해줍니다.

평균 빌드 시간은 대략 10초로 VIPER 와 MVP 와 유사합니다. 더 많은 유닛 테스트를 가질 수록 테스트 실행 시간은 더 추가됩니다. 하지만 VIPER 보다는 좀 더 빠릅니다.

Presenter 는 VIPER 와 비교해 514의 코드 라인 수를 줄였습니다. 그러나 주요 단점은 Interactor508 라인 더 증가하였습니다. 기본적으로 Presenter 에서 Interactor 로 이동하였습니다.

개인적으로 VIPER 를 선호합니다. VIP 아키텍처에는 내가 좋아하지 않는 것들이 있고 내 관점에서 Uncle Bob 의 원칙을 따르지 않습니다.

예를 들어 아무것도 없는 상태에서 Request 객체를 생성해야 되는 이유는 무엇일까요? 우리는 그렇게 할 수 없었지만 예제의 repository 를 열어보면 많은 빈 요청 객체를 볼 수 있습니다.

보일러플레이트 코드가 많이 있습니다.

ViewController 내부에 ViewModel 의 배열을 유지하는 것은 복잡성을 만들고 Worker 모델과 쉽게 동기화 되지 않을 수 있습니다.

물론 이러한 문제를 완화할 수 있는 고유한 VIP 변형을 사용할 수 있습니다.

긍정적인 면은 VIP 사이클의 개념과 TDD 를 사용하는데 얼마나 쉬운지에 대해선 좋습니다. 그러나 레이어의 엄격한 규칙을 따르면 각 작은 변경은 구현하기 어려울 수 있습니다. 이것은 소프트!웨어 여야 합니다?!

유용한 링크 (Useful Links)