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

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:
- User taps on a button to fetch the list of players. We are in the ViewController.
- The
IBAction
calls a method from the Interactor. - 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.
- 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 |