iOS Architecture Patterns 뿌시기: View Interactor Presenter (VIP)
Thanks to Radu Dan for allowing the translation.
Reference: Battle of the iOS Architecture Patterns: View Interactor Presenter (VIP)
본 글은 위의 내용에 대한 번역본입니다.
순서는 다음과 같습니다.
iOS Architecture Patterns 뿌시기: View Interactor Presenter (VIP)
아키텍처 시리즈 — View Interactor Presenter (VIP)
동기 (Motivation)
iOS 앱을 개발하기 전에 프로젝트의 구조에 대해 생각해야 합니다. 나중에 앱의 일부분을 다시 볼 때 코드의 조각을 추가하는 방법과 다른 개발자들과 “언어” 라고 알려진 형식을 고려해야 합니다.
“아키텍처 패턴” 시리즈의 마지막 글이고 Football Gather 애플리케이션을 어떻게 VIP 로 구현할 수 있는지 살펴 보겠습니다.
다른 글을 놓쳤다면 아래 내용을 확인하거나 이 글 마지막에 링크에서 확인할 수 있습니다.
- Model View Controller (MVC) — link here
- Model View ViewModel (MVVM) — link here
- Model View Presenter (MVP) — link here
- Model View Presenter with Coordinators (MVP-C) — link here
- View Interactor Presenter Entity Router (VIPER) — link here
바로 코드가 보고 싶으신가요? 걱정하지 마세요! GitHub 에서 코드를 확인할 수 있습니다.
다른 글에서와 동일하게 먼저 이 패턴에 대한 약간의 설명과 왜 유용한지에 대해 살펴 보겠습니다. 그런 다음에 실제 구현을 해보도록 합니다. 마지막으로 컴파일과 빌드 시간에 대해 몇가지 정보를 보여주고 유닛 테스트를 작성하는게 얼마나 쉬운지 확인하고 결론을 설명합니다.
iOS 앱에 아키텍처 패턴이 필요한 이유? (Why an Architecture Pattern for Your iOS App?)
고려해야 할 가장 중요한 것은 유지될 수 있는 앱을 가지는 것입니다. View 가 있다는 것을 알고 View Controller 가 Y 가 아닌 X 를 수행해야 한다는 것도 알고 있습니다. 더 중요한 것은 다른 사람들도 알고 있다는 것입니다.
좋은 아키텍처 패턴을 고르는 것에 대한 이점은 다음과 같습니다:
- 유지보수가 쉬움
- 비지니스 로직을 테스트하기 쉬움
- 다른 팀원들과 공통 언어로 개발
- 역할 분리 (각 기능별 분리)
- 더 적은 버그
요구사항 정의 (Defining the Requirements)
6개 또는 7개의 화면을 가진 iOS 애플리케이션이 주어지고 iOS 세계에서 가장 유명한 아키텍처 패턴을 사용하여 개발할 것입니다: MVC, MVVM, MVP, VIPER, VIP, 그리고 Coordinators.
데모앱은 Football Gather 라고 불리며 아마추어 축구 경기의 점수를 추적하는 간단한 앱입니다.
주요 요소 (Main features)
가능한 것:
- 앱에 선수 추가
- 선수에게 팀 할당
- 선수 수정
- 경기에 대한 카운트다운 타이머 설정
화면 목업 (Screen mockups)
iOS 앱인 “Football Gather” 에 화면 목업
백엔드 (Backend)
이 앱은 Vapor web framework 로 개발된 웹 앱으로 구동됩니다. Vapor 3 initial article 와 article about Migrating to Vapor 4 에서 앱을 확인할 수 있습니다.
VIP 처럼 코드 정리하기 (Cleaning your code like a VIP)
VIP 는 일반적으로 사용되는 아키텍처 패턴이 아닙니다. Raymond Law 에 의해 개발되었으며 iOS 프로젝트에 적용되는 Uncle Bob 의 Clean Architecture 버전입니다. 더 많은 정보는 여기를 참고하세요: https://clean-swift.com/.
VIP 의 주 목표는 MVC 에서 가지고 있는 무거운 ViewController 문제를 해결하는 것입니다. VIP 는 다른 아키텍처 패턴의 문제에 대한 대안도 제공하려고 합니다. 예를 들어 VIPER 는 중심에 Presenter 를 가집니다. VIP 는 단방향 제어를 사용하여 플로우를 단순화 하고 레이어를 통해 메서드를 더 쉽게 호출할 수 있습니다.
VIP 는 앱 제어를 VIP 사이클로 변환하고 단방향 제어 플로우를 제공합니다.
VIP 적용 시나리오의 예제입니다:
- 사용자는 선수의 목록을 조회하기 위해 버튼을 탭합니다. 이것은 ViewController 에 있습니다.
IBAction
은 Interactor 에서 메서드를 호출합니다.- Interactor 는 요청을 변환하고 서버에서 선수의 목록을 가져오는 것과 같은 약간의 비지니스 로직을 수행하고 사용자에게 나타낼 수 있는 응답으로 변환하기 위해 Presenter 를 호출합니다.
- Presenter 는 화면에 선수를 표시하기 위해 ViewController 를 호출합니다.
아키텍처 컴포넌트는 아래 자세히 나와있습니다.
View/ViewController
두가지 기능을 가집니다: Interactor 에 요청을 보내고 Presenter 로 부터 온 정보를 수행하고 표시합니다.
Interactor
“새로운” Presenter. 이 레이어는 데이터를 조회하고, 에러를 처리하고, 항목을 계산하기 위해 네트워크 호출과 같은 작업을 수행하는 VIP 아키텍처의 핵심입니다.
Worker
Football Gather 에서는 “Service” 라는 이름으로 사용되지만 기본적으로 같습니다. Worker 는 Interactor 의 일부 책임을 가지고 네트워크 호출 또는 데이터베이스 요청을 처리합니다.
Presenter
Interactor 로 부터 온 데이터를 처리하고 View 에 표시하기 위해 적절한 ViewModel 로 변환합니다.
Router
VIPER 에서와 동일한 역할을 가지며 화면 이동을 처리합니다.
Models
다른 패턴과 유사하게 Model 레이어는 데이터를 캡슐화 하기위해 사용됩니다.
통신 (Communication)
ViewController 는 Router 와 Interactor 와 통신합니다.
Interactor 는 Presenter 로 데이터를 보냅니다. Worker 로 부터 이벤트를 보내고 받을 수도 있습니다.
Presenter 는 Interactor 로 부터 온 응답을 ViewModel 로 변환하고 View/ViewController 로 전달합니다.
장점 (Advantages)
- MVC 에 있는 무거운 ViewController 문제를 더이상 가지지 않습니다.
- MVVM 을 잘못 사용하면 대신 무거운 ViewModel 을 가질 수 있습니다.
- VIP 사이클로 VIPER 의 제어 문제를 해결합니다.
- VIPER 를 잘못 사용하면 무거운 Presenter 를 가질 수 있습니다.
- 저자들은 이것이 Clean Architecture 원칙을 따른다고 말합니다.
- 복잡한 비지니스 로직을 가지는 경우 Worker 컴포넌트로 이동할 수 있습니다.
- 유닛 테스트와 TDD 사용이 매우 쉽습니다.
- 모듈화 하기에 좋습니다.
- 디버그 하기에 쉽습니다.
단점 (Disadvantages)
- 레이어가 너무 많아 코드 생성기를 사용하지 않으면 지루해 질 수 있습니다.
- 간단한 액션에도 많은 코드를 작성합니다.
- 작은 앱에서는 적합하지 않습니다.
- 일부 컴포넌트는 앱 사용 케이스에 따라 중복될 수 있습니다.
- 앱 시작이 약간 증가합니다.
VIP vs VIPER
- VIP 에서 Interactor 는 이제 ViewController 와 상호작용하는 레이어입니다.
- ViewController 는 VIP 에서 Router 에 대한 참조를 유지합니다.
- 잘못 사용하면 VIPER 는 무거운 Presenter 가 생길 수 있습니다.
- VIP 는 제어의 단방향 플로우를 가집니다.
- 서비스는 VIP 에서 Worker 라고 합니다.
코드에 적용하기 (Applying to our code)
VIPER 에서 VIP 로 앱을 변환하는 것은 생각하는 것만큼 쉽지 않을 수 있습니다. Presenter 를 Interactor 로 변환하는 것으로 시작할 수 있습니다. 다음으로 Presenter 에서 Router 를 추출하고 ViewController 에 통합할 수 있습니다.
VIPER 에서 수행했던 모듈 조립 로직을 유지합니다.
Login
scene
화면으로 이동합니다. Login 화면으로 시작합시다.
final class LoginViewController: UIViewController, LoginViewable {
// MARK: - Properties
@IBOutlet private weak var usernameTextField: UITextField!
@IBOutlet private weak var passwordTextField: UITextField!
@IBOutlet private weak var rememberMeSwitch: UISwitch!
lazy var loadingView = LoadingView.initToView(view)
var interactor: LoginInteractorProtocol = LoginInteractor()
var router: LoginRouterProtocol = LoginRouter()
// MARK: - View life cycle
override func viewDidLoad() {
super.viewDidLoad()
loadCredentials()
}
private func loadCredentials() {
let request = Login.LoadCredentials.Request()
interactor.loadCredentials(request: request)
}
// ...
}
보시다 시피 View 가 로드 되었음을 Presenter 에 더이상 말하지 않습니다. 이제 자격증명을 로드하기 위해 Interactor 로 요청합니다.
IBAction
는 아래와 같이 수정됩니다:
final class LoginViewController: UIViewController, LoginViewable {
// ...
@IBAction private func login(_ sender: Any) {
showLoadingView()
let request = Login.Authenticate.Request(username: usernameTextField.text,
password: passwordTextField.text,
storeCredentials: rememberMeSwitch.isOn)
interactor.login(request: request)
}
@IBAction private func register(_ sender: Any) {
showLoadingView()
let request = Login.Authenticate.Request(username: usernameTextField.text,
password: passwordTextField.text,
storeCredentials: rememberMeSwitch.isOn)
interactor.register(request: request)
}
// ...
}
로딩 View 를 시작하고 text field 의 사용자 이름, 암호와 사용자 이름 기억에 대한 UISwitch
의 상태를 포함하여 Interactor 에 요청을 구성합니다.
다음으로 viewDidLoad
UI 업데이트 처리를 LoginViewConfigurable
프로토콜을 통해 처리합니다:
extension LoginViewController: LoginViewConfigurable {
func displayStoredCredentials(viewModel: Login.LoadCredentials.ViewModel) {
rememberMeSwitch.isOn = viewModel.rememberMeIsOn
usernameTextField.text = viewModel.usernameText
}
}
마지막으로 로직 서비스 호출이 완료되면 Presenter 에서 다음 메서드를 호출합니다:
func loginCompleted(viewModel: Login.Authenticate.ViewModel) {
hideLoadingView()
if viewModel.isSuccessful {
router.showPlayerList()
} else {
handleError(title: viewModel.errorTitle!, message: viewModel.errorDescription!)
}
}
Interactor 는 VIPER 아키텍처의 Interactor 와 유사해 보입니다. 이것은 같은 의존성을 가집니다:
final class LoginInteractor: LoginInteractable {
var presenter: LoginPresenterProtocol
private let loginService: LoginService
private let usersService: StandardNetworkService
private let userDefaults: FootballGatherUserDefaults
private let keychain: FootbalGatherKeychain
init(presenter: LoginPresenterProtocol = LoginPresenter(),
loginService: LoginService = LoginService(),
usersService: StandardNetworkService = StandardNetworkService(resourcePath: "/api/users"),
userDefaults: FootballGatherUserDefaults = .shared,
keychain: FootbalGatherKeychain = .shared) {
self.presenter = presenter
self.loginService = loginService
self.usersService = usersService
self.userDefaults = userDefaults
self.keychain = keychain
}
}
여기서 핵심은 초기화 구문을 통해 Presenter 를 주입하고 더이상 weak 변수가 아닙니다.
자격증명 로딩은 아래에 나타나 있습니다. 먼저 ViewController 로 부터 요청을 받습니다. Presenter 에 대한 응답을 생성하고 presentCredentials(response: response)
함수를 호출합니다.
func loadCredentials(request: Login.LoadCredentials.Request) {
let rememberUsername = userDefaults.rememberUsername ?? true
let username = keychain.username
let response = Login.LoadCredentials.Response(rememberUsername: rememberUsername, username: username)
presenter.presentCredentials(response: response)
}
로그인과 등록 메서드는 네트워크 서비스 (Worker) 를 제외하고 동일합니다.
func login(request: Login.Authenticate.Request) {
guard let username = request.username, let password = request.password else {
let response = Login.Authenticate.Response(error: .missingCredentials)
presenter.authenticationCompleted(response: response)
return
}
let requestModel = UserRequestModel(username: username, password: password)
loginService.login(user: requestModel) { [weak self] result in
DispatchQueue.main.async {
switch result {
case .failure(let error):
let response = Login.Authenticate.Response(error: .loginFailed(error.localizedDescription))
self?.presenter.authenticationCompleted(response: response)
case .success(_):
guard let self = self else { return }
self.updateCredentials(username: username, shouldStore: request.storeCredentials)
let response = Login.Authenticate.Response(error: nil)
self.presenter.authenticationCompleted(response: response)
}
}
}
}
private func updateCredentials(username: String, shouldStore: Bool) {
keychain.username = shouldStore ? username : nil
userDefaults.rememberUsername = shouldStore
}
Presenter 는 Router 또는 Interactor 에 참조를 유지하지 않습니다. VIP 사이클을 완료하기 위해 weak 로 가지며 순환 참조 (retain cycle) 을 가지지 않는 View 의 의존성만 유지합니다.
Presenter 는 매우 간단하고 public API 의 두개 메서드를 노출합니다:
func presentCredentials(response: Login.LoadCredentials.Response) {
let viewModel = Login.LoadCredentials.ViewModel(rememberMeIsOn: response.rememberUsername,
usernameText: response.username)
view?.displayStoredCredentials(viewModel: viewModel)
}
func authenticationCompleted(response: Login.Authenticate.Response) {
guard response.error == nil else {
handleServiceError(response.error)
return
}
let viewModel = Login.Authenticate.ViewModel(isSuccessful: true, errorTitle: nil, errorDescription: nil)
view?.loginCompleted(viewModel: viewModel)
}
private func handleServiceError(_ error: LoginError?) {
switch error {
case .missingCredentials:
let viewModel = Login.Authenticate.ViewModel(isSuccessful: false,
errorTitle: "Error",
errorDescription: "Both fields are mandatory.")
view?.loginCompleted(viewModel: viewModel)
case .loginFailed(let message), .registerFailed(let message):
let viewModel = Login.Authenticate.ViewModel(isSuccessful: false,
errorTitle: "Error",
errorDescription: String(describing: message))
view?.loginCompleted(viewModel: viewModel)
default:
break
}
}
Router 레이어는 동일하게 유지됩니다.
Module 조립에 약간의 업데이트를 적용합니다:
extension LoginModule: AppModule {
func assemble() -> UIViewController? {
presenter.view = view
interactor.presenter = presenter
view.interactor = interactor
view.router = router
return view as? UIViewController
}
}
PlayerList
scene
다음으로 PlayerList
화면으로 이동합니다.
ViewController
는 유사한 방법으로 변환됩니다 - Presenter 는 Interactor 에 의해 대체되고 이제 Router 에 대한 참조를 유지합니다.
VIP 에 흥미로운 측면은 ViewController 내부에 ViewModel 의 배열을 가질 수 있다는 것입니다:
var interactor: PlayerListInteractorProtocol = PlayerListInteractor()
var router: PlayerListRouterProtocol = PlayerListRouter()
private var displayedPlayers: [PlayerList.FetchPlayers.ViewModel.DisplayedPlayer] = []
Presenter 에 View 가 로드되었음을 더이상 말하지 않습니다. ViewController 는 초기화 상태에서 UI 요소를 구성합니다.
override func viewDidLoad() {
super.viewDidLoad()
setupView()
fetchPlayers()
}
private func setupView() {
configureTitle("Players")
setupBarButtonItem(title: "Select")
setBarButtonState(isEnabled: false)
setupTableView()
}
유사하게 Login 에서 IBAction
은 요청을 구성하고 Interactor 내에서 메서드를 호출합니다.
// MARK: - Selectors
@objc private func selectPlayers() {
let request = PlayerList.SelectPlayers.Request()
interactor.selectPlayers(request: request)
}
@IBAction private func confirmOrAddPlayers(_ sender: Any) {
let request = PlayerList.ConfirmOrAddPlayers.Request()
interactor.confirmOrAddPlayers(request: request)
}
데이터를 조회해 표시할 준비가 되면 Presenter 는 displayFetchedPlayers
ViewController 에서 메서드를 호출합니다.
func displayFetchedPlayers(viewModel: PlayerList.FetchPlayers.ViewModel) {
displayedPlayers = viewModel.displayedPlayers
showEmptyViewIfRequired()
setBarButtonState(isEnabled: !playersCollectionIsEmpty)
reloadData()
}
Table View 의 데이터 소스는 아래와 같이 볼 수 있습니다:
extension PlayerListViewController: UITableViewDelegate, UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
displayedPlayers.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell: PlayerTableViewCell = tableView.dequeueReusableCell(withIdentifier: "PlayerTableViewCell") as? PlayerTableViewCell else {
return UITableViewCell()
}
let displayedPlayer = displayedPlayers[indexPath.row]
cell.set(nameDescription: displayedPlayer.name)
cell.set(positionDescription: "Position: \(displayedPlayer.positionDescription ?? "-")")
cell.set(skillDescription: "Skill: \(displayedPlayer.skillDescription ?? "-")")
cell.set(isSelected: displayedPlayer.isSelected)
cell.set(isListView: isInListViewMode)
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let request = PlayerList.SelectPlayer.Request(index: indexPath.row)
interactor.selectRow(request: request)
}
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
let request = PlayerList.CanEdit.Request()
return interactor.canEditRow(request: request)
}
func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
guard editingStyle == .delete else {
return
}
let request = PlayerList.DeletePlayer.Request(index: indexPath.row)
interactor.requestToDeletePlayer(request: request)
}
}
아시다시피 셀은 더이상 Presenter 를 필요하지 않습니다. ViewController 에 ViewModel 의 배열과 같은 필요한 모든 것을 가지고 있습니다.
Interactor 는 아래에 자세히 나와있습니다:
// MARK: - PlayerListInteractor
final class PlayerListInteractor: PlayerListInteractable {
var presenter: PlayerListPresenterProtocol
private let playersService: StandardNetworkService
private var players: [PlayerResponseModel] = []
private static let minimumPlayersToPlay = 2
init(presenter: PlayerListPresenterProtocol = PlayerListPresenter(),
playersService: StandardNetworkService = StandardNetworkService(resourcePath: "/api/players", authenticated: true)) {
self.presenter = presenter
self.playersService = playersService
}
}
// MARK: - PlayerListInteractorServiceRequester
extension PlayerListInteractor: PlayerListInteractorServiceRequester {
func fetchPlayers(request: PlayerList.FetchPlayers.Request) {
playersService.get { [weak self] (result: Result<[PlayerResponseModel], Error>) in
DispatchQueue.main.async {
guard let self = self else { return }
switch result {
case .success(let players):
self.players = players
let response = PlayerList.FetchPlayers.Response(players: players,
minimumPlayersToPlay: Self.minimumPlayersToPlay)
self.presenter.presentFetchedPlayers(response: response)
case .failure(let error):
let errorResponse = PlayerList.ErrorResponse(error: .serviceFailed(error.localizedDescription))
self.presenter.presentError(response: errorResponse)
}
}
}
}
func deletePlayer(request: PlayerList.DeletePlayer.Request) {
let index = request.index
let player = players[index]
var service = playersService
service.delete(withID: ResourceID.integer(player.id)) { [weak self] result in
DispatchQueue.main.async {
guard let self = self else { return }
switch result {
case .success(_):
self.players.remove(at: index)
let response = PlayerList.DeletePlayer.Response(index: index)
self.presenter.playerWasDeleted(response: response)
case .failure(let error):
let errorResponse = PlayerList.ErrorResponse(error: .serviceFailed(error.localizedDescription))
self.presenter.presentError(response: errorResponse)
}
}
}
}
}
// MARK: - PlayerListInteractorActionable
extension PlayerListInteractor: PlayerListInteractorActionable {
func requestToDeletePlayer(request: PlayerList.DeletePlayer.Request) {
let response = PlayerList.DeletePlayer.Response(index: request.index)
presenter.presentDeleteConfirmationAlert(response: response)
}
func selectPlayers(request: PlayerList.SelectPlayers.Request) {
presenter.presentViewForSelection()
}
func confirmOrAddPlayers(request: PlayerList.ConfirmOrAddPlayers.Request) {
let response = PlayerList.ConfirmOrAddPlayers.Response(teamPlayersDictionary: [.bench: players],
addDelegate: self,
confirmDelegate: self)
presenter.confirmOrAddPlayers(response: response)
}
}
// MARK: - Table Delegate
extension PlayerListInteractor: PlayerListInteractorTableDelegate {
func canEditRow(request: PlayerList.CanEdit.Request) -> Bool {
let response = PlayerList.CanEdit.Response()
return presenter.canEditRow(response: response)
}
func selectRow(request: PlayerList.SelectPlayer.Request) {
guard !players.isEmpty else {
return
}
let response = PlayerList.SelectPlayer.Response(index: request.index,
player: players[request.index],
detailDelegate: self)
presenter.selectPlayer(response: response)
}
}
Detail, Add, Confirm 화면 delegate 는 이제 Presenter 에서 Interactor 로 이동됩니다:
// MARK: - PlayerDetailDelegate
extension PlayerListInteractor: PlayerDetailDelegate {
func didUpdate(player: PlayerResponseModel) {
guard let index = players.firstIndex(of: player) else {
return
}
players[index] = player
let response = PlayerList.FetchPlayers.Response(players: players,
minimumPlayersToPlay: Self.minimumPlayersToPlay)
presenter.updatePlayers(response: response)
}
}
// MARK: - AddDelegate
extension PlayerListInteractor: PlayerAddDelegate {
func didAddPlayer() {
fetchPlayers(request: PlayerList.FetchPlayers.Request())
}
}
// MARK: - ConfirmDelegate
extension PlayerListInteractor: ConfirmPlayersDelegate {
func didEndGather() {
let response = PlayerList.ReloadViewState.Response(viewState: .list)
presenter.reloadViewState(response: response)
}
}
마지막으로 Presenter 입니다:
final class PlayerListPresenter: PlayerListPresentable {
// MARK: - Properties
weak var view: PlayerListViewProtocol?
private var viewState: PlayerListViewState
private var viewStateDetails: PlayerListViewStateDetails {
PlayerListViewStateDetailsFactory.makeViewStateDetails(from: viewState)
}
private var selectedRows: Set<Int> = []
private var minimumPlayersToPlay: Int
// MARK: - Public API
init(view: PlayerListViewProtocol? = nil,
viewState: PlayerListViewState = .list,
minimumPlayersToPlay: Int = 2) {
self.view = view
self.viewState = viewState
self.minimumPlayersToPlay = minimumPlayersToPlay
}
}
비지니스 로직 테스트 (Testing our business logic)
Gather
비지니스 기능을 유닛 테스트 할 때 VIPER 에서 VIP 로 전환은 보기보다 어렵지 않습니다. 기본적으로 Interactor 는 새로운 Presenter 입니다.
함수가 호출될 때마다 true 로 설정하는 불린 플래그를 사용하여 동일한 Mock 접근방식을 따릅니다:
import XCTest
@testable import FootballGather
// MARK: - Presenter
final class GatherMockPresenter: GatherPresenterProtocol {
var view: GatherViewProtocol?
weak var expectation: XCTestExpectation? = nil
var numberOfUpdateCalls = 1
private(set) var actualUpdateCalls = 0
private(set) var selectedMinutesComponent: Int?
private(set) var selectedMinutes: Int?
private(set) var selectedSecondsComponent: Int?
private(set) var selectedSeconds: Int?
private(set) var timeWasFormatted = false
private(set) var timerViewWasPresented = false
private(set) var timerWasCancelled = false
private(set) var timerWasToggled = false
private(set) var timerIsHidden = false
private(set) var timeWasUpdated = false
private(set) var alertWasPresented = false
private(set) var poppedToPlayerListView = false
private(set) var errorWasPresented = false
private(set) var timerState: GatherTimeHandler.State?
private(set) var score: [TeamSection: Double] = [:]
private(set) var error: Error?
private(set) var numberOfSections = 0
private(set) var numberOfRows = 0
func presentSelectedRows(response: Gather.SelectRows.Response) {
if let minutes = response.minutes {
selectedMinutes = minutes
}
if let minutesComponent = response.minutesComponent {
selectedMinutesComponent = minutesComponent
}
if let seconds = response.seconds {
selectedSeconds = seconds
}
if let secondsComponent = response.secondsComponent {
selectedSecondsComponent = secondsComponent
}
}
func formatTime(response: Gather.FormatTime.Response) {
selectedMinutes = response.selectedTime.minutes
selectedSeconds = response.selectedTime.seconds
timeWasFormatted = true
actualUpdateCalls += 1
if let expectation = expectation,
numberOfUpdateCalls == actualUpdateCalls {
expectation.fulfill()
}
}
func presentActionButton(response: Gather.ConfigureActionButton.Response) {
timerState = response.timerState
}
func displayTeamScore(response: Gather.UpdateValue.Response) {
score[response.teamSection] = response.newValue
}
func presentTimerView(response: Gather.SetTimer.Response) {
timerViewWasPresented = true
}
func cancelTimer(response: Gather.CancelTimer.Response) {
selectedMinutes = response.selectedTime.minutes
selectedSeconds = response.selectedTime.seconds
timerState = response.timerState
timerWasCancelled = true
}
func presentToggledTimer(response: Gather.ActionTimer.Response) {
timerState = response.timerState
timerWasToggled = true
}
func hideTimer() {
timerIsHidden = true
}
func presentUpdatedTime(response: Gather.TimerDidFinish.Response) {
selectedMinutes = response.selectedTime.minutes
selectedSeconds = response.selectedTime.seconds
timerState = response.timerState
timeWasUpdated = true
}
func presentEndGatherConfirmationAlert(response: Gather.EndGather.Response) {
alertWasPresented = true
}
func popToPlayerListView() {
poppedToPlayerListView = true
expectation?.fulfill()
}
func presentError(response: Gather.ErrorResponse) {
errorWasPresented = true
error = response.error
expectation?.fulfill()
}
func numberOfSections(response: Gather.SectionsCount.Response) -> Int {
numberOfSections = response.teamSections.count
return numberOfSections
}
func numberOfRowsInSection(response: Gather.RowsCount.Response) -> Int {
numberOfRows = response.players.count
return numberOfRows
}
func rowDetails(response: Gather.RowDetails.Response) -> Gather.RowDetails.ViewModel {
Gather.RowDetails.ViewModel(titleLabelText: response.player.name,
descriptionLabelText: response.player.preferredPosition?.acronym ?? "-")
}
func titleForHeaderInSection(response: Gather.SectionTitle.Response) -> Gather.SectionTitle.ViewModel {
Gather.SectionTitle.ViewModel(title: response.teamSection.headerTitle)
}
func numberOfPickerComponents(response: Gather.PickerComponents.Response) -> Int {
response.timeComponents.count
}
func numberOfPickerRows(response: Gather.PickerRows.Response) -> Int {
response.timeComponent.numberOfSteps
}
func titleForRow(response: Gather.PickerRowTitle.Response) -> Gather.PickerRowTitle.ViewModel {
let title = "\(response.row) \(response.timeComponent.short)"
return Gather.PickerRowTitle.ViewModel(title: title)
}
}
// MARK: - Delegate
final class GatherMockDelegate: GatherDelegate {
private(set) var gatherWasEnded = false
func didEndGather() {
gatherWasEnded = true
}
}
// MARK: - View
final class GatherMockView: GatherViewProtocol {
var interactor: GatherInteractorProtocol!
var router: GatherRouterProtocol = GatherRouter()
var loadingView = LoadingView()
private(set) var pickerComponent: Int?
private(set) var pickerRow: Int?
private(set) var animated: Bool?
private(set) var formattedTime: String?
private(set) var actionButtonTitle: String?
private(set) var timerViewIsVisible: Bool?
private(set) var teamAText: String?
private(set) var teamBText: String?
private(set) var selectedRowWasDisplayed = false
private(set) var timeWasFormatted = false
private(set) var confirmationAlertDisplayed = false
private(set) var updatedTimerIsDisplayed = false
private(set) var cancelTimerIsDisplayed = false
private(set) var loadingViewIsVisible = false
private(set) var poppedToPlayerListView = false
private(set) var errorWasHandled = true
func displaySelectedRow(viewModel: Gather.SelectRows.ViewModel) {
pickerComponent = viewModel.pickerComponent
pickerRow = viewModel.pickerRow
animated = viewModel.animated
selectedRowWasDisplayed = true
}
func displayTime(viewModel: Gather.FormatTime.ViewModel) {
formattedTime = viewModel.formattedTime
timeWasFormatted = true
}
func displayActionButtonTitle(viewModel: Gather.ConfigureActionButton.ViewModel) {
actionButtonTitle = viewModel.title
}
func displayEndGatherConfirmationAlert() {
confirmationAlertDisplayed = true
}
func configureTimerViewVisibility(viewModel: Gather.SetTimer.ViewModel) {
timerViewIsVisible = viewModel.timerViewIsVisible
}
func displayUpdatedTimer(viewModel: Gather.TimerDidFinish.ViewModel) {
actionButtonTitle = viewModel.actionTitle
formattedTime = viewModel.formattedTime
timerViewIsVisible = viewModel.timerViewIsVisible
updatedTimerIsDisplayed = true
}
func showLoadingView() {
loadingViewIsVisible = true
}
func hideLoadingView() {
loadingViewIsVisible = false
}
func popToPlayerListView() {
poppedToPlayerListView = true
}
func handleError(title: String, message: String) {
errorWasHandled = true
}
func displayTeamScore(viewModel: Gather.UpdateValue.ViewModel) {
if let teamAText = viewModel.teamAText {
self.teamAText = teamAText
}
if let teamBText = viewModel.teamBText {
self.teamBText = teamBText
}
}
func displayCancelTimer(viewModel: Gather.CancelTimer.ViewModel) {
actionButtonTitle = viewModel.actionTitle
formattedTime = viewModel.formattedTime
timerViewIsVisible = viewModel.timerViewIsVisible
cancelTimerIsDisplayed = true
}
}
다음은 Interactor 의 유닛 테스트 일부분입니다:
import XCTest
@testable import FootballGather
final class GatherInteractorTests: XCTestCase {
// MARK: - Configure
func testSelectRows_whenRequestIsGiven_presentsSelectedTime() {
// given
let mockSelectedTime = GatherTime(minutes: 25, seconds: 54)
let mockTimeHandler = GatherTimeHandler(selectedTime: mockSelectedTime)
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()),
timeHandler: mockTimeHandler)
// when
sut.selectRows(request: Gather.SelectRows.Request())
// then
XCTAssertEqual(mockPresenter.selectedMinutes, mockSelectedTime.minutes)
XCTAssertEqual(mockPresenter.selectedMinutesComponent, sut.minutesComponent?.rawValue)
XCTAssertEqual(mockPresenter.selectedSeconds, mockSelectedTime.seconds)
XCTAssertEqual(mockPresenter.selectedSecondsComponent, sut.secondsComponent?.rawValue)
}
func testSelectRows_whenComponentsAreNil_selectedTimeIsNil() {
// given
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()),
timeComponents: [])
// when
sut.selectRows(request: Gather.SelectRows.Request())
// then
XCTAssertNil(mockPresenter.selectedMinutes)
XCTAssertNil(mockPresenter.selectedMinutesComponent)
XCTAssertNil(mockPresenter.selectedSeconds)
XCTAssertNil(mockPresenter.selectedSecondsComponent)
}
func testFormatTime_whenRequestIsGiven_formatsTime() {
// given
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()))
// when
sut.formatTime(request: Gather.FormatTime.Request())
// then
XCTAssertNotNil(mockPresenter.selectedMinutes)
XCTAssertNotNil(mockPresenter.selectedSeconds)
XCTAssertTrue(mockPresenter.timeWasFormatted)
}
func testConfigureActionButton_whenRequestIsGiven_() {
// given
let mockState = GatherTimeHandler.State.running
let mockTimeHandler = GatherTimeHandler(state: mockState)
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()),
timeHandler: mockTimeHandler)
// when
sut.configureActionButton(request: Gather.ConfigureActionButton.Request())
// then
XCTAssertEqual(mockPresenter.timerState, mockState)
}
func testUpdateValue_whenRequestIsGiven_displaysTeamScore() {
// given
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()))
// when
sut.updateValue(request: Gather.UpdateValue.Request(teamSection: .teamA, newValue: 15))
sut.updateValue(request: Gather.UpdateValue.Request(teamSection: .teamB, newValue: 16))
// then
XCTAssertEqual(mockPresenter.score[.teamA], 15)
XCTAssertEqual(mockPresenter.score[.teamB], 16)
}
// MARK: - Time Handler
func testSetTimer_whenRequestIsGiven_selectsTime() {
// given
let mockSelectedTime = GatherTime(minutes: 5, seconds: 0)
let mockTimeHandler = GatherTimeHandler(selectedTime: mockSelectedTime)
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()),
timeHandler: mockTimeHandler)
// when
sut.setTimer(request: Gather.SetTimer.Request())
// then
XCTAssertEqual(mockPresenter.selectedMinutes, mockSelectedTime.minutes)
XCTAssertEqual(mockPresenter.selectedMinutesComponent, sut.minutesComponent?.rawValue)
XCTAssertEqual(mockPresenter.selectedSeconds, mockSelectedTime.seconds)
XCTAssertEqual(mockPresenter.selectedSecondsComponent, sut.secondsComponent?.rawValue)
}
func testSetTimer_whenRequestIsGiven_presentsTimerView() {
// given
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()))
// when
sut.setTimer(request: Gather.SetTimer.Request())
// then
XCTAssertTrue(mockPresenter.timerViewWasPresented)
}
func testCancelTimer_whenRequestIsGiven_cancelsTimer() {
// given
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()))
// when
sut.cancelTimer(request: Gather.CancelTimer.Request())
// then
XCTAssertNotNil(mockPresenter.selectedMinutes)
XCTAssertNotNil(mockPresenter.selectedSeconds)
XCTAssertNotNil(mockPresenter.timerState)
XCTAssertTrue(mockPresenter.timerWasCancelled)
}
func testActionTimer_whenRequestIsGiven_presentsToggledTime() {
// given
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()))
// when
sut.actionTimer(request: Gather.ActionTimer.Request())
// then
XCTAssertNotNil(mockPresenter.timerState)
XCTAssertTrue(mockPresenter.timerWasToggled)
}
func testActionTimer_whenTimeIsInvalid_presentsToggledTime() {
// given
let mockSelectedTime = GatherTime(minutes: -1, seconds: -1)
let mockTimeHandler = GatherTimeHandler(selectedTime: mockSelectedTime)
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()),
timeHandler: mockTimeHandler)
// when
sut.actionTimer(request: Gather.ActionTimer.Request())
// then
XCTAssertNotNil(mockPresenter.timerState)
XCTAssertTrue(mockPresenter.timerWasToggled)
}
func testActionTimer_whenTimeIsValid_updatesTimer() {
// given
let numberOfUpdateCalls = 2
let mockSelectedTime = GatherTime(minutes: 0, seconds: numberOfUpdateCalls)
let mockTimeHandler = GatherTimeHandler(selectedTime: mockSelectedTime)
let exp = expectation(description: "Update timer expectation")
let mockPresenter = GatherMockPresenter()
mockPresenter.expectation = exp
mockPresenter.numberOfUpdateCalls = numberOfUpdateCalls
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()),
timeHandler: mockTimeHandler)
// when
sut.actionTimer(request: Gather.ActionTimer.Request())
// then
waitForExpectations(timeout: 5) { _ in
XCTAssertEqual(mockPresenter.actualUpdateCalls, numberOfUpdateCalls)
sut.cancelTimer(request: Gather.CancelTimer.Request())
}
}
func testTimerDidCancel_whenRequestIsGiven_hidesTimer() {
// given
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()))
// when
sut.timerDidCancel(request: Gather.TimerDidCancel.Request())
// then
XCTAssertTrue(mockPresenter.timerIsHidden)
}
func testTimerDidFinish_whenRequestIsGiven_updatesTime() {
// given
let mockSelectedTime = GatherTime(minutes: 1, seconds: 13)
let mockTimeHandler = GatherTimeHandler(selectedTime: mockSelectedTime)
let mockRequest = Gather.TimerDidFinish.Request(selectedMinutes: 0, selectedSeconds: 25)
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()),
timeHandler: mockTimeHandler)
// when
sut.timerDidFinish(request: mockRequest)
// then
XCTAssertEqual(mockPresenter.selectedMinutes, mockRequest.selectedMinutes)
XCTAssertEqual(mockPresenter.selectedSeconds, mockRequest.selectedSeconds)
XCTAssertNotNil(mockPresenter.timerState)
XCTAssertTrue(mockPresenter.timeWasUpdated)
}
// MARK: - GatherInteractorActionable
func testRequestToEndGather_whenRequestIsGiven_presentsAlert() {
// given
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()))
// when
sut.requestToEndGather(request: Gather.EndGather.Request())
// then
XCTAssertTrue(mockPresenter.alertWasPresented)
}
func testEndGather_whenScoreDescriptionIsNil_returns() {
// given
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()))
// when
sut.endGather(request: Gather.EndGather.Request(winnerTeamDescription: "win"))
// then
XCTAssertFalse(mockPresenter.poppedToPlayerListView)
XCTAssertFalse(mockPresenter.errorWasPresented)
}
func testEndGather_whenWinnerTeamDescriptionIsNil_returns() {
// given
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()))
// when
sut.endGather(request: Gather.EndGather.Request(scoreDescription: "score"))
// then
XCTAssertFalse(mockPresenter.poppedToPlayerListView)
XCTAssertFalse(mockPresenter.errorWasPresented)
}
func testEndGather_whenScoreIsSet_updatesGather() {
// given
let appKeychain = AppKeychainMockFactory.makeKeychain()
appKeychain.token = ModelsMock.token
let session = URLSessionMockFactory.makeSession()
let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
let mockEndpoint = EndpointMockFactory.makeSuccessfulEndpoint(path: "/api/gathers")
let mockService = StandardNetworkService(session: session,
urlRequest: AuthURLRequestFactory(endpoint: mockEndpoint,
keychain: appKeychain))
let mockPresenter = GatherMockPresenter()
let exp = expectation(description: "Update gather expectation")
mockPresenter.expectation = exp
let mockDelegate = GatherMockDelegate()
let sut = GatherInteractor(presenter: mockPresenter,
delegate: mockDelegate,
gather: mockGatherModel,
updateGatherService: mockService)
// when
sut.endGather(request: Gather.EndGather.Request(winnerTeamDescription: "None", scoreDescription: "1-1"))
// then
waitForExpectations(timeout: 5) { _ in
XCTAssertTrue(mockPresenter.poppedToPlayerListView)
XCTAssertTrue(mockDelegate.gatherWasEnded)
appKeychain.storage.removeAll()
}
}
func testEndGather_whenScoreIsNotSet_errorIsPresented() {
// given
let appKeychain = AppKeychainMockFactory.makeKeychain()
appKeychain.token = ModelsMock.token
let session = URLSessionMockFactory.makeSession()
let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
let mockEndpoint = EndpointMockFactory.makeSuccessfulEndpoint(path: "/api/gathers")
let mockService = StandardNetworkService(session: session,
urlRequest: AuthURLRequestFactory(endpoint: mockEndpoint,
keychain: appKeychain))
let mockPresenter = GatherMockPresenter()
let exp = expectation(description: "Update gather expectation")
mockPresenter.expectation = exp
let mockDelegate = GatherMockDelegate()
let sut = GatherInteractor(presenter: mockPresenter,
delegate: mockDelegate,
gather: mockGatherModel,
updateGatherService: mockService)
// when
sut.endGather(request: Gather.EndGather.Request(winnerTeamDescription: "", scoreDescription: ""))
// then
waitForExpectations(timeout: 5) { _ in
XCTAssertTrue(mockPresenter.errorWasPresented)
XCTAssertTrue(mockPresenter.error is EndGatherError)
appKeychain.storage.removeAll()
}
}
// MARK: - Table Delegate
func testNumberOfSections_whenRequestIsGiven_returnsNumberOfTeamSections() {
// given
let mockTeamSections: [TeamSection] = [.teamA]
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()),
teamSections: mockTeamSections)
// when
let numberOfSections = sut.numberOfSections(request: Gather.SectionsCount.Request())
// then
XCTAssertEqual(mockPresenter.numberOfSections, mockTeamSections.count)
XCTAssertEqual(mockPresenter.numberOfSections, numberOfSections)
}
func testNumberOfRowsInSection_whenSectionIsZero_equalsNumberOfPlayersInTeamSection() {
// given
let mockGather = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
let expectedNumberOfPlayers = mockGather.players.filter { $0.team == .teamA }.count
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter, gather: mockGather)
// when
let numberOfRowsInSection = sut.numberOfRowsInSection(request: Gather.RowsCount.Request(section: 0))
// then
XCTAssertEqual(mockPresenter.numberOfRows, expectedNumberOfPlayers)
XCTAssertEqual(numberOfRowsInSection, expectedNumberOfPlayers)
}
func testNumberOfRowsInSection_whenSectionIsOne_equalsNumberOfPlayersInTeamSection() {
// given
let mockGather = ModelsMockFactory.makeGatherModel(numberOfPlayers: 5)
let expectedNumberOfPlayers = mockGather.players.filter { $0.team == .teamB }.count
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter, gather: mockGather)
// when
let numberOfRowsInSection = sut.numberOfRowsInSection(request: Gather.RowsCount.Request(section: 1))
// then
XCTAssertEqual(mockPresenter.numberOfRows, expectedNumberOfPlayers)
XCTAssertEqual(numberOfRowsInSection, expectedNumberOfPlayers)
}
func testRowDetails_whenInteractorHasPlayers_equalsPlayerNameAndPreferredPositionAcronym() {
// given
let mockGather = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
let firstTeamAPlayer = mockGather.players.filter { $0.team == .teamA }.first?.player
let expectedRowTitle = firstTeamAPlayer?.name
let expectedRowDescription = firstTeamAPlayer?.preferredPosition?.acronym
let mockIndexPath = IndexPath(row: 0, section: 0)
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter, gather: mockGather)
// when
let rowDetails = sut.rowDetails(request: Gather.RowDetails.Request(indexPath: mockIndexPath))
// then
XCTAssertEqual(rowDetails.titleLabelText, expectedRowTitle)
XCTAssertEqual(rowDetails.descriptionLabelText, expectedRowDescription)
}
func testTitleForHeaderInSection_whenSectionIsTeamA_equalsTeamSectionHeaderTitle() {
// given
let expectedTitle = TeamSection.teamA.headerTitle
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()))
// when
let titleForHeader = sut.titleForHeaderInSection(request: Gather.SectionTitle.Request(section: 0)).title
// then
XCTAssertEqual(titleForHeader, expectedTitle)
}
func testTitleForHeaderInSection_whenSectionIsTeamB_equalsTeamSectionHeaderTitle() {
// given
let expectedTitle = TeamSection.teamB.headerTitle
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()))
// when
let titleForHeader = sut.titleForHeaderInSection(request: Gather.SectionTitle.Request(section: 1)).title
// then
XCTAssertEqual(titleForHeader, expectedTitle)
}
// MARK: - Picker Delegate
func testNumberOfPickerComponents_whenTimeComponentsAreGiven_equalsInteractorTimeComponents() {
// given
let mockTimeComponents: [GatherTimeHandler.Component] = [.minutes]
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()),
timeComponents: mockTimeComponents)
// when
let numberOfPickerComponents = sut.numberOfPickerComponents(request: Gather.PickerComponents.Request())
// then
XCTAssertEqual(numberOfPickerComponents, mockTimeComponents.count)
}
func testNumberOfRowsInPickerComponent_whenComponentIsMinutes_equalsNumberOfSteps() {
// given
let mockTimeComponents: [GatherTimeHandler.Component] = [.minutes]
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()),
timeComponents: mockTimeComponents)
// when
let numberOfRowsInPickerComponent = sut.numberOfRowsInPickerComponent(request: Gather.PickerRows.Request(component: 0))
// then
XCTAssertEqual(numberOfRowsInPickerComponent, GatherTimeHandler.Component.minutes.numberOfSteps)
}
func testNumberOfRowsInPickerComponent_whenComponentIsSeconds_equalsNumberOfSteps() {
// given
let mockTimeComponents: [GatherTimeHandler.Component] = [.seconds]
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()),
timeComponents: mockTimeComponents)
// when
let numberOfRowsInPickerComponent = sut.numberOfRowsInPickerComponent(request: Gather.PickerRows.Request(component: 0))
// then
XCTAssertEqual(numberOfRowsInPickerComponent, GatherTimeHandler.Component.seconds.numberOfSteps)
}
func testTitleForPickerRow_whenComponentsAreNotEmpty_containsTimeComponentShort() {
// given
let mockTimeComponents: [GatherTimeHandler.Component] = [.seconds]
let mockPresenter = GatherMockPresenter()
let sut = GatherInteractor(presenter: mockPresenter,
gather: GatherModel(players: [], gatherUUID: UUID()),
timeComponents: mockTimeComponents)
// when
let titleForPickerRow = sut.titleForPickerRow(request: Gather.PickerRowTitle.Request(row: 0, component: 0)).title
// then
XCTAssertTrue(titleForPickerRow.contains(GatherTimeHandler.Component.seconds.short))
}
}
그리고 Presenter 유닛 테스트 입니다:
import XCTest
@testable import FootballGather
final class GatherPresenterTests: XCTestCase {
// MARK: - View Configuration
func testPresentSelectedRows_whenResponseHasMinutes_displaysSelectedRow() {
// given
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
sut.presentSelectedRows(response: Gather.SelectRows.Response(minutes: 1, minutesComponent: 1))
// then
XCTAssertTrue(mockView.selectedRowWasDisplayed)
XCTAssertEqual(mockView.pickerRow, 1)
XCTAssertEqual(mockView.pickerComponent, 1)
}
func testPresentSelectedRows_whenResponseHasSeconds_displaysSelectedRow() {
// given
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
sut.presentSelectedRows(response: Gather.SelectRows.Response(seconds: 15, secondsComponent: 45))
// then
XCTAssertTrue(mockView.selectedRowWasDisplayed)
XCTAssertEqual(mockView.pickerRow, 15)
XCTAssertEqual(mockView.pickerComponent, 45)
}
func testFormatTime_whenResponseIsGiven_formatsTime() {
// given
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
sut.formatTime(response: Gather.FormatTime.Response(selectedTime: GatherTime(minutes: 1, seconds: 21)))
// then
XCTAssertTrue(mockView.timeWasFormatted)
XCTAssertEqual(mockView.formattedTime, "01:21")
}
func testPresentActionButton_whenStateIsPaused_displaysResumeActionButtonTitle() {
// given
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
sut.presentActionButton(response: Gather.ConfigureActionButton.Response(timerState: .paused))
// then
XCTAssertEqual(mockView.actionButtonTitle, "Resume")
}
func testPresentActionButton_whenStateIsRunning_displaysPauseActionButtonTitle() {
// given
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
sut.presentActionButton(response: Gather.ConfigureActionButton.Response(timerState: .running))
// then
XCTAssertEqual(mockView.actionButtonTitle, "Pause")
}
func testPresentActionButton_whenStateIsStopped_displaysStartActionButtonTitle() {
// given
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
sut.presentActionButton(response: Gather.ConfigureActionButton.Response(timerState: .stopped))
// then
XCTAssertEqual(mockView.actionButtonTitle, "Start")
}
func testPresentEndGatherConfirmationAlert_whenResponseIsGiven_alertIsDisplayed() {
// given
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
sut.presentEndGatherConfirmationAlert(response: Gather.EndGather.Response())
// then
XCTAssertTrue(mockView.confirmationAlertDisplayed)
}
func testPresentTimerView_whenResponseIsGive_timerViewIsVisible() {
// given
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
sut.presentTimerView(response: Gather.SetTimer.Response())
// then
XCTAssertTrue(mockView.timerViewIsVisible!)
}
func testDisplayCancelTimer_whenSelectedTimeIsGiven_displaysCancelledTimer() {
// given
let mockGatherTime = GatherTime(minutes: 21, seconds: 32)
let mockResponse = Gather.CancelTimer.Response(selectedTime: mockGatherTime,
timerState: .paused)
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
sut.cancelTimer(response: mockResponse)
// then
XCTAssertEqual(mockView.actionButtonTitle, "Resume")
XCTAssertEqual(mockView.formattedTime, "21:32")
XCTAssertFalse(mockView.timerViewIsVisible!)
XCTAssertTrue(mockView.cancelTimerIsDisplayed)
}
func testPresentToggleTimer_whenResponseIsGiven_displaysActionButtonTitle() {
// given
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
sut.presentToggledTimer(response: Gather.ActionTimer.Response(timerState: .running))
// then
XCTAssertEqual(mockView.actionButtonTitle, "Pause")
}
func testHideTimer_whenPresenterIsAllocated_timerViewIsNotVisible() {
// given
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
sut.hideTimer()
// then
XCTAssertFalse(mockView.timerViewIsVisible!)
}
func testPresentUpdatedTime_whenSelectedTimeIsGiven_displaysUpdatedTimer() {
// given
let mockGatherTime = GatherTime(minutes: 1, seconds: 5)
let mockResponse = Gather.TimerDidFinish.Response(selectedTime: mockGatherTime,
timerState: .stopped)
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
sut.presentUpdatedTime(response: mockResponse)
// then
XCTAssertEqual(mockView.actionButtonTitle, "Start")
XCTAssertEqual(mockView.formattedTime, "01:05")
XCTAssertFalse(mockView.timerViewIsVisible!)
XCTAssertTrue(mockView.updatedTimerIsDisplayed)
}
func testPopToPlayerListView_whenPresenterIsAllocated_hidesLoadingViewAndPopsToPlayerListView() {
// given
let mockView = GatherMockView()
mockView.showLoadingView()
let sut = GatherPresenter(view: mockView)
// when
sut.popToPlayerListView()
// then
XCTAssertFalse(mockView.loadingViewIsVisible)
XCTAssertTrue(mockView.poppedToPlayerListView)
}
func testPresentError_whenResponseIsGiven_displaysError() {
// given
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
sut.presentError(response: Gather.ErrorResponse(error: .endGatherError))
// then
XCTAssertTrue(mockView.errorWasHandled)
}
func testDisplayTeamScore_when_displaysScore() {
// given
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
sut.displayTeamScore(response: Gather.UpdateValue.Response(teamSection: .teamA, newValue: 1))
sut.displayTeamScore(response: Gather.UpdateValue.Response(teamSection: .teamB, newValue: 15))
// then
XCTAssertEqual(mockView.teamAText, "1")
XCTAssertEqual(mockView.teamBText, "15")
}
// MARK: - Table Delegate
func testNumberOfSections_whenResponseIsGiven_returnsTeamSectionsCount() {
// given
let mockTeamSections: [TeamSection] = [.bench, .teamB, .teamA]
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
let numberOfSections = sut.numberOfSections(response: Gather.SectionsCount.Response(teamSections: mockTeamSections))
// then
XCTAssertEqual(numberOfSections, mockTeamSections.count)
}
func testNumberOfRowsInSection_whenResponseIsGiven_returnsPlayersCount() {
// given
let mockPlayerResponseModel = PlayerResponseModel(id: -1, name: "mock-name")
let mockResponse = Gather.RowsCount.Response(players: [mockPlayerResponseModel])
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
let numberOfRows = sut.numberOfRowsInSection(response: mockResponse)
// then
XCTAssertEqual(numberOfRows, 1)
}
func testRowDetails_whenResponseIsGiven_returnsPlayerNameAndPreferredPositionAcronym() {
// given
let mockPlayerResponseModel = PlayerResponseModel(id: -1,
name: "mock-name",
preferredPosition: .goalkeeper)
let mockResponse = Gather.RowDetails.Response(player: mockPlayerResponseModel)
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
let rowDetails = sut.rowDetails(response: mockResponse)
// then
XCTAssertEqual(rowDetails.titleLabelText, mockPlayerResponseModel.name)
XCTAssertEqual(rowDetails.descriptionLabelText, mockPlayerResponseModel.preferredPosition!.acronym)
}
func testRowDetails_whenPositionIsNil_descriptionLabelIsDash() {
// given
let mockPlayerResponseModel = PlayerResponseModel(id: -1,
name: "mock-name")
let mockResponse = Gather.RowDetails.Response(player: mockPlayerResponseModel)
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
let rowDetails = sut.rowDetails(response: mockResponse)
// then
XCTAssertEqual(rowDetails.descriptionLabelText, "-")
}
func testTitleForHeaderInSection_whenTeamSectionIsA_returnsTeamAHeaderTitle() {
// given
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
let title = sut.titleForHeaderInSection(response: Gather.SectionTitle.Response(teamSection: .teamA)).title
// then
XCTAssertEqual(title, TeamSection.teamA.headerTitle)
}
func testTitleForHeaderInSection_whenTeamSectionIsB_returnsTeamBHeaderTitle() {
// given
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
let title = sut.titleForHeaderInSection(response: Gather.SectionTitle.Response(teamSection: .teamB)).title
// then
XCTAssertEqual(title, TeamSection.teamB.headerTitle)
}
// MARK: - Picker Delegate
func testNumberOfPickerComponents_whenResponseIsGiven_returnsTimeComponentsCount() {
// given
let mockTimeComponents: [GatherTimeHandler.Component] = [.minutes, .seconds]
let mockResponse = Gather.PickerComponents.Response(timeComponents: mockTimeComponents)
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
let numberOfPickerComponents = sut.numberOfPickerComponents(response: mockResponse)
// then
XCTAssertEqual(numberOfPickerComponents, mockTimeComponents.count)
}
func testNumberOfPickerRows_whenComponentIsMinutes_returnsNumberOfSteps() {
// given
let mockResponse = Gather.PickerRows.Response(timeComponent: .minutes)
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
let numberOfPickerRows = sut.numberOfPickerRows(response: mockResponse)
// then
XCTAssertEqual(numberOfPickerRows, GatherTimeHandler.Component.minutes.numberOfSteps)
}
func testNumberOfPickerRows_whenComponentIsSeconds_returnsNumberOfSteps() {
// given
let mockResponse = Gather.PickerRows.Response(timeComponent: .seconds)
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
let numberOfPickerRows = sut.numberOfPickerRows(response: mockResponse)
// then
XCTAssertEqual(numberOfPickerRows, GatherTimeHandler.Component.seconds.numberOfSteps)
}
func testTitleForRow_whenTimeComponentIsMinutes_containsRowAndTimeComponentShort() {
// given
let mockResponse = Gather.PickerRowTitle.Response(timeComponent: .minutes, row: 5)
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
let titleForRow = sut.titleForRow(response: mockResponse).title
// then
XCTAssertTrue(titleForRow.contains("\(mockResponse.row)"))
XCTAssertTrue(titleForRow.contains("\(mockResponse.timeComponent.short)"))
}
func testTitleForRow_whenTimeComponentIsSeconds_containsRowAndTimeComponentShort() {
// given
let mockResponse = Gather.PickerRowTitle.Response(timeComponent: .seconds, row: 11)
let mockView = GatherMockView()
let sut = GatherPresenter(view: mockView)
// when
let titleForRow = sut.titleForRow(response: mockResponse).title
// then
XCTAssertTrue(titleForRow.contains("\(mockResponse.row)"))
XCTAssertTrue(titleForRow.contains("\(mockResponse.timeComponent.short)"))
}
}
주요지표 (Key Metrics)
코드 라인 수 (Lines of code) — Protocols
코드 라인 수 (Lines of code) — View Controllers and Views
코드 라인 수 (Lines of code) — Modules
코드 라인 수 (Lines of code) — Routers
코드 라인 수 (Lines of code) — Presenters
코드 라인 수 (Lines of code) — Interactors
코드 라인 수 (Lines of code) — Local Models
유닛 테스트 (Unit Tests)
빌드 시간 (Build Times)
테스트는 8-Core Intel Core i9, MacBook Pro, 2019. Xcode 버전: 12.5.1. macOS Big Sur.
결론 (Conclusion)
VIPER 로 된 애플리케이션을 VIP 로 적용하였고 가장 먼저 눈에 띄는 것은 Presenter 가 더 간소화 되고 깔끔해 졌습니다. MVC 앱에서 부터 왔다면 ViewController 는 상당히 줄어들었을 것입니다.
VIP 는 단방향 제어를 사용하여 플로우를 간소화 하고 레이어를 통해 메서드 호출을 더 쉽게 해줍니다.
평균 빌드 시간은 대략 10초로 VIPER 와 MVP 와 유사합니다. 더 많은 유닛 테스트를 가질 수록 테스트 실행 시간은 더 추가됩니다. 하지만 VIPER 보다는 좀 더 빠릅니다.
Presenter 는 VIPER 와 비교해 514의 코드 라인 수를 줄였습니다. 그러나 주요 단점은 Interactor 에 508 라인 더 증가하였습니다. 기본적으로 Presenter 에서 Interactor 로 이동하였습니다.
개인적으로 VIPER 를 선호합니다. VIP 아키텍처에는 내가 좋아하지 않는 것들이 있고 내 관점에서 Uncle Bob 의 원칙을 따르지 않습니다.
예를 들어 아무것도 없는 상태에서 Request 객체를 생성해야 되는 이유는 무엇일까요? 우리는 그렇게 할 수 없었지만 예제의 repository 를 열어보면 많은 빈 요청 객체를 볼 수 있습니다.
보일러플레이트 코드가 많이 있습니다.
ViewController 내부에 ViewModel 의 배열을 유지하는 것은 복잡성을 만들고 Worker 모델과 쉽게 동기화 되지 않을 수 있습니다.
물론 이러한 문제를 완화할 수 있는 고유한 VIP 변형을 사용할 수 있습니다.
긍정적인 면은 VIP 사이클의 개념과 TDD 를 사용하는데 얼마나 쉬운지에 대해선 좋습니다. 그러나 레이어의 엄격한 규칙을 따르면 각 작은 변경은 구현하기 어려울 수 있습니다. 이것은 소프트!웨어 여야 합니다?!
유용한 링크 (Useful 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 and article link
- Model View ViewModel (MVVM) — GitHub Repo Link and article link
- Model View Presenter (MVP) — GitHub Repo link and article link
- Coordinator Pattern — MVP with Coordinators (MVP-C) — GitHub Repo link and article link
- View Interactor Presenter Entity Router (VIPER) — GitHub Repo linkand article link
- View Interactor Presenter (VIP) — GitHub Repo link