Architecture Series - View Interactor Presenter Entity Router (VIPER)
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.
In this implementation article, we will transform our Football Gather app into using a VIPER codebase architecture.
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
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).
Dude, where’s my VIPER?
VIPER stands for View-Interactor-Presenter-Entity-Router.
We saw in MVP what the Presenter layer is and what it does. This concept applies as well for VIPER, but has been enhanced with a new responsibility, to get data from the Interactor and based on the rules, it will update / configure the View.
View
Must be as dumb as possible. It forwards all events to the Presenter and mostly should do what the Presenter tells it to do, being passive.
Interactor
A new layer has been introduced, and in here we should put everything that has to do with the business rules and logic.
Presenter
Has the responsibility to get data from the Interactor, based on the user’s actions, and then handle the View updates.
Entity
Is the Model layer and is used to encapsulate data.
Router
Holds all navigation logic for our application. It looks more like a Coordinator, without the business logic.
Communication
When something happens in the view layer, for example when the user initiates an action, it is communicated to the Presenter.
The Presenter asks the Interactor for the data needed by the user. The Interactor provides the data.
The Presenter applies the needed UI transformation to display that data.
When the model / data has been changed, the Interactor will inform the Presenter.
The Presenter will configure or refresh the View based on the data it received.
When users navigate through different screens within the app or take a different route that will change the flow, the View will communicate it to the Presenter.
The Presenter will notify the Router to load the new screen or load the new flow (e.g. pushing a new view controller).
Extended VIPER
There are a few concepts that are commonly used with VIPER architecture pattern.
Modules
Is a good idea to separate the VIPER layers creation from the Router and introduce a new handler for module assembly. This is done most likely with a Factory method pattern.
protocol AppModule {
func assemble() -> UIViewController?
}
protocol ModuleFactoryProtocol {
func makeLogin(using navigationController: UINavigationController) -> LoginModule
func makePlayerList(using navigationController: UINavigationController) -> PlayerListModule
}
And the concrete implementation for our app:
struct ModuleFactory: ModuleFactoryProtocol {
func makeLogin(using navigationController: UINavigationController = UINavigationController()) -> LoginModule {
let router = LoginRouter(navigationController: navigationController, moduleFactory: self)
let view: LoginViewController = Storyboard.defaultStoryboard.instantiateViewController()
return LoginModule(view: view, router: router)
}
func makePlayerList(using navigationController: UINavigationController = UINavigationController()) -> PlayerListModule {
let router = PlayerListRouter(navigationController: navigationController, moduleFactory: self)
let view: PlayerListViewController = Storyboard.defaultStoryboard.instantiateViewController()
return PlayerListModule(view: view, router: router)
}
}
We will see later more source code.
TDD
This approach does a good job from a Clean Code perspective, and you develop the layers to have a good separation of concerns and follow the SOLID principles better.
So, TDD is easy to achieve using VIPER.
- The modules are decoupled.
- There is a clear separation of concerns.
- The modules are are neat and clean from a coding perspective.
Code generation tool
As we add more modules, flows and functionality to our application, we will discover that we write a lot of code and most of it is repetitive.
There is a good idea to have a code generator tool for your VIPER modules.
Solving the back problem
We saw that when applying the Coordinator pattern we had a problem when navigating back in the stack, to a specific view controller.
In this case, we need to think of a way if in our app we need to go back or send data between different VIPER modules.
This problem can be easily solved with Delegation.
For example:
protocol PlayerDetailsDelegate: AnyObject {
func didUpdatePlayer(_ player: Player)
}
// we make the Presenter the delegate of PlayerDetailsPresenter so we can refresh the UI when a player has been changed.
extension PlayerListPresenter: PlayerDetailsDelegate {
func didUpdatePlayer(_ player: Player) {
viewState = .list
configureView()
view?.reloadData()
}
}
More practical examples we are going to see in the section Applying to our code.
When to use VIPER
VIPER should be used when you have some knowledge about Swift and iOS programming or you have experienced or more senior developers within your team.
If you are part of a small project, that will not scale, then VIPER might be too much. MVC should work just fine.
Use it when you are more interested in modularising and unit test the app giving you a high code coverage.
Don’t use it when you are a beginner or you don’t have that much experience into iOS development.
Be prepared to write more code.
From my point of view, VIPER is great and I really like how clean the code looks. Is easy to test, my classes are decoupled and the code is indeed SOLID.
For our app, we separated the View layer into two components: ViewController and the actual View.
The ViewController acts as a Coordinator / Router and holds a reference to the view, usually set as an IBOutlet
.
Advantages
- The code is clean, SRP is at its core.
- Unit tests are easy to write.
- The code is decoupled.
- Less bugs, especially if you are using TDD.
- Very useful for complex projects, where it simplifies the business logic.
- The modules can be reusable.
- New features are easy to add.
Disadvantages
- You may write a lot of boilerplate code.
- Is not great for small apps.
- You end up with a big codebase and a lot of classes.
- Some of the components might be redundant based on your app use cases.
- App startup will slightly increase.
Applying to our code
There will be major changes to the app by applying VIPER.
We decided to not keep two separate layers for View and ViewController, because one of these layer will become very light and it didn’t serve much purpose.
All coordinators will be removed.
First, we start by creating an AppLoader
that will load the first module, Login.
struct AppLoader {
private let window: UIWindow
private let navigationController: UINavigationController
private let moduleFactory: ModuleFactoryProtocol
init(window: UIWindow = UIWindow(frame: UIScreen.main.bounds),
navigationController: UINavigationController = UINavigationController(),
moduleFactory: ModuleFactoryProtocol = ModuleFactory()) {
self.window = window
self.navigationController = navigationController
self.moduleFactory = moduleFactory
}
// This function is similar with the one we had for Coordinators, start().
func build() {
let module = moduleFactory.makeLogin(using: navigationController)
let viewController = module.assemble()
setRootViewController(viewController)
}
private func setRootViewController(_ viewController: UIViewController?) {
window.rootViewController = navigationController
if let viewController = viewController {
navigationController.pushViewController(viewController, animated: true)
}
window.makeKeyAndVisible()
}
}
We allocate AppLoader
in AppDelegate
and call the function build()
when the app did finish launching.
class AppDelegate: UIResponder, UIApplicationDelegate {
private lazy var loader = AppLoader()
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
loader.build()
return true
}
..
}
We saw earlier, how we use ModuleFactory
to create VIPER modules. We provide an interface for all modules that require assemble in our app.
protocol ModuleFactoryProtocol {
func makeLogin(using navigationController: UINavigationController) -> LoginModule
func makePlayerList(using navigationController: UINavigationController) -> PlayerListModule
func makePlayerDetails(using navigationController: UINavigationController,
for player: PlayerResponseModel,
delegate: PlayerDetailDelegate) -> PlayerDetailModule
func makePlayerEdit(using navigationController: UINavigationController,
for playerEditable: PlayerEditable,
delegate: PlayerEditDelegate) -> PlayerEditModule
func makePlayerAdd(using navigationController: UINavigationController, delegate: PlayerAddDelegate) -> PlayerAddModule
func makeConfirmPlayers(using navigationController: UINavigationController,
playersDictionary: [TeamSection: [PlayerResponseModel]],
delegate: ConfirmPlayersDelegate) -> ConfirmPlayersModule
func makeGather(using navigationController: UINavigationController,
gather: GatherModel,
delegate: GatherDelegate) -> GatherModule
}
We have a struct ModuleFactory
that is the concrete implementation of the above protocol.
struct ModuleFactory: ModuleFactoryProtocol {
func makeLogin(using navigationController: UINavigationController = UINavigationController()) -> LoginModule {
let router = LoginRouter(navigationController: navigationController, moduleFactory: self)
let view: LoginViewController = Storyboard.defaultStoryboard.instantiateViewController()
return LoginModule(view: view, router: router)
}
/// other functions
…
}
Let’s see how LoginModule is created.
final class LoginModule {
// Set the dependencies
private var view: LoginViewProtocol
private var router: LoginRouterProtocol
private var interactor: LoginInteractorProtocol
private var presenter: LoginPresenterProtocol
// Optionally, provide default implementation for your protocols with concrete classes
init(view: LoginViewProtocol = LoginViewController(),
router: LoginRouterProtocol = LoginRouter(),
interactor: LoginInteractorProtocol = LoginInteractor(),
presenter: LoginPresenterProtocol = LoginPresenter()) {
self.view = view
self.router = router
self.interactor = interactor
self.presenter = presenter
}
}
// Reference your layers
extension LoginModule: AppModule {
func assemble() -> UIViewController? {
presenter.view = view
presenter.interactor = interactor
presenter.router = router
interactor.presenter = presenter
view.presenter = presenter
return view as? UIViewController
}
}
Every module will have a function assemble()
that is needed when implementing the AppModule
protocol.
In here, we create the references between the VIPER layers:
- We set the view to the presenter (weak link).
- Presenter holds a strong reference to the Interactor.
- Presenter holds a strong reference to the Router.
- Interactor holds a weak reference to the Presenter.
- Our View holds a strong reference to the Presenter.
We set the weak references to avoid, of course, retain cycles which can cause memory leaks.
Every VIPER module within our app is assembled in the same way.
LoginRouter
has a simple job: present the players after the user logged in.
final class LoginRouter {
private let navigationController: UINavigationController
private let moduleFactory: ModuleFactoryProtocol
// We inject the module factory so we can create and assemble the next screen module (PlayerList).
init(navigationController: UINavigationController = UINavigationController(),
moduleFactory: ModuleFactoryProtocol = ModuleFactory()) {
self.navigationController = navigationController
self.moduleFactory = moduleFactory
}
}
// When the user logged in, route to PlayerList
extension LoginRouter: LoginRouterProtocol {
func showPlayerList() {
let module = moduleFactory.makePlayerList(using: navigationController)
if let viewController = module.assemble() {
navigationController.pushViewController(viewController, animated: true)
}
}
}
One important aspect that we missed when applying MVP to our code, was that we didn’t made our View passive. The Presenter acted more like a ViewModel in some cases.
Let’s correct that and make the View as passive and dumb as we can.
Another thing that we did, was to split the LoginViewProtocol
into multiple small protocols, addressing the specific need:
typealias LoginViewProtocol = LoginViewable & Loadable & LoginViewConfigurable & ErrorHandler
protocol LoginViewable: AnyObject {
var presenter: LoginPresenterProtocol { get set }
}
protocol LoginViewConfigurable: AnyObject {
var rememberMeIsOn: Bool { get }
var usernameText: String? { get }
var passwordText: String? { get }
func setRememberMeSwitch(isOn: Bool)
func setUsername(_ username: String?)
}
We combined all of them by using protocol composition and named them with a typealias
. We use the same approach for all of our VIPER protocols.
The LoginViewController
is described below:
final class LoginViewController: UIViewController, LoginViewable {
// MARK: - Properties
@IBOutlet weak var usernameTextField: UITextField!
@IBOutlet weak var passwordTextField: UITextField!
@IBOutlet weak var rememberMeSwitch: UISwitch!
lazy var loadingView = LoadingView.initToView(view)
// We can remove the default implementation of LoginPresenter() and force-unwrap the presenter in the protocol definition. We used this approach for some modules.
var presenter: LoginPresenterProtocol = LoginPresenter()
// MARK: - View life cycle
override func viewDidLoad() {
super.viewDidLoad()
presenter.viewDidLoad()
}
// MARK: - IBActions
@IBAction private func login(_ sender: Any) {
presenter.performLogin()
}
@IBAction private func register(_ sender: Any) {
presenter.performRegister()
}
}
// MARK: - Configuration
extension LoginViewController: LoginViewConfigurable {
// UIKit is not allowed to be referenced in the Presenter. We expose the value of our outlets by using abstraction.
var rememberMeIsOn: Bool { rememberMeSwitch.isOn }
var usernameText: String? { usernameTextField.text }
var passwordText: String? { passwordTextField.text }
func setRememberMeSwitch(isOn: Bool) {
rememberMeSwitch.isOn = isOn
}
func setUsername(_ username: String?) {
usernameTextField.text = username
}
}
// MARK: - Loadable
extension LoginViewController: Loadable {}
// MARK: - Error Handler
extension LoginViewController: ErrorHandler {}
Loadable
is the same helper protocol that we used in our previous versions of the codebase. It simply shows and hides a loading view, which comes in handy when doing some Network requests. It has a default implementation for classes of type UIView
and UIViewController
(example: extension Loadable where Self: UIViewController
).
ErrorHandler
is a new helper protocol that has one method:
protocol ErrorHandler {
func handleError(title: String, message: String)
}
extension ErrorHandler where Self: UIViewController {
func handleError(title: String, message: String) {
AlertHelper.present(in: self, title: title, message: message)
}
}
The default implementation uses the static method from AlertHelper
to present an alert controller. We use it for displaying the Network errors.
We continue with the Presenter layer below:
final class LoginPresenter: LoginPresentable {
// MARK: - Properties
weak var view: LoginViewProtocol?
var interactor: LoginInteractorProtocol
var router: LoginRouterProtocol
// MARK: - Public API
init(view: LoginViewProtocol? = nil,
interactor: LoginInteractorProtocol = LoginInteractor(),
router: LoginRouterProtocol = LoginRouter()) {
self.view = view
self.interactor = interactor
self.router = router
}
}
We set our dependencies to be injected via the initialiser. Now, the presenter has two new dependencies: Interactor and Router.
After our ViewController finished to load the view, we notify the Presenter. We want to make the View more passive, so we let the Presenter to specify the View how to configure its UI elements with the information that we get from the Interactor:
// MARK: - View Configuration
extension LoginPresenter: LoginPresenterViewConfiguration {
func viewDidLoad() {
// Fetch the UserDefaults and Keychain values by asking the Interactor. Configure the UI elements based on the values we got.
let rememberUsername = interactor.rememberUsername
view?.setRememberMeSwitch(isOn: rememberUsername)
if rememberUsername {
view?.setUsername(interactor.username)
}
}
}
The service API calls to login and register are similar:
extension LoginPresenter: LoginPresenterServiceInteractable {
func performLogin() {
guard validateCredentials() else { return }
view?.showLoadingView()
interactor.login(username: username!, password: password!)
}
func performRegister() {
guard validateCredentials() else { return }
view?.showLoadingView()
interactor.register(username: username!, password: password!)
}
private func validateCredentials() -> Bool {
guard credentialsAreValid else {
view?.handleError(title: "Error", message: "Both fields are mandatory.")
return false
}
return true
}
private var credentialsAreValid: Bool {
username?.isEmpty == false && password?.isEmpty == false
}
private var username: String? {
view?.usernameText
}
private var password: String? {
view?.passwordText
}
}
When the API calls are finished, the Interactor calls the following methods from the Presenter:
// MARK: - Service Handler
extension LoginPresenter: LoginPresenterServiceHandler {
func serviceFailedWithError(_ error: Error) {
view?.hideLoadingView()
view?.handleError(title: "Error", message: String(describing: error))
}
func didLogin() {
handleAuthCompletion()
}
func didRegister() {
handleAuthCompletion()
}
private func handleAuthCompletion() {
storeUsernameAndRememberMe()
view?.hideLoadingView()
router.showPlayerList()
}
private func storeUsernameAndRememberMe() {
let rememberMe = view?.rememberMeIsOn ?? true
if rememberMe {
interactor.setUsername(view?.usernameText)
} else {
interactor.setUsername(nil)
}
}
}
The Interactor now holds the business logic:
final class LoginInteractor: LoginInteractable {
weak var presenter: LoginPresenterProtocol?
private let loginService: LoginService
private let usersService: StandardNetworkService
private let userDefaults: FootballGatherUserDefaults
private let keychain: FootbalGatherKeychain
init(loginService: LoginService = LoginService(),
usersService: StandardNetworkService = StandardNetworkService(resourcePath: "/api/users"),
userDefaults: FootballGatherUserDefaults = .shared,
keychain: FootbalGatherKeychain = .shared) {
self.loginService = loginService
self.usersService = usersService
self.userDefaults = userDefaults
self.keychain = keychain
}
}
We expose in our Public API the actual values for rememberMe
and the username
:
// MARK: - Credentials handler
extension LoginInteractor: LoginInteractorCredentialsHandler {
var rememberUsername: Bool { userDefaults.rememberUsername ?? true }
var username: String? { keychain.username }
func setRememberUsername(_ value: Bool) {
userDefaults.rememberUsername = value
}
func setUsername(_ username: String?) {
keychain.username = username
}
}
The service handlers are lighter than in previous architecture patterns:
// MARK: - Services
extension LoginInteractor: LoginInteractorServiceRequester {
func login(username: String, password: String) {
let requestModel = UserRequestModel(username: username, password: password)
loginService.login(user: requestModel) { [weak self] result in
DispatchQueue.main.async {
switch result {
case .failure(let error):
self?.presenter?.serviceFailedWithError(error)
case .success(_):
self?.presenter?.didLogin()
}
}
}
}
func register(username: String, password: String) {
guard let hashedPasssword = Crypto.hash(message: password) else {
fatalError("Unable to hash password")
}
let requestModel = UserRequestModel(username: username, password: hashedPasssword)
usersService.create(requestModel) { [weak self] result in
DispatchQueue.main.async {
switch result {
case .failure(let error):
self?.presenter?.serviceFailedWithError(error)
case .success(let resourceId):
print("Created user: \(resourceId)")
self?.presenter?.didRegister()
}
}
}
}
}
When editing a player, we use delegation for refreshing the list of the players from the PlayerList module.
struct ModuleFactory: ModuleFactoryProtocol {
func makePlayerDetails(using navigationController: UINavigationController = UINavigationController(),
for player: PlayerResponseModel,
delegate: PlayerDetailDelegate) -> PlayerDetailModule {
let router = PlayerDetailRouter(navigationController: navigationController, moduleFactory: self)
let view: PlayerDetailViewController = Storyboard.defaultStoryboard.instantiateViewController()
let interactor = PlayerDetailInteractor(player: player)
let presenter = PlayerDetailPresenter(interactor: interactor, delegate: delegate)
return PlayerDetailModule(view: view, router: router, interactor: interactor, presenter: presenter)
}
func makePlayerEdit(using navigationController: UINavigationController = UINavigationController(),
for playerEditable: PlayerEditable,
delegate: PlayerEditDelegate) -> PlayerEditModule {
let router = PlayerEditRouter(navigationController: navigationController, moduleFactory: self)
let view: PlayerEditViewController = Storyboard.defaultStoryboard.instantiateViewController()
let interactor = PlayerEditInteractor(playerEditable: playerEditable)
let presenter = PlayerEditPresenter(interactor: interactor, delegate: delegate)
return PlayerEditModule(view: view, router: router, interactor: interactor, presenter: presenter)
}
…
}
Navigating to Edit screen
We show PlayerDetailsView
by calling the router from PlayerListPresenter
:
func selectRow(at index: Int) {
guard playersCollectionIsEmpty == false else {
return
}
if isInListViewMode {
let player = interactor.players[index]
showDetailsView(for: player)
} else {
toggleRow(at: index)
updateSelectedRows(at: index)
reloadViewAfterRowSelection(at: index)
}
}
private func showDetailsView(for player: PlayerResponseModel) {
router.showDetails(for: player, delegate: self)
}
PlayerListRouter
is shown below:
extension PlayerListRouter: PlayerListRouterProtocol {
func showDetails(for player: PlayerResponseModel, delegate: PlayerDetailDelegate) {
let module = moduleFactory.makePlayerDetails(using: navigationController, for: player, delegate: delegate)
if let viewController = module.assemble() {
navigationController.pushViewController(viewController, animated: true)
}
}
}
Now, we use the same approach from Detail screen to Edit screen:
func selectRow(at indexPath: IndexPath) {
let player = interactor.player
let rowDetails = sections[indexPath.section].rows[indexPath.row]
let items = self.items(for: rowDetails.editableField)
let selectedItemIndex = items.firstIndex(of: rowDetails.value.lowercased())
let editablePlayerDetails = PlayerEditable(player: player,
items: items,
selectedItemIndex: selectedItemIndex,
rowDetails: rowDetails)
router.showEditView(with: editablePlayerDetails, delegate: self)
}
And the router:
extension PlayerDetailRouter: PlayerDetailRouterProtocol {
func showEditView(with editablePlayerDetails: PlayerEditable, delegate: PlayerEditDelegate) {
let module = moduleFactory.makePlayerEdit(using: navigationController, for: editablePlayerDetails, delegate: delegate)
if let viewController = module.assemble() {
navigationController.pushViewController(viewController, animated: true)
}
}
}
Navigating back to the List screen
When the user confirms the changes to a player, we call our presenter delegate.
extension PlayerEditPresenter: PlayerEditPresenterServiceHandler {
func playerWasUpdated() {
view?.hideLoadingView()
delegate?.didUpdate(player: interactor.playerEditable.player)
router.dismissEditView()
}
}
The delegate is PlayerDetailsPresenter
:
// MARK: - PlayerEditDelegate
extension PlayerDetailPresenter: PlayerEditDelegate {
func didUpdate(player: PlayerResponseModel) {
interactor.updatePlayer(player)
delegate?.didUpdate(player: player)
}
}
Finally, we call the PlayerDetailDelegate
(assigned to PlayerListPresenter
) and refresh the list of players:
// MARK: - PlayerEditDelegate
extension PlayerListPresenter: PlayerDetailDelegate {
func didUpdate(player: PlayerResponseModel) {
interactor.updatePlayer(player)
}
}
We follow the same approach for Confirm and Add modules:
func confirmOrAddPlayers() {
if isInListViewMode {
showAddPlayerView()
} else {
showConfirmPlayersView()
}
}
private var isInListViewMode: Bool {
viewState == .list
}
private func showAddPlayerView() {
router.showAddPlayer(delegate: self)
}
private func showConfirmPlayersView() {
router.showConfirmPlayers(with: interactor.selectedPlayers(atRows: selectedRows), delegate: self)
}
The Router class is presented below:
extension PlayerListRouter: PlayerListRouterProtocol {
func showAddPlayer(delegate: PlayerAddDelegate) {
let module = moduleFactory.makePlayerAdd(using: navigationController, delegate: delegate)
if let viewController = module.assemble() {
navigationController.pushViewController(viewController, animated: true)
}
}
func showConfirmPlayers(with playersDictionary: [TeamSection: [PlayerResponseModel]], delegate: ConfirmPlayersDelegate) {
let module = moduleFactory.makeConfirmPlayers(using: navigationController, playersDictionary: playersDictionary, delegate: delegate)
if let viewController = module.assemble() {
navigationController.pushViewController(viewController, animated: true)
}
}
}
Implementing the service handler in PlayerAddPresenter
:
extension PlayerAddPresenter: PlayerAddPresenterServiceHandler {
func playerWasAdded() {
view?.hideLoadingView()
delegate?.didAddPlayer()
router.dismissAddView()
}
}
Finally, delegation to the list of players:
// MARK: - PlayerAddDelegate
extension PlayerListPresenter: PlayerAddDelegate {
func didAddPlayer() {
loadPlayers()
}
}
// MARK: - ConfirmPlayersDelegate
extension PlayerListPresenter: ConfirmPlayersDelegate {
func didEndGather() {
viewState = .list
configureView()
view?.reloadData()
}
}
In this architecture pattern, we wanted to make the View as passive as we could (this concept should be applied to MVP, too).
For that we created for the table rows, a CellViewPresenter
:
protocol PlayerTableViewCellPresenterProtocol: AnyObject {
var view: PlayerTableViewCellProtocol? { get set }
var viewState: PlayerListViewState { get set }
var isSelected: Bool { get set }
func setupView()
func configure(with player: PlayerResponseModel)
func toggle()
}
The concrete class described below:
final class PlayerTableViewCellPresenter: PlayerTableViewCellPresenterProtocol {
var view: PlayerTableViewCellProtocol?
var viewState: PlayerListViewState
var isSelected = false
init(view: PlayerTableViewCellProtocol? = nil,
viewState: PlayerListViewState = .list) {
self.view = view
self.viewState = viewState
}
func setupView() {
if viewState == .list {
view?.setupDefaultView()
} else {
view?.setupViewForSelection(isSelected: isSelected)
}
}
func toggle() {
isSelected.toggle()
if viewState == .selection {
view?.setupCheckBoxImage(isSelected: isSelected)
}
}
func configure(with player: PlayerResponseModel) {
view?.set(nameDescription: player.name)
setPositionDescription(for: player)
setSkillDescription(for: player)
}
private func setPositionDescription(for player: PlayerResponseModel) {
let position = player.preferredPosition?.rawValue
view?.set(positionDescription: "Position: \(position ?? "-")")
}
private func setSkillDescription(for player: PlayerResponseModel) {
let skill = player.skill?.rawValue
view?.set(skillDescription: "Skill: \(skill ?? "-")")
}
}
The presenter will update the CellView
:
final class PlayerTableViewCell: UITableViewCell, PlayerTableViewCellProtocol {
@IBOutlet weak var checkboxImageView: UIImageView!
@IBOutlet weak var playerCellLeftConstraint: NSLayoutConstraint!
@IBOutlet weak var nameLabel: UILabel!
@IBOutlet weak var positionLabel: UILabel!
@IBOutlet weak var skillLabel: UILabel!
private enum Constants {
static let playerContentLeftPadding: CGFloat = 10.0
static let playerContentAndIconLeftPadding: CGFloat = -20.0
}
func setupDefaultView() {
playerCellLeftConstraint.constant = Constants.playerContentAndIconLeftPadding
setupCheckBoxImage(isSelected: false)
checkboxImageView.isHidden = true
}
func setupViewForSelection(isSelected: Bool) {
playerCellLeftConstraint.constant = Constants.playerContentLeftPadding
checkboxImageView.isHidden = false
setupCheckBoxImage(isSelected: isSelected)
}
func setupCheckBoxImage(isSelected: Bool) {
let imageName = isSelected ? "ticked" : "unticked"
checkboxImageView.image = UIImage(named: imageName)
}
func set(nameDescription: String) {
nameLabel.text = nameDescription
}
func set(positionDescription: String) {
positionLabel.text = positionDescription
}
func set(skillDescription: String) {
skillLabel.text = skillDescription
}
}
In PlayerViewController
, we have the cellForRowAt
method:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell: PlayerTableViewCell = tableView.dequeueReusableCell(withIdentifier: "PlayerTableViewCell") as? PlayerTableViewCell else {
return UITableViewCell()
}
let index = indexPath.row
let cellPresenter = presenter.cellPresenter(at: index)
let player = presenter.player(at: index)
cellPresenter.view = cell
cellPresenter.setupView()
cellPresenter.configure(with: player)
return cell
}
Inside the Presenter we cache the existing cell presenters:
func cellPresenter(at index: Int) -> PlayerTableViewCellPresenterProtocol {
if let cellPresenter = cellPresenters[index] {
cellPresenter.viewState = viewState
return cellPresenter
}
let cellPresenter = PlayerTableViewCellPresenter(viewState: viewState)
cellPresenters[index] = cellPresenter
return cellPresenter
}
Finally, we present our main app module, Gather.
GatherViewController
is simplified and looks great:
// MARK: - GatherViewController
final class GatherViewController: UIViewController, GatherViewable {
// MARK: - Properties
@IBOutlet weak var playerTableView: UITableView!
@IBOutlet weak var scoreLabelView: ScoreLabelView!
@IBOutlet weak var scoreStepper: ScoreStepper!
@IBOutlet weak var timerLabel: UILabel!
@IBOutlet weak var timerView: UIView!
@IBOutlet weak var timePickerView: UIPickerView!
@IBOutlet weak var actionTimerButton: UIButton!
lazy var loadingView = LoadingView.initToView(view)
var presenter: GatherPresenterProtocol!
// MARK: - View life cycle
override func viewDidLoad() {
super.viewDidLoad()
presenter.viewDidLoad()
}
// MARK: - IBActions
@IBAction private func endGather(_ sender: Any) {
presenter.requestToEndGather()
}
@IBAction private func setTimer(_ sender: Any) {
presenter.setTimer()
}
@IBAction private func cancelTimer(_ sender: Any) {
presenter.cancelTimer()
}
@IBAction private func actionTimer(_ sender: Any) {
presenter.actionTimer()
}
@IBAction private func timerCancel(_ sender: Any) {
presenter.timerCancel()
}
@IBAction private func timerDone(_ sender: Any) {
presenter.timerDone()
}
}
We expose the Public API using the protocol GatherViewConfigurable
:
// MARK: - Configuration
extension GatherViewController: GatherViewConfigurable {
var scoreDescription: String {
scoreLabelView.scoreDescription
}
var winnerTeamDescription: String {
scoreLabelView.winnerTeamDescription
}
func configureTitle(_ title: String) {
self.title = title
}
func setActionButtonTitle(_ title: String) {
actionTimerButton.setTitle(title, for: .normal)
}
func setupScoreStepper() {
scoreStepper.delegate = self
}
func setTimerViewVisibility(isHidden: Bool) {
timerView.isHidden = isHidden
}
func selectRow(_ row: Int, inComponent component: Int, animated: Bool = false) {
timePickerView.selectRow(row, inComponent: component, animated: animated)
}
func selectedRow(in component: Int) -> Int {
timePickerView.selectedRow(inComponent: component)
}
func setTimerLabelText(_ text: String) {
timerLabel.text = text
}
func setTeamALabelText(_ text: String) {
scoreLabelView.teamAScoreLabel.text = text
}
func setTeamBLabelText(_ text: String) {
scoreLabelView.teamBScoreLabel.text = text
}
}
GatherViewReloadable
defines the reloadData
method. Here, we reload all picker
components and the tableView
data.
// MARK: - Reload
extension GatherViewController: GatherViewReloadable {
func reloadData() {
timePickerView.reloadAllComponents()
playerTableView.reloadData()
}
}
We don’t have any more two separate layers, ViewController and View. The alert controller presentation is done inside the View layer:
// MARK: - Confirmation
extension GatherViewController: GatherViewConfirmable {
func displayConfirmationAlert() {
let alertController = UIAlertController(title: "End Gather", message: "Are you sure you want to end the gather?", preferredStyle: .alert)
let confirmAction = UIAlertAction(title: "Yes", style: .default) { [weak self] _ in
self?.presenter.endGather()
}
alertController.addAction(confirmAction)
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
alertController.addAction(cancelAction)
present(alertController, animated: true, completion: nil)
}
}
We could have used a separate layer and create another object for the table’s and picker’s DataSource and Delegate, but for the sake of our exercise we preferred to implement the methods inside our ViewController:
// MARK: - UITableViewDelegate | UITableViewDataSource
extension GatherViewController: UITableViewDelegate, UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
presenter.numberOfSections
}
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
presenter.titleForHeaderInSection(section)
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
presenter.numberOfRowsInSection(section)
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "GatherCellId") else {
return UITableViewCell()
}
let cellPresenter = GatherTableViewCellPresenter(view: cell)
cellPresenter.configure(title: presenter.rowTitle(at: indexPath), descriptionDetails: presenter.rowDescription(at: indexPath))
return cell
}
}
// MARK: - UIPickerViewDataSource
extension GatherViewController: UIPickerViewDataSource, UIPickerViewDelegate {
func numberOfComponents(in pickerView: UIPickerView) -> Int {
presenter.numberOfPickerComponents
}
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
presenter.numberOfRowsInPickerComponent(component)
}
func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
presenter.titleForPickerRow(row, forComponent: component)
}
}
We also have implemented the ScoreStepperDelegate
to pass to the presenter the UI updates of the team sliders.
And finally some helper protocols to add functionality for our custom cell, showing and hiding a loading spinner and to handle errors.
// MARK: - UITableViewCell
extension UITableViewCell: GatherTableViewCellProtocol {}
// MARK: - Loadable
extension GatherViewController: Loadable {}
// MARK: - Error Handler
extension GatherViewController: ErrorHandler {}
Testing our business logic
In VIPER we have the Interactor handling our business logic. This should be tested.
However, the core of the architecture is the Presenter, which handles updates to the View and communicates with both the Router and Interactor. This should be tested as well.
Testing the Presenter
The class for for unit testing the presenter is GatherPresenterTests
:
final class GatherPresenterTests: XCTestCase {
// MARK: - GatherPresenterViewConfiguration
func testViewDidLoad_whenPresenterIsAllocated_configuresView() {
// given
let mockView = GatherMockView()
let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
// when
sut.viewDidLoad()
// then
XCTAssertEqual(mockView.title, "Gather in progress")
XCTAssertTrue(mockView.timerViewIsHidden)
XCTAssertEqual(mockView.selectionDictionary[mockInteractor.minutesComponent!.rawValue], mockInteractor.selectedTime.minutes)
XCTAssertEqual(mockView.selectionDictionary[mockInteractor.secondsComponent!.rawValue], mockInteractor.selectedTime.seconds)
XCTAssertEqual(mockView.timerLabelText, "10:00")
XCTAssertEqual(mockView.actionButtonTitle, "Start")
XCTAssertTrue(mockView.scoreStepperWasSetup)
XCTAssertTrue(mockView.viewWasReloaded)
}
func testViewDidLoad_whenTimeComponentsAreEmpty_minutesComponentIsNil() {
// given
let mockView = GatherMockView()
let mockGather = GatherModel(players: [], gatherUUID: UUID())
let mockInteractor = GatherInteractor(gather: mockGather, timeComponents: [])
let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
// when
sut.viewDidLoad()
// then
XCTAssertNil(mockView.selectionDictionary[0])
XCTAssertNil(mockView.selectionDictionary[1])
}
}
Testing the table view's and picker view's data source:
// MARK: - Table Data Source
func testNumberOfSections_whenPresenterIsAllocated_equalsTeamSectionsCount() {
// given
let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
let sut = GatherPresenter(interactor: mockInteractor)
// when
let numberOfSections = sut.numberOfSections
// then
XCTAssertEqual(mockInteractor.teamSections.count, numberOfSections)
}
func testNumberOfRowsInSection_whenSectionIsZero_equalsNumberOfPlayersInTeamSection() {
// given
let mockGather = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
let expectedNumberOfPlayers = mockGather.players.filter { $0.team == .teamA }.count
let mockInteractor = GatherInteractor(gather: mockGather)
let sut = GatherPresenter(interactor: mockInteractor)
// when
let numberOfRowsInSection = sut.numberOfRowsInSection(0)
// then
XCTAssertEqual(numberOfRowsInSection, expectedNumberOfPlayers)
}
func testNumberOfRowsInSection_whenSectionIsOne_equalsNumberOfPlayersInTeamSection() {
// given
let mockGather = ModelsMockFactory.makeGatherModel(numberOfPlayers: 5)
let expectedNumberOfPlayers = mockGather.players.filter { $0.team == .teamB }.count
let mockInteractor = GatherInteractor(gather: mockGather)
let sut = GatherPresenter(interactor: mockInteractor)
// when
let numberOfRowsInSection = sut.numberOfRowsInSection(1)
// then
XCTAssertEqual(numberOfRowsInSection, expectedNumberOfPlayers)
}
func testRowTitle_whenInteractorHasPlayers_equalsPlayerName() {
// given
let mockGather = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
let expectedRowTitle = mockGather.players.filter { $0.team == .teamA }.first?.player.name
let mockInteractor = GatherInteractor(gather: mockGather)
let sut = GatherPresenter(interactor: mockInteractor)
// when
let rowTitle = sut.rowTitle(at: IndexPath(row: 0, section: 0))
// then
XCTAssertEqual(rowTitle, expectedRowTitle)
}
func testRowDescription_whenInteractorHasPlayers_equalsPlayerPreferredPositionAcronym() {
// given
let mockGather = ModelsMockFactory.makeGatherModel(numberOfPlayers: 1)
let expectedRowDescription = mockGather.players.filter { $0.team == .teamB }.first?.player.preferredPosition?.acronym
let mockInteractor = GatherInteractor(gather: mockGather)
let sut = GatherPresenter(interactor: mockInteractor)
// when
let rowDescription = sut.rowDescription(at: IndexPath(row: 0, section: 1))
// then
XCTAssertEqual(rowDescription, expectedRowDescription)
}
func testTitleForHeaderInSection_whenSectionIsTeamA_equalsTeamSectionHeaderTitle() {
// given
let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
let expectedTitle = TeamSection.teamA.headerTitle
let sut = GatherPresenter(interactor: mockInteractor)
// when
let titleForHeader = sut.titleForHeaderInSection(0)
// then
XCTAssertEqual(titleForHeader, expectedTitle)
}
func testTitleForHeaderInSection_whenSectionIsTeamB_equalsTeamSectionHeaderTitle() {
// given
let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
let expectedTitle = TeamSection.teamB.headerTitle
let sut = GatherPresenter(interactor: mockInteractor)
// when
let titleForHeader = sut.titleForHeaderInSection(1)
// then
XCTAssertEqual(titleForHeader, expectedTitle)
}
// MARK: - Picker Data Source
func testNumberOfPickerComponents_whenTimeComponentsAreGiven_equalsInteractorTimeComponents() {
// given
let mockTimeComponents: [GatherTimeHandler.Component] = [.minutes]
let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()), timeComponents: mockTimeComponents)
let sut = GatherPresenter(interactor: mockInteractor)
// when
let numberOfPickerComponents = sut.numberOfPickerComponents
// then
XCTAssertEqual(numberOfPickerComponents, mockTimeComponents.count)
}
func testNumberOfRowsInPickerComponent_whenComponentIsMinutes_equalsNumberOfSteps() {
// given
let mockTimeComponents: [GatherTimeHandler.Component] = [.minutes]
let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()), timeComponents: mockTimeComponents)
let sut = GatherPresenter(interactor: mockInteractor)
// when
let numberOfRowsInPickerComponent = sut.numberOfRowsInPickerComponent(0)
// then
XCTAssertEqual(numberOfRowsInPickerComponent, GatherTimeHandler.Component.minutes.numberOfSteps)
}
func testNumberOfRowsInPickerComponent_whenComponentIsSeconds_equalsNumberOfSteps() {
// given
let mockTimeComponents: [GatherTimeHandler.Component] = [.seconds]
let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()), timeComponents: mockTimeComponents)
let sut = GatherPresenter(interactor: mockInteractor)
// when
let numberOfRowsInPickerComponent = sut.numberOfRowsInPickerComponent(0)
// then
XCTAssertEqual(numberOfRowsInPickerComponent, GatherTimeHandler.Component.seconds.numberOfSteps)
}
func testTitleForPickerRow_whenComponentsAreNotEmpty_containsTimeComponentShort() {
// given
let mockTimeComponents: [GatherTimeHandler.Component] = [.seconds]
let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()), timeComponents: mockTimeComponents)
let sut = GatherPresenter(interactor: mockInteractor)
// when
let titleForPickerRow = sut.titleForPickerRow(0, forComponent: 0)
// then
XCTAssertTrue(titleForPickerRow.contains(GatherTimeHandler.Component.seconds.short))
}
Testing the stepper handler:
// MARK: - Stepper Handler
func testUpdateValue_whenTeamIsA_viewSetsTeamALabelTextWithNewValue() {
// given
let mockValue = 15.0
let mockView = GatherMockView()
let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
// when
sut.updateValue(for: .teamA, with: mockValue)
// then
XCTAssertEqual(mockView.teamALabelText, "\(Int(mockValue))")
}
func testUpdateValue_whenTeamIsB_viewSetsTeamBLabelTextWithNewValue() {
// given
let mockValue = 15.0
let mockView = GatherMockView()
let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
// when
sut.updateValue(for: .teamB, with: mockValue)
// then
XCTAssertEqual(mockView.teamBLabelText, "\(Int(mockValue))")
}
Testing the IBActions
:
// MARK: - Actions
func testRequestToEndGather_whenPresenterIsAllocated_viewDisplaysConfirmationAlert() {
// given
let mockView = GatherMockView()
let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
// when
sut.requestToEndGather()
// then
XCTAssertTrue(mockView.confirmationAlertWasDisplayed)
}
func testSetTimer_whenPresenterIsAllocated_selectsRowAndSetsTimerViewVisibile() {
// given
let mockView = GatherMockView()
let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
// when
sut.setTimer()
// then
XCTAssertNotNil(mockView.selectionDictionary[0])
XCTAssertNotNil(mockView.selectionDictionary[1])
XCTAssertFalse(mockView.timerViewIsHidden)
}
func testCancelTimer_whenPresenterIsAllocated_resetsTimerAndUpdatesView() {
// given
let mockView = GatherMockView()
let mockInteractor = GatherMockInteractor()
let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
// when
sut.cancelTimer()
// then
XCTAssertTrue(mockInteractor.timerWasStopped)
XCTAssertTrue(mockInteractor.timerWasResetted)
XCTAssertNotNil(mockView.timerLabelText)
XCTAssertNotNil(mockView.actionButtonTitle)
XCTAssertTrue(mockView.timerViewIsHidden)
}
func testActionTimer_whenPresenterIsAllocated_togglesTimerAndUpdatesActionButtonTitle() {
// given
let mockView = GatherMockView()
let mockInteractor = GatherMockInteractor()
let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
// when
sut.actionTimer()
// then
XCTAssertTrue(mockInteractor.timerWasToggled)
XCTAssertNotNil(mockView.actionButtonTitle)
}
func testTimerCancel_whenPresenterIsAllocated_timerViewIsHidden() {
// given
let mockView = GatherMockView()
let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
// when
sut.timerCancel()
// then
XCTAssertTrue(mockView.timerViewIsHidden)
}
func testTimerDone_whenPresenterIsAllocated_stopsTimerUpdatesTimeAndConfiguresView() {
// given
let mockView = GatherMockView()
let mockInteractor = GatherMockInteractor()
let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
// when
sut.timerDone()
// then
XCTAssertTrue(mockInteractor.timerWasStopped)
XCTAssertTrue(mockInteractor.timeWasUpdated)
XCTAssertNotNil(mockView.timerLabelText)
XCTAssertNotNil(mockView.actionButtonTitle)
XCTAssertTrue(mockView.timerViewIsHidden)
}
func testTimerDone_whenViewIsNil_stopsTimerUpdatesTimeAndConfiguresView() {
// given
let mockInteractor = GatherMockInteractor()
let sut = GatherPresenter(interactor: mockInteractor)
// when
sut.timerDone()
// then
XCTAssertFalse(mockInteractor.timeWasUpdated)
}
func testEndGather_whenViewIsNotNil_showsLoadingViewAndEndsGather() {
// given
let mockView = GatherMockView()
let mockInteractor = GatherMockInteractor()
let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
// when
sut.endGather()
// then
XCTAssertTrue(mockView.loadingViewWasShown)
XCTAssertTrue(mockInteractor.gatherWasEnded)
}
func testEndGather_whenViewIsNil_returns() {
// given
let mockInteractor = GatherMockInteractor()
let sut = GatherPresenter(interactor: mockInteractor)
// when
sut.endGather()
// then
XCTAssertFalse(mockInteractor.gatherWasEnded)
}
func testGatherEnded_whenPresenterIsAllocated_hidesLoadingViewEndGathersAndPopsToPlayerList() {
// given
let mockView = GatherMockView()
let mockInteractor = GatherMockInteractor()
let mockDelegate = GatherMockDelegate()
let mockRouter = GatherMockRouter()
let sut = GatherPresenter(view: mockView, interactor: mockInteractor, router: mockRouter, delegate: mockDelegate)
// when
sut.gatherEnded()
// then
XCTAssertTrue(mockView.loadingViewWasHidden)
XCTAssertTrue(mockDelegate.gatherWasEnded)
XCTAssertTrue(mockRouter.poppedToPlayerList)
}
func testServiceFailedToEndGather_whenPresenterIsAllocated_hidesLoadingViewAndHandlesError() {
// given
let mockView = GatherMockView()
let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
// when
sut.serviceFailedToEndGather()
// then
XCTAssertTrue(mockView.loadingViewWasHidden)
XCTAssertTrue(mockView.errorWasHandled)
}
func testTimerDecremented_whenPresenterIsAllocated_setsTimerLabelText() {
// given
let mockView = GatherMockView()
let mockInteractor = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
// when
sut.timerDecremented()
// then
XCTAssertNotNil(mockView.timerLabelText)
}
func testActionButtonTitle_whenTimerStateIsPaused_isResume() {
// given
let mockView = GatherMockView()
let mockInteractor = GatherMockInteractor(timerState: .paused)
let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
// when
sut.actionTimer()
// then
XCTAssertEqual(mockView.actionButtonTitle, "Resume")
}
func testActionButtonTitle_whenTimerStateIsRunning_isPause() {
// given
let mockView = GatherMockView()
let mockInteractor = GatherMockInteractor(timerState: .running)
let sut = GatherPresenter(view: mockView, interactor: mockInteractor)
// when
sut.actionTimer()
// then
XCTAssertEqual(mockView.actionButtonTitle, "Pause")
}
The mocks are defined in a separate file:
// MARK: - View
final class GatherMockView: GatherViewProtocol {
var presenter: GatherPresenterProtocol!
var loadingView = LoadingView()
private(set) var title: String?
private(set) var timerViewIsHidden = false
private(set) var selectionDictionary: [Int: Int] = [:]
private(set) var timerLabelText: String?
private(set) var actionButtonTitle: String?
private(set) var scoreStepperWasSetup = false
private(set) var viewWasReloaded = false
private(set) var teamALabelText: String?
private(set) var teamBLabelText: String?
private(set) var confirmationAlertWasDisplayed = false
private(set) var loadingViewWasShown = false
private(set) var loadingViewWasHidden = false
private(set) var errorWasHandled = false
var scoreDescription: String { "" }
var winnerTeamDescription: String { "" }
func configureTitle(_ title: String) {
self.title = title
}
func setTimerViewVisibility(isHidden: Bool) {
timerViewIsHidden = isHidden
}
func selectRow(_ row: Int, inComponent component: Int, animated: Bool) {
selectionDictionary[component] = row
}
func setTimerLabelText(_ text: String) {
timerLabelText = text
}
func setActionButtonTitle(_ title: String) {
actionButtonTitle = title
}
func setupScoreStepper() {
scoreStepperWasSetup = true
}
func reloadData() {
viewWasReloaded = true
}
func setTeamALabelText(_ text: String) {
teamALabelText = text
}
func setTeamBLabelText(_ text: String) {
teamBLabelText = text
}
func displayConfirmationAlert() {
confirmationAlertWasDisplayed = true
}
func showLoadingView() {
loadingViewWasShown = true
}
func hideLoadingView() {
loadingViewWasHidden = true
}
func handleError(title: String, message: String) {
errorWasHandled = true
}
func selectedRow(in component: Int) -> Int { 0 }
}
// MARK: - Interactor
final class GatherMockInteractor: GatherInteractorProtocol {
var presenter: GatherPresenterServiceHandler?
var teamSections: [TeamSection] = TeamSection.allCases
private(set) var timerState: GatherTimeHandler.State
private(set) var timerWasStopped = false
private(set) var timerWasResetted = false
private(set) var timerWasToggled = false
private(set) var timeWasUpdated = false
private(set) var gatherWasEnded = false
init(timerState: GatherTimeHandler.State = .stopped) {
self.timerState = timerState
}
func stopTimer() {
timerWasStopped = true
}
func resetTimer() {
timerWasResetted = true
}
func teamSection(at index: Int) -> TeamSection {
teamSections[index]
}
func toggleTimer() {
timerWasToggled = true
}
func updateTime(_ gatherTime: GatherTime) {
timeWasUpdated = true
}
func endGather(score: String, winnerTeam: String) {
gatherWasEnded = true
}
var selectedTime: GatherTime { .defaultTime }
var minutesComponent: GatherTimeHandler.Component? { .minutes }
var secondsComponent: GatherTimeHandler.Component? { .seconds }
var timeComponents: [GatherTimeHandler.Component] = GatherTimeHandler.Component.allCases
func timeComponent(at index: Int) -> GatherTimeHandler.Component {
timeComponents[index]
}
func players(in team: TeamSection) -> [PlayerResponseModel] { [] }
}
// MARK: - Delegate
final class GatherMockDelegate: GatherDelegate {
private(set) var gatherWasEnded = false
func didEndGather() {
gatherWasEnded = true
}
}
// MARK: - Router
final class GatherMockRouter: GatherRouterProtocol {
private(set) var poppedToPlayerList = false
func popToPlayerListView() {
poppedToPlayerList = true
}
}
Testing the Interactor:
import XCTest
@testable import FootballGather
final class GatherInteractorTests: XCTestCase {
func testTeamSections_whenInteractorIsAllocated_equalsTeamAandTeamB() {
// given
let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
// when
let teamSections = sut.teamSections
// then
XCTAssertEqual(teamSections, [.teamA, .teamB])
}
func testTeamSection_whenIndexIsZero_equalsTeamA() {
// given
let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
// when
let teamSection = sut.teamSection(at: 0)
// then
XCTAssertEqual(teamSection, .teamA)
}
func testTeamSection_whenIndexIsOne_equalsTeamB() {
// given
let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
// when
let teamSection = sut.teamSection(at: 1)
// then
XCTAssertEqual(teamSection, .teamB)
}
func testPlayersInTeam_whenInteractorHasPlayers_returnsPlayersForTheGivenTeam() {
// given
let mockGather = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
let expectedPlayers = mockGather.players.filter { $0.team == .teamA }.compactMap { $0.player }
let sut = GatherInteractor(gather: mockGather)
// when
let players = sut.players(in: .teamA)
// then
XCTAssertEqual(players, expectedPlayers)
}
func testEndGather_whenScoreIsSet_updatesGather() {
// given
let appKeychain = AppKeychainMockFactory.makeKeychain()
appKeychain.token = ModelsMock.token
let session = URLSessionMockFactory.makeSession()
let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
let mockEndpoint = EndpointMockFactory.makeSuccessfulEndpoint(path: "/api/gathers")
let mockService = StandardNetworkService(session: session, urlRequest: AuthURLRequestFactory(endpoint: mockEndpoint, keychain: appKeychain))
let mockPresenter = GatherMockPresenter()
let exp = expectation(description: "Update gather expectation")
mockPresenter.expectation = exp
let sut = GatherInteractor(gather: mockGatherModel, updateGatherService: mockService)
sut.presenter = mockPresenter
// when
sut.endGather(score: "1-1", winnerTeam: "None")
// then
waitForExpectations(timeout: 5) { _ in
XCTAssertTrue(mockPresenter.gatherEndedCalled)
appKeychain.storage.removeAll()
}
}
func testEndGather_whenScoreIsNotSet_updatesGather() {
// given
let appKeychain = AppKeychainMockFactory.makeKeychain()
appKeychain.token = ModelsMock.token
let session = URLSessionMockFactory.makeSession()
let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
let mockEndpoint = EndpointMockFactory.makeSuccessfulEndpoint(path: "/api/gathers")
let mockService = StandardNetworkService(session: session, urlRequest: AuthURLRequestFactory(endpoint: mockEndpoint, keychain: appKeychain))
let mockPresenter = GatherMockPresenter()
let exp = expectation(description: "Update gather expectation")
mockPresenter.expectation = exp
let sut = GatherInteractor(gather: mockGatherModel, updateGatherService: mockService)
sut.presenter = mockPresenter
// when
sut.endGather(score: "", winnerTeam: "")
// then
waitForExpectations(timeout: 5) { _ in
XCTAssertTrue(mockPresenter.serviceFailedCalled)
appKeychain.storage.removeAll()
}
}
func testMinutesComponent_whenInteractorIsAllocated_isMinutes() {
// given
let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
// when
let minutesComponent = sut.minutesComponent
// then
XCTAssertEqual(minutesComponent, .minutes)
}
func testSecondsComponent_whenInteractorIsAllocated_isSeconds() {
// given
let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
// when
let secondsComponent = sut.secondsComponent
// then
XCTAssertEqual(secondsComponent, .seconds)
}
func testSelectedTime_whenInteractorIsAllocated_isDefaultTime() {
// given
let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
// when
let selectedTime = sut.selectedTime
// then
XCTAssertEqual(selectedTime.minutes, GatherTime.defaultTime.minutes)
XCTAssertEqual(selectedTime.seconds, GatherTime.defaultTime.seconds)
}
func testTimerState_whenInteractorIsAllocated_isStopped() {
// given
let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
// when
let timerState = sut.timerState
// then
XCTAssertEqual(timerState, .stopped)
}
func testTimeComponent_whenIndexIsZero_isMinutes() {
// given
let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
// when
let timeComponent = sut.timeComponent(at: 0)
// then
XCTAssertEqual(timeComponent, .minutes)
}
func testTimeComponent_whenIndexIsOne_isSeconds() {
// given
let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
// when
let timeComponent = sut.timeComponent(at: 1)
// then
XCTAssertEqual(timeComponent, .seconds)
}
func testStopTimer_whenStateIsRunning_isStopped() {
// given
let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()), timeHandler: GatherTimeHandler(state: .running))
// when
sut.stopTimer()
// then
XCTAssertEqual(sut.timerState, .stopped)
}
func testUpdateTimer_whenGatherTimeIsDifferent_updatesSelectedTime() {
// given
let mockSelectedTime = GatherTime(minutes: 99, seconds: 101)
let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()))
// when
sut.updateTime(mockSelectedTime)
// then
XCTAssertEqual(sut.selectedTime.minutes, mockSelectedTime.minutes)
XCTAssertEqual(sut.selectedTime.seconds, mockSelectedTime.seconds)
}
func testToggleTimer_whenTimeIsValid_decrementsTime() {
// given
let numberOfUpdateCalls = 2
let mockSelectedTime = GatherTime(minutes: 0, seconds: numberOfUpdateCalls)
let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()), timeHandler: GatherTimeHandler(selectedTime: mockSelectedTime))
let mockPresenter = GatherMockPresenter()
let exp = expectation(description: "Update gather expectation")
mockPresenter.expectation = exp
mockPresenter.numberOfUpdateCalls = numberOfUpdateCalls
sut.presenter = mockPresenter
// when
sut.toggleTimer()
// then
waitForExpectations(timeout: 5) { _ in
XCTAssertTrue(mockPresenter.timerDecrementedCalled)
XCTAssertEqual(mockPresenter.actualUpdateCalls, numberOfUpdateCalls)
sut.stopTimer()
}
}
func testToggleTimer_whenTimeIsInvalid_returns() {
// given
let mockSelectedTime = GatherTime(minutes: -1, seconds: -1)
let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()), timeHandler: GatherTimeHandler(selectedTime: mockSelectedTime))
let mockPresenter = GatherMockPresenter()
sut.presenter = mockPresenter
// when
sut.toggleTimer()
// then
XCTAssertFalse(mockPresenter.timerDecrementedCalled)
}
func testResetTimer_whenInteractorIsAllocated_resetsTime() {
// given
let numberOfUpdateCalls = 1
let mockSelectedTime = GatherTime(minutes: 0, seconds: numberOfUpdateCalls)
let sut = GatherInteractor(gather: GatherModel(players: [], gatherUUID: UUID()), timeHandler: GatherTimeHandler(selectedTime: mockSelectedTime))
let mockPresenter = GatherMockPresenter()
let exp = expectation(description: "Update gather expectation")
mockPresenter.expectation = exp
mockPresenter.numberOfUpdateCalls = numberOfUpdateCalls
sut.presenter = mockPresenter
// when
sut.toggleTimer()
waitForExpectations(timeout: 5) { _ in
XCTAssertTrue(mockPresenter.timerDecrementedCalled)
XCTAssertEqual(mockPresenter.actualUpdateCalls, numberOfUpdateCalls)
XCTAssertEqual(sut.selectedTime.minutes, mockSelectedTime.minutes)
XCTAssertNotEqual(sut.selectedTime.seconds, mockSelectedTime.seconds)
sut.resetTimer()
// then
XCTAssertEqual(sut.selectedTime.minutes, mockSelectedTime.minutes)
XCTAssertEqual(sut.selectedTime.seconds, mockSelectedTime.seconds)
sut.stopTimer()
}
}
}
We mock the Presenter:
// MARK: - Presenter
final class GatherMockPresenter: GatherPresenterServiceHandler {
private(set) var gatherEndedCalled = false
private(set) var serviceFailedCalled = false
private(set) var timerDecrementedCalled = false
weak var expectation: XCTestExpectation? = nil
var numberOfUpdateCalls = 1
private(set) var actualUpdateCalls = 0
func gatherEnded() {
gatherEndedCalled = true
expectation?.fulfill()
}
func serviceFailedToEndGather() {
serviceFailedCalled = true
expectation?.fulfill()
}
func timerDecremented() {
timerDecrementedCalled = true
actualUpdateCalls += 1
if expectation != nil && numberOfUpdateCalls == actualUpdateCalls {
expectation?.fulfill()
}
}
}
Key Metrics
Lines of code - Protocols
File | Number of lines of code |
---|---|
GatherProtocols | 141 |
PlayerListProtocols | 127 |
ConfirmPlayersProtocols | 92 |
PlayerEditProtocols | 87 |
PlayerDetailProtocols | 86 |
LoginProtocols | 74 |
PlayerAddProtocols | 73 |
TOTAL | 680 |
Lines of code - View Controllers and Views
File | Number of 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) | 68 | 129 (-61) | 134 (-66) | 77 (-9) | 79 (-11) |
PlayerListViewController and PlayerListView (MVP-C & MVP) | 192 | 324 (-132) | 353 (-161) | 296 (-104) | 387 (-195) |
PlayerDetailViewController and PlayerDetailView (MVP-C & MVP) | 74 | 148 (-74) | 162 (-88) | 96 (-22) | 204 (-130) |
LoginViewController and LoginView (MVP-C & MVP) | 60 | 134 (-74) | 131 (-71) | 96 (-36) | 126 (-66) |
PlayerEditViewController and PlayerEditView (MVP-C & MVP) | 106 | 190 (-84) | 195 (-89) | 124 (-18) | 212 (-106) |
GatherViewController and GatherView (MVP-C & MVP) | 186 | 265 (-79) | 271 (-85) | 227 (-41) | 359 (-173) |
ConfirmPlayersViewController and ConfirmPlayersView (MVP-C & MVP) | 104 | 149 (-45) | 154 (-50) | 104 | 260 (-156) |
TOTAL | 790 | 1339 (-549) | 1400 (-610) | 1020 (-230) | 1627 (-837) |
Lines of code - Modules
File | Number of lines of code |
---|---|
AppModule | 98 |
PlayerListModule | 42 |
LoginModule | 42 |
PlayerEditModule | 41 |
PlayerDetailModule | 41 |
PlayerAddModule | 41 |
GatherModule | 41 |
ConfirmPlayersModule | 41 |
TOTAL | 387 |
Lines of code - Routers
File | Number of lines of code |
---|---|
PlayerListRouter | 48 |
LoginRouter | 32 |
PlayerDetailRouter | 31 |
GatherRouter | 31 |
ConfirmPlayersRouter | 31 |
PlayerEditRouter | 27 |
PlayerAddRouter | 27 |
TOTAL | 227 |
Lines of code - Presenters
File | Number of lines of code | MVP-C - LOC | MVP - LOC | MVVM - View Model LOC |
---|---|---|---|---|
LoginPresenter | 113 | 111 (+2) | 111 (+2) | 75 (+38) |
PlayerListPresenter | 261 | 252 (+9) | 259 (+2) | 258 (+3) |
PlayerEditPresenter | 153 | 187 (-34) | 187 (-34) | 155 (-2) |
PlayerAddPresenter | 75 | 52 (+25) | 52 (+25) | 37 (+38) |
PlayerDetailPresenter | 142 | 195 (-53) | 195 (-53) | 178 (-36) |
GatherPresenter | 234 | 237 (-3) | 237 (-3) | 204 (+30) |
ConfirmPlayersPresenter | 131 | 195 (-64) | 195 (-64) | 206 (-75) |
PlayerTableViewCellPresenter | 55 | N/A | N/A | N/A |
PlayerDetailTableViewCellPresenter | 22 | N/A | N/A | N/A |
GatherTableViewCellPresenter | 22 | N/A | N/A | N/A |
ConfirmPlayersTableViewCellPresenter | 22 | N/A | N/A | N/A |
TOTAL | 1230 | 1229 (+1) | 1236 (-6) | 1113 (+116) |
Lines of code - Interactors
File | Number of lines of code |
---|---|
PlayerListInteractor | 76 |
LoginInteractor | 86 |
PlayerDetailInteractor | 30 |
GatherInteractor | 113 |
ConfirmPlayersInteractor | 145 |
PlayerEditInteractor | 121 |
PlayerAddInteractor | 38 |
TOTAL | 609 |
Lines of code - Local Models
File | Number of lines of code | MVP-C - LOC | MVP - LOC |
---|---|---|---|
PlayerListViewState | N/A | 69 | 69 |
TeamSection | 50 | 50 | 50 |
GatherTimeHandler | 120 | 100 (+20) | 100 (+20) |
PlayerEditable | 26 | N/A | N/A |
PlayerDetailSection | 24 | N/A | N/A |
TOTAL | 220 | 219 (+1) | 219 (+1) |
Unit Tests
Topic | Data | MVP-C Data | MVP Data | MVVM Data | MVC Data |
---|---|---|---|---|---|
Number of key classes | 53 | 24 +29 | 24 +29 | 14 +39 | 7 +46 |
Key Classes | GatherPresenter, GatherInteractor | GatherPresenter | GatherPresenter | GatherViewModel | GatherViewController |
Number of Unit Tests | 17 Interactor, 29 Presenter, Total: 46 | 34 +12 | 34 +12 | 34 +12 | 30 +16 |
Code Coverage of Gathers feature | 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 | 5/5 -4 |
Build Times
Build | 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,43 | 10.08 +0.35 | 10.18 +0.25 | 9.65 +0.78 | 9.78 +0.65 |
Average Build Time | 0.1 | 0.1 | 0.1 | 0.1 | 0.1 |
Average Unit Test Execution Time (after clean Derived Data & Clean Build) | 19.03 | 18.45 +0.58 | 16.52 +2.51 | 17.88 +1.15 | 12.78 +6.25 |
* 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
VIPER is an awesome architecture if you are looking for clean code. We can even go further and create more layers if we truly want to embrace Single Responsibility Principle.
Unit tests are easy to write.
On the other hand, we have a lot of files, protocols and classes in our project. When something changes or we need to update something in the UI, we end up changing a lot of things and this takes time.
Specifically to our application, transforming the existing MVP-C architecture into VIPER was harder to ahieve comparing to the other patterns. We had to merge the View and ViewController layers first and then we had to touch almost all classes and creating new classes / files.
The functions are quite small, most of them concentrating on doing one thing.
The protocols files are useful if we want to use static frameworks, decoupling the modules from the main .xcodeproj
.
We notice that the ViewControllers have been reduced considerably, all of them summing up almost 800 lines of code. This is more than double than the ViewControllers from MVC, where we had 1627 lines of code.
On the other hand, we now have new layers:
- Protocols - this is just the abstractions of the modules, containing just the definition of the layers.
- Modules - the assemble of the VIPER layers. It's part of the Router and is usually initialized via a factory.
- Interactors - contains the business logic and network calls, orchestrating the data changes.
The new layers add 1903 lines of code.
Writing unit tests was very fun to do. We had everything decoupled and was a pleasure to assert different conditions.
We manage to obtain 100% code coverage.
However, the build times are the highest of all. Each time we delete the content of the Dervived Data folder and clean the build folder, we loose 10.43 seconds.This takes almost one second more than the time when the app was in MVVM or MVC. But who knows how much time we saved fixing potential bugs?!
Executing unit tests after cleaning folders, takes close to 20 seconds. We have more tests, 46 in total.
More files, classes and dependencies add more time to the compiler's build time.
Lucky for us, we don't have to clean build and wipe out derived data folder each time we want to run the unit tests. We can leave this responsibility to the CI server.
I personally like using VIPER in medium to large applications that don't tend to change very often and we add new features on top of the existing ones.
However, there are some notable disadvantages when adopting VIPER.
Firstly, you write a lot of code and you might think why you need to go through three layers, instead of just doing it in the View Controller.
Secondly, it doesn't make sense for small applications, we don't need to add boilerplate to simple tasks, creating redundant files.
Finally, your app compilation time and even your startup time will increase.
Thanks for staying until the end!
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 |