Radu Dan
VIP Banner

Architecture Series - View Interactor Presenter (VIP)

Motivation

Before starting to develop an iOS app, we have to think about the structure of the project. We need to consider how we add those pieces of code together so they make sense later on - when we come back and revisit a part of the app - and how to form a known “language” with the other developers.

This is the final article of the "Architecture Patterns" series and we will going to see how we can implement VIP to the Football Gather application.

If you missed the other articles you can access them below or you can find the links at the end of this article.

  • 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

Are you impatient and just want to see the code? No worries! You have it available on GitHub.

Following the same approach as we did for the other posts, we will first say a few things about this pattern and why it's useful. Then we will see the actual implementation.
Finally, we will show some figures about compilation and build times, check how easy was to write unit tests and state our conclusions.

Why an architecture pattern for your iOS app

The most important thing to consider is to have an app that can be maintainable. You know the View goes there, this View Controller should do X and not Y. And more important the others know that too.

Here are some advantages of choosing a good architecture pattern:

  • Easier to maintain
  • Easier to test the business logic
  • Develop a common language with the other teammates
  • Separate the responsibility of your entities
  • Less bugs

Defining the requirements

Given an iOS application with 6-7 screens, we are going to develop it using the most popular architecture patterns from the iOS world: MVC, MVVM, MVP, VIPER, VIP and Coordinators.

The demo app is called Football Gather and is a simple way of friends to track score of their amateur football matches.

Main features

  • Ability to add players in the app
  • You can assign teams to the players
  • Edit players
  • Set countdown timer for matches

Screen Mockups

Football Gather Mockups

Backend

The app is powered by a web app developed in Vapor web framework. You can check the app here (Vapor 3 initial article) and here (Migrating to Vapor 4).

Cleaning your code like a VIP

VIP is not an architecture pattern which is commonly used. It has been invented by Raymond Law and is intended to be Uncle’s Bob Clean Architecture version applied to iOS projects. More to find here: https://clean-swift.com/.

The main goal of VIP is to fix the massive view controller problem that we have with MVC. VIP also intends to provide a clean alternative to other architecture patterns problems. For example, VIPER has the Presenter at the centre of the app. VIP simplifies the flow by using an unidirectional way of control, becoming easier to invoke methods through layers.

VIP transforms your app control into VIP cycles, providing an unidirectional flow of control.

Example of scenario applying VIP:

  1. User taps on a button to fetch the list of players. We are in the ViewController.
  2. The IBAction calls a method from the Interactor.
  3. The Interactor transforms the request, performs some business logic (fetches the list of players from the server) and invokes the Presenter to transform the response in a presentable way for the user.
  4. The Presenter invokes the ViewController to display the players on the screen.

The architecture components are described below.

View/ViewController

Has two functions: sends to requests to the Interactor and actions and displays the information coming from the Presenter.

Interactor

The “new” Presenter. This layer is the core of VIP architecture, doing stuff like network calls to fetch data, handle errors, compute entries.

Worker

In Football Gather we use “Services” for a name, but basically these are the same thing. A Worker takes some of the responsibility of Interactors and handles Network calls or database requests.

Presenter

Handles data coming from the Interactor and transforms them into a ViewModel suitable to be displayed in the View.

Router

Has the same role as in VIPER, it takes care of scene transitions.

Models

Similar with other patterns, the Model layer is used to encapsulate data.

Communication

The ViewController communicates with Router and Interactor.

Interactor sends the data to the Presenter. It can have send and receive events from Workers as well.

Presenter transforms the response incoming from the Interactor into a ViewModel and passes it to the View/ViewController.

Advantages

  • You no longer have the Massive View Controller problem you have in MVC.
  • Using MVVM incorrectly, you might end up having Massive View Models instead.
  • Solves the control problem from VIPER with the VIP cycle.
  • Using VIPER incorrectly, you can have Massive Presenters.
  • The authors say it follows the Clean Architecture principles.
  • In case you have complex business logic, it can go into a Worker component.
  • Very easy to unit test and use TDD.
  • Good modularity.
  • Easier to debug.

Disadvantages

  • Too many layers and gets boring after a while if you don’t use a code generator.
  • You write a lot of code even for simple actions.
  • Is not great for small apps.
  • Some of the components might be redundant based on your app use cases.
  • App startup will slightly increase.

VIP vs VIPER

  • In VIP, the Interactor is now the layer that interacts with the View Controller.
  • The ViewController holds a reference to the Router in VIP.
  • If used incorrectly, VIPER can grow massive Presenters.
  • VIP has an unidirectional flow of control.
  • Services are called Workers in VIP.

Applying to our code

Transforming the app from VIPER to VIP might not be as easy as you may think. We can start with transforming our Presenter into an Interactor. Next, we can extract the Router from the Presenter and integrate into the ViewController.

We keep the Module assembly logic that we did for VIPER.

Login scene

Moving on to our Scenes. Let’s start with the Login scene.


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

        // ...

    }

As you can see we no longer tell the Presenter that the view has been loaded. We now make a request to the Interactor to load the credentials.

The IBActions have been modified as below:


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

        // ...

    }

We start the loading view, construct the request to the Interactor containing the username, password contents of the text fields and the state of the UISwitch for remember the username.

Next, handling the viewDidLoad UI updates are made through LoginViewConfigurable protocol:


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

Finally, when the logic service call has been completed we call from the Presenter the following method:


    func loginCompleted(viewModel: Login.Authenticate.ViewModel) {
            hideLoadingView()

            if viewModel.isSuccessful {
                router.showPlayerList()
            } else {
                handleError(title: viewModel.errorTitle!, message: viewModel.errorDescription!)
            }
        }
    }

The Interactor looks the same as the one from the VIPER architecture. It has the same dependencies:


    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
        }

    }

The key thing here is that we now inject the Presenter through the initialiser and it is no longer a weak variable.

Loading credentials is presented below. We first take the incoming request from the ViewController. We create a response for the presenter and call the function 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)
    }

The login and register methods are the same, the exception being the Network service (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
    }

The Presenter doesn’t hold references to the Router or Interactor. We just keep the dependency of the View, which has to be weak to complete the VIP cycle and not have retain cycles.

The Presenter has been greatly simplified, exposing two methods of the 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
       }
    }

The Router layer remains the same.

We apply some minor updats to the Module assembly:


    extension LoginModule: AppModule {
        func assemble() -> UIViewController? {
            presenter.view = view

            interactor.presenter = presenter

            view.interactor = interactor
            view.router     = router

            return view as? UIViewController
        }
    }

PlayerList scene

Next, we move to PlayerList scene.

The ViewController will be transformed in a similar way - the Presenter will be replaced by Interactor and we now hold a reference to the Router.

An interesting aspect in VIP is the fact we can have an array of view models inside the ViewController:


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

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

We no longer tell the Presenter that the View has been loaded. The ViewController will configure its UI elements in the initial state.


    override func viewDidLoad() {
       super.viewDidLoad()

       setupView()
       fetchPlayers()
    }

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

Similar to Login, the IBActions will construct a request and will call a method within the 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)
    }

When the data will be fetched and ready to be displayable, the Presenter will call the method from the ViewController displayFetchedPlayers.


    func displayFetchedPlayers(viewModel: PlayerList.FetchPlayers.ViewModel) {
        displayedPlayers = viewModel.displayedPlayers

        showEmptyViewIfRequired()
        setBarButtonState(isEnabled: !playersCollectionIsEmpty)
        reloadData()
    }

The data source of the table view can be seen below:


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

As you might notice, our cells don’t require a Presenter any more. We have everything needed (array of view models) in our view controller.

The Interactor is detailed below:


    // 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)
        }
    }

The Detail, Add, Confirm screens delegate are now moved from the Presenter to the 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)
        }
    }

Finally, the 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

Switching from VIPER to VIP when unit testing the Gather business functionality is not as hard as it may seem.
Basically, the Interactor is the new Presenter.

We follow the same Mock approach, with boolean flags that are set to true whenever a function is called:


    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
        }

    }

Here are some unit tests of the 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))
        }

    }

And the Presenter unit tests:


    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

File Number of lines of code VIPER lines of code
GatherProtocols 130 141 (-11)
PlayerListProtocols 106 127 (-21)
ConfirmPlayersProtocols 82 92 (-10)
PlayerEditProtocols 97 87 (+10)
PlayerDetailProtocols 84 86 (-2)
LoginProtocols 52 74 (-22)
PlayerAddProtocols 68 73 (-5)
TOTAL 619 680 (-61)

Lines of code - View Controllers and Views

File Number of lines of code VIPER - Lines of code MVP-C - Lines of code MVP - Lines of code MVVM - Lines of code MVC - Lines of code
PlayerAddViewController and PlayerAddView (MVP-C & MVP) 81 68 (+13) 129 (-48) 134 (-53) 77 (+4) 79 (+2)
PlayerListViewController and PlayerListView (MVP-C & MVP) 300 192 (+108) 324 (-24) 353 (-53) 296 (+4) 387 (-87)
PlayerDetailViewController and PlayerDetailView (MVP-C & MVP) 96 74 (+22) 148 (-52) 162 (-66) 96 204 (-108)
LoginViewController and LoginView (MVP-C & MVP) 75 60 (+15) 134 (-59) 131 (-56) 96 (-21) 126 (-51)
PlayerEditViewController and PlayerEditView (MVP-C & MVP) 149 106 (+43) 190 (-41) 195 (-46) 124 (+25) 212 (-63)
GatherViewController and GatherView (MVP-C & MVP) 266 186 (+80) 265 (+1) 271 (-5) 227 (+39) 359 (-93)
ConfirmPlayersViewController and ConfirmPlayersView (MVP-C & MVP) 117 104 (+13) 149 (-32 ) 154 (-37) 104 (+13) 260 (-143)
TOTAL 1084 790 (-294) 1339 (-255) 1400 (-316) 1020 (+64) 1627 (-543)

Lines of code - Modules

File Number of lines of code VIPER - lines of code
AppModule 98 98
PlayerListModule 41 42 (-1)
LoginModule 41 42 (-1)
PlayerEditModule 40 41 (-1)
PlayerDetailModule 40 41 (-1)
PlayerAddModule 40 41 (-1)
GatherModule 40 41 (-1)
ConfirmPlayersModule 40 41 (-1)
TOTAL 282 387 (-105)

Lines of code - Routers

File Number of lines of code VIPER - lines of code
PlayerListRouter 48 48
LoginRouter 32 32
PlayerDetailRouter 31 31
GatherRouter 31 31
ConfirmPlayersRouter 31 31
PlayerEditRouter 27 27
PlayerAddRouter 27 27
TOTAL 227 227

Lines of code - Presenters

File Number of lines of code VIPER - LOC MVP-C - LOC MVP - LOC MVVM - View Model LOC
LoginPresenter 60 113 (-53) 111 (-51) 111 (-51) 75 (-15)
PlayerListPresenter 173 261 (-88) 252 (-79) 259 (-86) 258 (-85)
PlayerEditPresenter 110 153 (-43) 187 (-77) 187 (-77) 155 (-45)
PlayerAddPresenter 43 75 (-32) 52 (-9) 52 (-9) 37 (+6)
PlayerDetailPresenter 81 142 (-61) 195 (-114) 195 (-114) 178 (-97)
GatherPresenter 172 234 (-62) 237 (-65) 237 (-65) 204 (-32)
ConfirmPlayersPresenter 77 131 (-54) 195 (-118) 195 (-118) 206 (-129)
PlayerTableViewCellPresenter N/A 55 (-55) N/A N/A N/A
PlayerDetailTableViewCellPresenter N/A 22 (-22) N/A N/A N/A
GatherTableViewCellPresenter N/A 22 (-22) N/A N/A N/A
ConfirmPlayersTableViewCellPresenter N/A 22 (-22) N/A N/A N/A
TOTAL 716 1230 (-514) 1229 (-513) 1236 (-520) 1113 (-397)

Lines of code - Interactors

File Number of lines of code VIPER - lines of code
PlayerListInteractor 141 76 (+65)
LoginInteractor 107 86 (+21)
PlayerDetailInteractor 108 30 (+78)
GatherInteractor 276 113 (+163)
ConfirmPlayersInteractor 77 145 (+68)
PlayerEditInteractor 208 121 (+87)
PlayerAddInteractor 57 38 (+19)
TOTAL 1117 609 (+508)

Lines of code - Local Models

File Number of lines of code VIPER - LOC MVP-C - LOC MVP - LOC
PlayerListViewState 50 N/A 69 69
TeamSection 50 50 50 50
GatherTimeHandler 120 120 100 (+20) 100 (+20)
PlayerEditable 26 26 N/A N/A
PlayerDetailSection 61 24 (+37) N/A N/A
PlayerListModels 142 N/A N/A N/A
GatherModels 266 N/A N/A N/A
PlayerEditModels 157 N/A N/A N/A
PlayerDetailModels 95 N/A N/A N/A
PlayerAddModels 49 N/A N/A N/A
LoginModels 49 N/A N/A N/A
ConfirmPlayersModels 101 N/A N/A N/A
TOTAL 1166 220 (+946) 219 (+947) 219 (+947)

Unit Tests

Topic Data VIPER Data MVP-C Data MVP Data MVVM Data MVC Data
Number of key classes 53 53 24 +29 24 +29 14 +39 7 +46
Key Classes GatherPresenter, GatherInteractor GatherPresenter, GatherInteractor GatherPresenter GatherPresenter GatherViewModel GatherViewController
Number of Unit Tests 28 Interactor, 26 Presenter, Total: 54 17 +11 Interactor, 29 -3 Presenter,
Total: 46 +8
34 +20 34 +20 34 +20 30 +24
Code Coverage of Gathers feature 100% Interactor, 100% Presenter 100% Interactor, 100% Presenter 97.2% +2.8 97.2% +2.8 97.3% +2.7 95.7% +4.3
How hard to write unit tests 1/5 3/5 -2 3/5 -2 3/5 -2 3/5 -2 5/5 -4

Build Times

Build Time (sec)* VIPER Time (sec)* MVP-C Time (sec)* MVP Time (sec)* MVVM Time (sec)* MVC Time (sec)*
Average Build Time (after clean Derived Data & Clean Build) 10.13 10.43 -0.3 10.08 +0.05 10.18 -0.05 9.65 +0.48 9.78 +0.35
Average Build Time 0.1 0.1 0.1 0.1 0.1 0.1
Average Unit Test Execution Time (after clean Derived Data & Clean Build) 18.95 19.03 -0.08 18.45 +0.5 16.52 +2.43 17.88 +1.07 12.78 +6.17

* tests were run on an 8-Core Intel Core i9, MacBook Pro, 2019.
Xcode version: 12.5.1.
macOS Big Sur 11.5.2

Conclusion

We applied VIP to an application written in VIPER and the first thing we might notice is the Presenters are simplified and much cleaner. If we would be coming from an MVC app, the view controllers would be reduced considerably by separation of concerns

VIP simplifies the flow by using an unidirectional way of control, becoming easier to invoke methods through layers.

The average build times are similar with the ones from VIPER and MVP, around 10 seconds.
Having more unit tests, adds on top of the test execution time. However, we were a little faster than VIPER.

The Presenters have been greatly reduced with 514 lines of code compared to VIPER. But the main downside is that we gain the number of lines in the Interactors, overall being increased with 508 lines of code. Basically, what we took out from the Presenters we put in the Interactors.

Personally, I prefer VIPER. In VIP architecture there are things that I don’t like and from my point of view they are not following as much as they brag the Uncle’s Bob principles.

For example, why do we need to construct a Request object, even if there is nothing attached to it? I mean, we could not do that, but if you open up the repo of examples you can see plenty of empty requests objects.

There is a lot of boiler plate code.

Keeping an array of view models inside the ViewController creates complexity and can easily become out of sync with the Worker models.

Of course you can use your own variation of VIP that can mitigate these problems.

On a positive note, I like the concept of VIP cycles and how easy it is to use TDD. However, following a strict rule on the layers, each minor change can be hard to implement. It should be SOFTware, right?!

Useful Links

Item Series Links
The iOS App - Football Gather GitHub Repo Link
The web server application made in Vapor GitHub Repo Link
Vapor 3 - Backend APIs article link
Migrating to Vapor 4 article link
Model View Controller (MVC) GitHub Repo Link
Article Link
Model View ViewModel (MVVM) GitHub Repo Link
Article Link
Model View Presenter (MVP) GitHub Repo Link
Article Link
Coordinator Pattern - MVP with Coordinators (MVP-C) GitHub Repo Link
Article Link
View Interactor Presenter Entity Router (VIPER) GitHub Repo Link
Article Link
View Interactor Presenter (VIP) GitHub Repo Link
Article Link