Architecture Series - Model View Controller (MVC)
Motivation
Before starting to develop an iOS app, we have to think of 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.
We are kicking off with a series of articles where we take one application through the most known patterns of iOS development.
We will check build times, pros and cons of each pattern, but most important we will see the actual implementation and the source code.
If you just want to see the code, feel free to skip this post. The code is available open source here.
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).
What is MVC
Probably the most known architecture pattern in the world.
There are three components: Model, View and Controller.
Model
- Contains all your data classes, helpers, networking code
- All data specific to your app and defines the logic that processes that data
- In our app, the model represents what is found in the Utils, Storage and Networking groups
- It can have a 1:M and M:M relationships with other model objects, as we have players with gathers (M:M) or user with players / gathers (1:M)
- It should not communicate directly with the View and should not care about the user interface
Communication
- When something happens in the view layer, for example when the user initiates an action, it is communicated to the model through the Controller
- When the model is changed, for example when new data is available, the model notifies the Controller.
View
- Represents components of what users see on the screen
- Responds to user actions
- The purpose of the view is to show data from the Model and to make it available for user interactions
- Key Apple frameworks: UIKit, AppKit
- Examples from our app: LoadingView, EmptyView, PlayerTableViewCell, ScoreStepper
Communication
- Views can’t communicate directly with the Model, everything is done through the Controller
Controller
- This is the core layer of MVC
- It takes care of View updates and mutates the Model
- It takes care of Model updates and updates the View
- Controllers can have setup methods or tasks to manage the life cycles of other objects
Communication
- Can communicate with both layers, Model and View
- Controllers interprets user actions and triggers changes to the data via Model layer
- When data changes, it makes sure those changes are communicated to the user interface, updating the View
Different flavours of MVC
The traditional MVC is different from Cocoa’s MVC. View and Model layers could communicate between each other.
The View is stateless and rendered by the Controller once the Model has been updated.
It was introduced into Smalltalk-79 and has been created on top of several design patterns: composite, strategy and observer.
Composite
The view objects in an application are actually a composite of nested views that work together in a coordinated fashion (that is, the view hierarchy). These display components range from a window to compound views, such as a table view, to individual views, such as buttons. User input and display can take place at any level of the composite structure.
Think about UIView
’s hierarchy. Views are main components of user’s interface. They can contain other subviews.
For example, in our app, LoginViewController
has the main view that contains a bunch of stack views and inside them are the text fields for entering the username and password and the login button.
Strategy
A controller object implements the strategy for one or more view objects. The view object confines itself to maintaining its visual aspects, and it delegates to the controller all decisions about the application-specific meaning of the interface behavior.
Observer
A model object keeps interested objects in an application—usually view objects—advised of changes in its state.
The main disadvantage with the old MVC is that all three layers are tightly coupled. Is hard to test, maintain them and even reuse some of the logic.
How and when to use MVC
It depends.
If you do it right, you can use it in every app. There is no a definitive answer, yes or no, it depends on your app, teams, organisation, growth of the project, skills of the developers, deadlines etc.
However, there are some vulnerable points you should consider:
- As you can see, the Controller is in the center of this architecture pattern. It has a strong couple with the View and the Model layers.
- The Controller might turn into the well known Massive View Controller
- It is harder to test
There are ways to tackle the points mentioned above. One of them is to split the View Controller into mini-ViewControllers, where you have a big Container/Parent View Controller that acts as a coordinator and each zone of the app is handled by a different or a child View Controller.
Why should you use MVC:
- It is recommended by Apple and used in their frameworks (e.g.
UIKit
) - A lot of the developers know this pattern and is easy to collaborate with them
- You write code faster than in other architecture patterns
Applying to our code
In FootballGather we implement each screen as a View Controller:
LoginViewController
Description
- landing page where users can login with their credentials or create new users
UI elements
- usernameTextField - this is the text field where users enter their username
- passwordTextField - secure text field for entering passwords
- rememberMeSwitch - is an
UISwitch
for saving the username in Keychain after login and autopopulate the field next time we enter the app - loadingView - is used to show an indicator while a server call is made
Services
- loginService - used to call the Login API with the entered credentials
- usersService - used to call the Register API, creating a new user
As we can see, this class has three major functions: login, register and remember my username. Jumping to next screen is done via performSegue
.
Code snippet
@IBAction private func login(_ sender: Any) {
// This is the minimum validation that we do, we check if the fields are empty. If yes, we display an alert to the user.
guard let userText = usernameTextField.text, userText.isEmpty == false,
let passwordText = passwordTextField.text, passwordText.isEmpty == false else {
AlertHelper.present(in: self, title: "Error", message: "Both fields are mandatory.")
return
}
// Adds the loading view while the Network call is performed
showLoadingView()
// Creates the request model
let requestModel = UserRequestModel(username: userText, password: passwordText)
// Calls the Login webservice API and handles the Result
loginService.login(user: requestModel) { [weak self] result in
guard let self = self else { return }
DispatchQueue.main.async {
self.hideLoadingView()
switch result {
case .failure(let error):
// An error has occured. Make sure the user is informed
AlertHelper.present(in: self, title: "Error", message: String(describing: error))
case .success(_):
// Hides the loading view, stores rememberMe option in Keychain or cleares the existing one and performs the segue so we advance to next screen.
self.handleSuccessResponse()
}
}
}
PlayerListViewController
Description
- shows the players for the logged in user. Consists of a main table view, each player is displayed in a separated row.
UI elements
- playerTableView - The table view that displays players
- confirmOrAddPlayersButton - Action button from the bottom of the view, that can either correspond to an add player action or confirms the selected players
- loadingView - is used to show an indicator while a server call is made
- emptyView - Shown when the user hasn’t added any players
- barButtonItem - The top right button that can have different states based on the view mode we are in. Has the title “Cancel” when we go into selection mode to choose the players we want for the gather or “Select” when we are in view mode.
Services
- playersService - Used to retrieve the list of players and to delete a player
Models
- players - An array of players created by the user. This are the rows we see in playerTableView
- selectedPlayersDictionary - A cache dictionary that stores the row index of the selected player as key and the selected player as value.
If you open up Main.storyboard
you can see that from this view controller you can perform three segues
- ConfirmPlayersSegueIdentifier - After you select what players you want for your gather, you go to a confirmation screen where you assign the teams they will be part of.
- PlayerAddSegueIdentifier - Goes to a screen where you can create a new player
- PlayerDetailSegueIdentifier - Opens a screen where you can see the details of the player
We have the following function to retrieve the model for this View Controller.
// Performs a GET request to Players API to retrieve the list of the players.
private func loadPlayers() {
// UI setup methods, show Network activity indicator
view.isUserInteractionEnabled = false
// Perfoms the GET request
playersService.get { [weak self] (result: Result<[PlayerResponseModel], Error>) in
DispatchQueue.main.async {
self?.view.isUserInteractionEnabled = true
switch result {
case .failure(let error):
// In case of failures, present an alert to the user.
self?.handleServiceFailures(withError: error)
case .success(let players):
// Server returned a successful response with the array of players
self?.players = players
self?.handleLoadPlayersSuccessfulResponse()
}
}
}
}
And if we want to delete one player we do the following:
// Table delegate that asks the data source to commit the insertion or deletion of a specified row in the receiver.
func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
// We just use it for deletion and not editing
guard editingStyle == .delete else { return }
// Show a confirmation alert if we are sure to delete the player
let alertController = UIAlertController(title: "Delete player", message: "Are you sure you want to delete the selected player?", preferredStyle: .alert)
let confirmAction = UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
self?.handleDeletePlayerConfirmation(forRowAt: indexPath)
}
alertController.addAction(confirmAction)
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
alertController.addAction(cancelAction)
present(alertController, animated: true, completion: nil)
}
private func handleDeletePlayerConfirmation(forRowAt indexPath: IndexPath) {
showLoadingView()
// Try to delete the player from the server and if successful, delete it
requestDeletePlayer(at: indexPath) { [weak self] result in
guard result, let self = self else { return }
// Deletes the player from the data source and performs the table row animations.
self.playerTableView.beginUpdates()
self.players.remove(at: indexPath.row)
self.playerTableView.deleteRows(at: [indexPath], with: .fade)
self.playerTableView.endUpdates()
if self.players.isEmpty {
self.showEmptyView()
}
}
}
The service call is presented below:
private func requestDeletePlayer(at indexPath: IndexPath, completion: @escaping (Bool) -> Void) {
let player = players[indexPath.row]
var service = playersService
service.delete(withID: ResourceID.integer(player.id)) { [weak self] result in
DispatchQueue.main.async {
switch result {
case .failure(let error):
self?.handleServiceFailures(withError: error)
completion(false)
case .success(_):
completion(true)
}
}
}
PlayerAddViewController
Description
- This screen is used for creating a player.
UI Elements
- playerNameTextField - Used to enter the name of the player
- doneButton - Bar button item that is used to confirm the player to be created and initiates a service call
- loadingView - Is used to show an indicator while a server call is made
Services
- We use the
StandardNetworkService
that points to /api/players. To add players, we initiate a POST request.
Code snippet
private func createPlayer(_ player: PlayerCreateModel, completion: @escaping (Bool) -> Void) {
let service = StandardNetworkService(resourcePath: "/api/players", authenticated: true)
service.create(player) { result in
if case .success(_) = result {
completion(true)
} else {
completion(false)
}
}
}
PlayerDetailViewController
Description
- maps a screen that shows the details of a player (name, age, position, skill and favourite team)
UI elements
- playerDetailTableView - A tableview that displays the details of the player.
Model
- player - The model of the player as
PlayerResponseModel
We have no services in this ViewController. A request to update an information of player is received fromPlayerEditViewController
and passed to PlayerListViewController
through delegation.
The sections are made with a factory pattern:
// There are three sections: Personal, Play and Likes
private func makeSections() -> [PlayerSection] {
return [
PlayerSection(
title: "Personal",
rows: [
PlayerRow(title: "Name",
value: self.player?.name ?? "",
editableField: .name),
PlayerRow(title: "Age",
value: self.player?.age != nil ? "\(self.player!.age!)" : "",
editableField: .age)
]
),
PlayerSection(
title: "Play",
rows: [
PlayerRow(title: "Preferred position",
value: self.player?.preferredPosition?.rawValue.capitalized ?? "",
editableField: .position),
PlayerRow(title: "Skill",
value: self.player?.skill?.rawValue.capitalized ?? "",
editableField: .skill)
]
),
PlayerSection(
title: "Likes",
rows: [
PlayerRow(title: "Favourite team",
value: self.player?.favouriteTeam ?? "",
editableField: .favouriteTeam)
]
)
]
}
PlayerEditViewController
Description
- Edits a player information.
UI Elements
- playerEditTextField - The field that is filled with the player’s detail we want to edit
- playerTableView - We wanted to have a similar behaviour and UI as we have in iOS General Settings for editing a details. This table view has either one row with a text field or multiple rows with a selection behaviour.
- loadingView - is used to show an indicator while a server call is made
- doneButton - An UIBarButtonItem that performs the action of editing.
Services
- Update Player API, used as a
StandardNetworkService
:
private func updatePlayer(_ player: PlayerResponseModel, completion: @escaping (Bool) -> Void) {
var service = StandardNetworkService(resourcePath: "/api/players", authenticated: true)
service.update(PlayerCreateModel(player), resourceID: ResourceID.integer(player.id)) { result in // players have an ID that is Int
if case .success(let updated) = result {
completion(updated)
} else {
completion(false)
}
}
}
Models
- viewType - An enum that can be
.text
(for player details that are entered via keyboard) or.selection
(for player details that are selected by tapping one of the cells, for example the preferred position). - player - The player we want to edit.
- items - An array of strings corresponding to all possible options for preferred positions or skill. This array is
nil
when a text entry is going to be edited.
ConfirmPlayersViewController
Description
- Before reaching the Gather screen we want to put the players in the desired teams
UI elements
- playerTableView - A table view split in three sections (Bench, Team A and Team B) that shows the selected players we want for the gather.
- startGatherButton - Initially disabled, when tapped triggers an action to perform the Network API calls required to start the gather and at last, it pushes the next screen.
- loadingView - is used to show an indicator while a server call is made.
Services
- Create Gather - Adds a new gather by making a POST request to /api/gathers.
- Add Player to Gather - After we are done with selecting teams for our players, we add them to the gather by doing a POST request to api/gathers/{gather_id}/players/{player_id}.
Models
- playersDictionary - Each team has an array of players, so the dictionary has the teams as keys (Team A, Team B or Bench) and for values we have the selected players (array of players).
When we are done with the selection (UI), a new gather is created and each player is assigned a team.
@IBAction func startGatherAction(_ sender: Any) {
showLoadingView()
// Authenticated request to create a new gather
createGather { [weak self] uuid in
guard let self = self else { return }
guard let gatherUUID = uuid else {
self.handleServiceFailure()
return
}
// Each player is added to the gather
self.addPlayersToGather(havingUUID: gatherUUID)
}
}
The for loop to add players is presented below:
private func addPlayersToGather(havingUUID gatherUUID: UUID) {
let players = self.playerTeamArray
// [1] Using a DispatchGroup so we wait all requests to finish
let dispatchGroup = DispatchGroup()
var serviceFailed = false
players.forEach { playerTeamModel in
dispatchGroup.enter()
self.addPlayer(
playerTeamModel.player,
toGatherHavingUUID: gatherUUID,
team: playerTeamModel.team,
completion: { playerWasAdded in
if !playerWasAdded {
serviceFailed = true
}
dispatchGroup.leave()
})
}
// [2] All requests finished, now update the UI
dispatchGroup.notify(queue: DispatchQueue.main) {
self.hideLoadingView()
if serviceFailed {
self.handleServiceFailure()
} else {
self.performSegue(withIdentifier: SegueIdentifiers.gather.rawValue,
sender: GatherModel(players: players, gatherUUID: gatherUUID))
}
}
}
GatherViewController
Description
- This is the core screen of the application, where you are in the gather mode and start / pause or stop the timer and in the end, finish the match.
UI elements
- playerTableView - Used to display the players in gather, split in two sections: Team A and Team B.
- scoreLabelView - A view that has two labels for displaying the score, one for Team A and the other one for Team B.
- scoreStepper - A view that has two steppers for the teams.
- timerLabel - Used to display the remaining time in the format mm:ss.
- timerView - An overlay view that has a UIPickerView to choose the time of the gather.
- timePickerView - The picker view with two components (minutes and seconds) for selecting the gather’s time.
- actionTimerButton - Different state button that manages the countdown timer (resume, pause and start).
- loadingView - is used to show an indicator while a server call is made.
Services
- Update Gather - when a gather is ended, we do a PUT request to update the winner team and the score
Models
- GatherTime - A tuple that has minutes and seconds as Int.
- gatherModel - Contains the gather ID and an array of player team model (the player response model and the team he belongs to). This is created and passed from
ConfirmPlayersViewController
. - timer - Used to countdown the minutes and seconds of the gather.
- timerState - Can have three states stopped, running and paused. We observer when one of the values is set so we can change the
actionTimerButton
’s title accordingly. When it’s paused the button’s title will be Resume. When it’s running the button’s title will be Pause and Start when the timer is stopped.
When the actionTimerButton
is tapped, we verify if we want to invalidate or start the timer:
@IBAction func actionTimer(_ sender: Any) {
// [1] Check if the user selected a time more than 1 second
guard selectedTime.minutes > 0 || selectedTime.seconds > 0 else {
return
}
switch timerState {
// [2] The timer was in a stopped or paused state. Now it becomes in a running state.
case .stopped, .paused:
timerState = .running
// [3] If it’s running, we pause it. For cancelling we have a different IBAction.
case .running:
timerState = .paused
}
if timerState == .paused {
// [4] Stop the timer
timer.invalidate()
} else {
// [5] Start the timer and call each second the selector updateTimer.
timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(updateTimer), userInfo: nil, repeats: true)
}
}
To cancel a timer we have the following action implemented:
@IBAction func cancelTimer(_ sender: Any) {
// [1] Set the state
timerState = .stopped
timer.invalidate()
// [2] Set to 10 minutes
selectedTime = Constants.defaultTime
timerView.isHidden = true
}
The selector updateTimer
is called each second:
@objc func updateTimer(_ timer: Timer) {
// [1] Before substracting a second, we verify the current timer state. If the seconds are 0, we substract the minutes.
if selectedTime.seconds == 0 {
selectedTime.minutes -= 1
selectedTime.seconds = 59
} else {
selectedTime.seconds -= 1
}
// [2] If timer reached out to 0, we stop it.
if selectedTime.seconds == 0 && selectedTime.minutes == 0 {
timerState = .stopped
timer.invalidate()
}
}
Before ending a gather we check the winner team:
guard let scoreTeamAString = scoreLabelView.teamAScoreLabel.text,
let scoreTeamBString = scoreLabelView.teamBScoreLabel.text,
let scoreTeamA = Int(scoreTeamAString),
// [1] Get the score from the labels and convert it to Ints.
let scoreTeamB = Int(scoreTeamBString) else {
return
}
//[2] Format of the score
let score = "\(scoreTeamA)-\(scoreTeamB)"
var winnerTeam: String = "None"
if scoreTeamA > scoreTeamB {
winnerTeam = "Team A"
} else if scoreTeamA < scoreTeamB {
winnerTeam = "Team B"
}
// [3] Set the winner team, by default being None.
let gather = GatherCreateModel(score: score, winnerTeam: winnerTeam)
And the service call:
private func updateGather(_ gather: GatherCreateModel, completion: @escaping (Bool) -> Void) {
guard let gatherModel = gatherModel else {
completion(false)
return
}
// [1] We are using the StandardNetworkService where we pass the UUID of the gather and the update model (winner team and score)
var service = StandardNetworkService(resourcePath: "/api/gathers", authenticated: true)
service.update(gather, resourceID: ResourceID.uuid(gatherModel.gatherUUID)) { result in
if case .success(let updated) = result {
completion(updated)
} else {
completion(false)
}
}
}
The private method updateGather
is called from endGather
:
let gather = GatherCreateModel(score: score, winnerTeam: winnerTeam)
showLoadingView()
updateGather(gather) { [weak self] gatherWasUpdated in
guard let self = self else { return }
DispatchQueue.main.async {
self.hideLoadingView()
if !gatherWasUpdated {
// [1] The server call failed, make sure we show an alert to the user.
AlertHelper.present(in: self, title: "Error update", message: "Unable to update gather. Please try again.")
} else {
guard let playerViewController = self.navigationController?.viewControllers.first(where: { $0 is PlayerListViewController }) as? PlayerListViewController else {
return
}
// [2] The PlayerListViewController is in a selection mode state. We make sure we turn it back to .list.
playerViewController.toggleViewState()
// [3] we don’t need the previous screen, PlayerConfirmViewController
self.navigationController?.popToViewController(playerViewController, animated: true)
}
}
}
Testing our business logic
We saw a first iteration of applying MVC to the demo app FootbalGather. Of course, we can refactor the code and make it better and decouple some of the logic, split it into different classes, but for the sake of the exercise we are going to keep this version of the codebase.
Let’s see how we can write unit tests for our classes. We are going to exemplify for GatherViewController
and try to reach close to 100% code coverage.
First, we see GatherViewController
is part of Main storyboard
. To make our lives easier, we use an identifier and instantiate it with the method storyboard.instantiateViewController
. Let’s use the setUp
method for this logic:
final class GatherViewControllerTests: XCTestCase {
var sut: GatherViewController!
override func setUp() {
super.setUp()
let storyboard = UIStoryboard(name: "Main", bundle: nil)
if let viewController = storyboard.instantiateViewController(identifier: "GatherViewController") as? GatherViewController {
sut = viewController
sut.gatherModel = gatherModel
_ = sut.view
} else {
XCTFail("Unable to instantiate GatherViewController")
}
}
//…
}
For our first test, we verify all outlets are not nil:
func testOutlets_whenViewControllerIsLoadedFromStoryboard_areNotNil() {
XCTAssertNotNil(sut.playerTableView)
XCTAssertNotNil(sut.scoreLabelView)
XCTAssertNotNil(sut.scoreStepper)
XCTAssertNotNil(sut.timerLabel)
XCTAssertNotNil(sut.timerView)
XCTAssertNotNil(sut.timePickerView)
XCTAssertNotNil(sut.actionTimerButton)
}
Now let’s see if viewDidLoad
is called. The title is set and some properties are configured. We verify the public parameters:
func testViewDidLoad_whenViewControllerIsLoadedFromStoryboard_setsVariables() {
XCTAssertNotNil(sut.title)
XCTAssertTrue(sut.timerView.isHidden)
XCTAssertNotNil(sut.timePickerView.delegate)
}
The variable timerView
is a pop-up custom view where users set their match timer.
Moving forward let’s unit test our table view methods:
func testNumberOfSections_whenGatherModelIsSet_returnsTwoTeams() {
XCTAssert(sut.playerTableView?.numberOfSections == Team.allCases.count - 1)
}
We have just two teams: Team A and Team B. The Bench team is not visible and not part of this screen.
func testTitleForHeaderInSection_whenSectionIsTeamAAndGatherModelIsSet_returnsTeamATitleHeader() {
let teamASectionTitle = sut.tableView(sut.playerTableView, titleForHeaderInSection: 0)
XCTAssertEqual(teamASectionTitle, Team.teamA.headerTitle)
}
func testTitleForHeaderInSection_whenSectionIsTeamBAndGatherModelIsSet_returnsTeamBTitleHeader() {
let teamBSectionTitle = sut.tableView(sut.playerTableView, titleForHeaderInSection: 1)
XCTAssertEqual(teamBSectionTitle, Team.teamB.headerTitle)
}
Our tableview should have two sections with both header titles being set to the team names (Team A and Team B).
For checking the number of rows, we inject a mocked gather model:
private let gatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 4)
static func makeGatherModel(numberOfPlayers: Int, gatherUUID: UUID = ModelsMock.gatherUUID) -> GatherModel {
let allSkills = PlayerSkill.allCases
let allPositions = PlayerPosition.allCases
var playerTeams: [PlayerTeamModel] = []
(1...numberOfPlayers).forEach { index in
let skill = allSkills[Int.random(in: 0..<allSkills.count)]
let position = allPositions[Int.random(in: 0..<allPositions.count)]
let team: Team = index % 2 == 0 ? .teamA : .teamB
let playerResponseModel = makePlayerResponseModel(id: index, name: "Player \(index)", age: 20 + index, favouriteTeam: "Fav team \(index)", skill: skill, preferredPosition: position)
let playerTeamModel = PlayerTeamModel(team: team, player: playerResponseModel)
playerTeams.append(playerTeamModel)
}
return GatherModel(players: playerTeams, gatherUUID: gatherUUID)
}
And the unit tests for verifying the number of rows in sections:
func testNumberOfRowsInSection_whenTeamIsA_returnsNumberOfPlayersInTeamA() {
let expectedTeamAPlayersCount = gatherModel.players.filter { $0.team == .teamA }.count
XCTAssertEqual(sut.playerTableView.numberOfRows(inSection: 0), expectedTeamAPlayersCount)
}
func testNumberOfRowsInSection_whenTeamIsB_returnsNumberOfPlayersInTeamB() {
let expectedTeamBPlayersCount = gatherModel.players.filter { $0.team == .teamB }.count
XCTAssertEqual(sut.playerTableView.numberOfRows(inSection: 1), expectedTeamBPlayersCount)
}
Nil scenario when the section is invalid.
func testNumberOfRowsInSection_whenGatherModelIsNil_returnsZero() {
sut.gatherModel = nil
XCTAssertEqual(sut.tableView(sut.playerTableView, numberOfRowsInSection: -1), 0)
}
For displaying the player details we use the normal table view cells, setting the textLabel
with the player’s name and the detailTextLabel
with the player’s preferred position.
Let’s verify these properties are set:
func testCellForRowAtIndexPath_whenSectionIsTeamA_setsCellDetails() {
let indexPath = IndexPath(row: 0, section: 0)
let playerTeams = gatherModel.players.filter({ $0.team == .teamA })
let player = playerTeams[indexPath.row].player
let cell = sut.playerTableView.cellForRow(at: indexPath)
XCTAssertEqual(cell?.textLabel?.text, player.name)
XCTAssertEqual(cell?.detailTextLabel?.text, player.preferredPosition?.acronym)
}
func testCellForRowAtIndexPath_whenSectionIsTeamB_setsCellDetails() {
let indexPath = IndexPath(row: 0, section: 1)
let playerTeams = gatherModel.players.filter({ $0.team == .teamB })
let player = playerTeams[indexPath.row].player
let cell = sut.playerTableView.cellForRow(at: indexPath)
XCTAssertEqual(cell?.textLabel?.text, player.name)
XCTAssertEqual(cell?.detailTextLabel?.text, player.preferredPosition?.acronym)
}
Great! We’ve now successfully tested our model. It wasn’t that hard, but we had to mock stuff, use storyboards and check the table view delegate and data source, a lot of UI stuff for verifying the business logic.
We continue with the pickerView
methods.
func testPickerViewNumberOfComponents_returnsAllCountDownCases() {
XCTAssertEqual(sut.timePickerView.numberOfComponents, GatherViewController.GatherCountDownTimerComponent.allCases.count)
}
The only way to verify the number of components is if we make the enum GatherCountDownTimerComponent
public.
Testing the number of rows in component is somehow similar with what we did for our players tableView
.
func testPickerViewNumberOfRowsInComponent_whenComponentIsMinutes_returns60() {
let minutesComponent = GatherViewController.GatherCountDownTimerComponent.minutes.rawValue
let numberOfRows = sut.pickerView(sut.timePickerView, numberOfRowsInComponent: minutesComponent)
XCTAssertEqual(numberOfRows, 60)
}
func testPickerViewNumberOfRowsInComponent_whenComponentIsSecounds() {
let secondsComponent = GatherViewController.GatherCountDownTimerComponent.seconds.rawValue
let numberOfRows = sut.pickerView(sut.timePickerView, numberOfRowsInComponent: secondsComponent)
XCTAssertEqual(numberOfRows, 60)
}
The rows should equal with the number of minutes and seconds. And the title for rows:
func testPickerViewTitleForRow_whenComponentIsMinutes_containsMin() {
let minutesComponent = GatherViewController.GatherCountDownTimerComponent.minutes.rawValue
let title = sut.pickerView(sut.timePickerView, titleForRow: 0, forComponent: minutesComponent)
XCTAssertTrue(title!.contains("min"))
}
func testPickerViewTitleForRow_whenComponentIsSeconds_containsSec() {
let secondsComponent = GatherViewController.GatherCountDownTimerComponent.seconds.rawValue
let title = sut.pickerView(sut.timePickerView, titleForRow: 0, forComponent: secondsComponent)
XCTAssertTrue(title!.contains("sec"))
}
Let’s see how we test the IBActions.
The setTimer
action configures the picker view (it sets the picker view components to the selected minutes and seconds. If not, it defaults to 10 minutes) and hides the timerView
overlay.
The only public property we have is the timerView
. We call setTimer
and verify timerView
is not hidden:
func testSetTimer_whenActionIsSent_showsTimerView() {
sut.setTimer(UIButton())
XCTAssertFalse(sut.timerView.isHidden)
}
We replicate the same logic for cancelTimer
, but this time our timerView
should be hidden:
func testCancelTimer_whenActionIsSent_hidesTimerView() {
sut.cancelTimer(UIButton())
XCTAssertTrue(sut.timerView.isHidden)
}
And the similar method for the overlay button from the timerView
popup:
func testTimerCancel_whenActionIsSent_hidesTimerView() {
sut.timerCancel(UIButton())
XCTAssertTrue(sut.timerView.isHidden)
}
Things are getting harder and harder:
func testTimerDone_whenActionIsSent_hidesTimerViewAndSetsMinutesAndSeconds() {
sut.timerDone(UIButton())
let minutes = sut.timePickerView.selectedRow(inComponent: GatherViewController.GatherCountDownTimerComponent.minutes.rawValue)
let seconds = sut.timePickerView.selectedRow(inComponent: GatherViewController.GatherCountDownTimerComponent.seconds.rawValue)
XCTAssertTrue(sut.timerView.isHidden)
XCTAssertGreaterThan(minutes, 0)
XCTAssertEqual(seconds, 0)
}
We verify the timerView
is hidden and the minutes and seconds are set. We don’t have access to the ViewController
’s default time, so we use XCTAssertGreaterThan
for minutes component.
Let’s see if our timer reacts to row changes:
func testActionTimer_whenSelectedTimeIsZero_returns() {
let minutesComponent = GatherViewController.GatherCountDownTimerComponent.minutes.rawValue
let secondsComponent = GatherViewController.GatherCountDownTimerComponent.seconds.rawValue
sut.timePickerView.selectRow(0, inComponent: minutesComponent, animated: false)
sut.timePickerView.selectRow(0, inComponent: secondsComponent, animated: false)
sut.timerDone(UIButton())
sut.actionTimer(UIButton())
XCTAssertEqual(sut.timePickerView.selectedRow(inComponent: minutesComponent), 0)
XCTAssertEqual(sut.timePickerView.selectedRow(inComponent: secondsComponent), 0)
}
In this test, we select the first rows, so the time will be 00:00. We then call timerDone
method and actionTimer
. It will hit the guard statement:
guard selectedTime.minutes > 0 || selectedTime.seconds > 0 else {
return
}
Now, testing the happy path:
func testActionTimer_whenSelectedTimeIsSet_updatesTimer() {
// given
let minutesComponent = GatherViewController.GatherCountDownTimerComponent.minutes.rawValue
let secondsComponent = GatherViewController.GatherCountDownTimerComponent.seconds.rawValue
sut.timePickerView.selectRow(0, inComponent: minutesComponent, animated: false)
// set to 1 second
sut.timePickerView.selectRow(1, inComponent: secondsComponent, animated: false)
// initial state
sut.timerDone(UIButton())
XCTAssertEqual(sut.actionTimerButton.title(for: .normal), "Start")
// when
sut.actionTimer(UIButton())
// then
XCTAssertEqual(sut.actionTimerButton.title(for: .normal), "Pause")
// make sure timer is resetted
let exp = expectation(description: "Timer expectation")
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
XCTAssertEqual(self.sut.actionTimerButton.title(for: .normal), "Start")
exp.fulfil()
}
waitForExpectations(timeout: 5, handler: nil)
}
We check if actionTimerButton
has the title Start when we are in an initial state. After we call actionTimer
, the actionTimerButton
should now have the title Pause (because the timer is counting and the match has started).
We set the seconds component to one. So, if we are dispatching after two seconds, we should have stopped, the timer should get invalidated and the actionTimerButton
should be back to its initial state having the title Start.
To wait for the expectation fulfil, we use waitForExpectations
, with a 5 seconds timeout.
Checking the switch between Resume and Pause is similar:
func testActionTimer_whenTimerIsSetAndRunning_isPaused() {
// given
let sender = UIButton()
let minutesComponent = GatherViewController.GatherCountDownTimerComponent.minutes.rawValue
let secondsComponent = GatherViewController.GatherCountDownTimerComponent.seconds.rawValue
sut.timePickerView.selectRow(0, inComponent: minutesComponent, animated: false)
sut.timePickerView.selectRow(3, inComponent: secondsComponent, animated: false)
// initial state
sut.timerDone(sender)
XCTAssertEqual(sut.actionTimerButton.title(for: .normal), "Start")
sut.actionTimer(sender)
// when
let exp = expectation(description: "Timer expectation")
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.sut.actionTimer(sender)
XCTAssertEqual(self.sut.actionTimerButton.title(for: .normal), "Resume")
exp.fulfil()
}
waitForExpectations(timeout: 5, handler: nil)
}
We set a longer period of time for seconds component (3 seconds). We call actionTimer
to start and after one second, we call the function again. The match should be paused and the actionTimerButton
should have the Resume title.
To check if the selector gets called, we can verify the timerLabel
text:
func testUpdateTimer_whenSecondsReachZero_decrementsMinuteComponent() {
let sender = UIButton()
let timer = Timer()
let minutesComponent = GatherViewController.GatherCountDownTimerComponent.minutes.rawValue
let secondsComponent = GatherViewController.GatherCountDownTimerComponent.seconds.rawValue
sut.timePickerView.selectRow(1, inComponent: minutesComponent, animated: false)
sut.timePickerView.selectRow(0, inComponent: secondsComponent, animated: false)
sut.timerDone(sender)
XCTAssertEqual(sut.timerLabel.text, "01:00")
sut.updateTimer(timer)
XCTAssertEqual(sut.timerLabel.text, "00:59")
}
In this test we checked if the seconds are decremented when the minutes component is reaching zero.
Having access to the outlets, we can easily verify the stepperDidChangeValue
delegates:
func testStepperDidChangeValue_whenTeamAScores_updatesTeamAScoreLabel() {
sut.scoreStepper.teamAStepper.value = 1
sut.scoreStepper.teamAStepperValueChanged(UIButton())
XCTAssertEqual(sut.scoreLabelView.teamAScoreLabel.text, "1")
XCTAssertEqual(sut.scoreLabelView.teamBScoreLabel.text, "0")
}
func testStepperDidChangeValue_whenTeamBScores_updatesTeamBScoreLabel() {
sut.scoreStepper.teamBStepper.value = 1
sut.scoreStepper.teamBStepperValueChanged(UIButton())
XCTAssertEqual(sut.scoreLabelView.teamAScoreLabel.text, "0")
XCTAssertEqual(sut.scoreLabelView.teamBScoreLabel.text, "1")
}
func testStepperDidChangeValue_whenTeamIsBench_scoreIsNotUpdated() {
sut.stepper(UIStepper(), didChangeValueForTeam: .bench, newValue: 1)
XCTAssertEqual(sut.scoreLabelView.teamAScoreLabel.text, "0")
XCTAssertEqual(sut.scoreLabelView.teamBScoreLabel.text, "0")
}
Finally, the hardest and probably the most important method we have in GatherViewController
is the endGather
method. Here, we do a service call updating the gather model. We pass the winnerTeam
and the score of the match.
It is a big method, does more than one thing and is private. (we use it as per example, functions should not be big and functions should do one thing!).
The responsibilities of this function are detailed below. endGather
does the following:
- gets the score from
scoreLabelViews
- computes the winner team by comparing the score
- creates the
GatherModel
for the service call - shows a loading spinner
- does the
updateGather
service call - hides the loading spinner
- handles success and failure
- for success, the view controller is popped to
PlayerListViewController
(this view should be in the stack) - for failure, it presents an alert
How we should test all of that? (Again, as best practice, this function should be splitted down into multiple functions).
Let’s take one step at a time.
Creating a mocked service and injecting it in our sut:
private let session = URLSessionMockFactory.makeSession()
private let resourcePath = "/api/gathers"
private let appKeychain = AppKeychainMockFactory.makeKeychain()
let endpoint = EndpointMockFactory.makeSuccessfulEndpoint(path: resourcePath)
sut.updateGatherService = StandardNetworkService(session: session, urlRequest: AuthURLRequestFactory(endpoint: endpoint, keychain: appKeychain))
Testing the success handler. We use a protocol instead of the concrete class PlayerListViewController
and we mock it in our test class:
protocol PlayerListTogglable {
func toggleViewState()
}
class PlayerListViewController: UIViewController, PlayerListTogglable { .. }
private extension GatherViewControllerTests {
final class MockPlayerTogglableViewController: UIViewController, PlayerListTogglable {
weak var viewStateExpectation: XCTestExpectation?
private(set) var viewState = true
func toggleViewState() {
viewState = !viewState
viewStateExpectation?.fulfil()
}
}
}
This should be part of a navigation controller:
let playerListViewController = MockPlayerTogglableViewController()
let window = UIWindow()
let navController = UINavigationController(rootViewController: playerListViewController)
window.rootViewController = navController
window.makeKeyAndVisible()
_ = playerListViewController.view
XCTAssertTrue(playerListViewController.viewState)
let exp = expectation(description: "Timer expectation")
playerListViewController.viewStateExpectation = exp
navController.pushViewController(sut, animated: false)
We check the initial viewState
. It should be true.
The rest of the unit test is presented below:
// mocked endpoint expects a 1-1 score
sut.scoreLabelView.teamAScoreLabel.text = "1"
sut.scoreLabelView.teamBScoreLabel.text = "1"
let endpoint = EndpointMockFactory.makeSuccessfulEndpoint(path: resourcePath)
sut.updateGatherService = StandardNetworkService(session: session, urlRequest: AuthURLRequestFactory(endpoint: endpoint, keychain: appKeychain))
sut.endGather(UIButton())
let alertController = (sut.presentedViewController as! UIAlertController)
alertController.tapButton(atIndex: 0)
waitForExpectations(timeout: 5) { _ in
XCTAssertFalse(playerListViewController.viewState)
}
We prepare our mocked endpoint and call endGather
.
A confirmation dialog should appear on screen (UIAlertController
). We tap OK to end our gather.
Using waitForExpectation
we wait for the closure to be executed and we verify the success path; viewState
should now be false.
Because endGather
is a private method, we had to use the IBAction
that calls this method. And for tapping on OK in the alert controller that was presented we had to use its private API:
private extension UIAlertController {
typealias AlertHandler = @convention(block) (UIAlertAction) -> Void
func tapButton(atIndex index: Int) {
guard let block = actions[index].value(forKey: "handler") else { return }
let handler = unsafeBitCast(block as AnyObject, to: AlertHandler.self)
handler(actions[index])
}
}
We don’t have the guarantee this unit test will work in future Swift versions. This is bad.
Key Metrics
Lines of code
File | Number of lines of code |
---|---|
PlayerAddViewController | 79 |
PlayerListViewController | 387 |
PlayerDetailViewController | 204 |
LoginViewController | 126 |
PlayerEditViewController | 212 |
GatherViewController | 359 |
ConfirmPlayersViewController | 260 |
TOTAL | 1627 |
Unit Tests
Topic | Data |
---|---|
Number of key classes (the ViewControllers) | 7 |
Key Class | GatherViewController |
Number of Unit Tests | 30 |
Code Coverage of Gathers feature | 95.7% |
How hard to write unit tests | 5/5 |
Build Times
Build | Time (sec)* |
---|---|
Average Build Time (after clean Derived Data & Clean Build) | 9.78 |
Average Build Time | 0.1 |
Average Unit Test Execution Time (after clean Derived Data & Clean Build) | 12.78 |
* tests were run on an 8-Core Intel Core i9, MacBook Pro, 2019
Conclusion
MVC is the most known architecture pattern when it comes to iOS Development.
In this first article, we saw it in action, being applied to a small application. We used the naive approach, where each screen is represented by a View Controller.
In the real world, you should not follow this approach for a screen that has a lot actions, you should split the responsibilities. One solution is to use child view controllers.
We also took each screen of the app and explained what is their role, provided a small description, what UI elements are part of it, the model which the controller interacts with and we saw code snippets of the key methods.
Finally, we described the feel of writing unit tests of the key class, GatherViewController
. It wasn't as easy as we hoped, we even had to use a private method of UIAlertController
, which is bad practice. Apple might change the public API of this class in a future release, breaking our unit test
However, used well, MVC is really cool and really great when it comes to iOS app development.
We can't say much by looking at the key metrics yet, we will need to see how the other patterns do. We can guess that the number of lines of code and classes is much smaller in MVC. The other patterns introduce more layers, thus more lines of code.
Thanks for staying until the end! Some useful links below.
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 |