iOS Architecture Patterns 뿌시기: Model View Controller (MVC)

24 분 소요

Thanks to Radu Dan for allowing the translation.

Reference: Battle of the iOS Architecture Patterns: Model View Controller (MVC)

본 글은 위의 내용에 대한 번역본입니다.

순서는 다음과 같습니다.

  1. MVC
  2. MVVM
  3. MVP
  4. MVP-C
  5. VIPER
  6. VIP

iOS Architecture Patterns 뿌시기: Model View Controller (MVC)

iOS 개발에서 가장 기본 아키텍처 패턴 (architecture pattern) 으로 시작합시다.

아키텍처 시리즈 — Model View Controller (MVC)

아키텍처 시리즈 — Model View Controller (MVC)

동기 (Motivation)

iOS 앱을 개발하기 전에 프로젝트의 구조에 대해 생각해야 합니다. 나중에 앱의 일부분을 다시 볼 때 코드의 조각을 추가하는 방법과 다른 개발자들과 “언어” 라고 알려진 형식을 고려해야 합니다.

iOS 개발에서 가장 잘 알려진 패턴을 통해 하나의 애플리케이션을 사용하는 글을 시작하려고 합니다. 각 패턴에 대한 빌드 시간과 장점과 단점을 확인하지만 가장 중요한 것은 실제 구현과 소스 코드를 확인해 볼 것입니다.

코드를 보기 원한다면 이 글을 건너뛰어도 됩니다. 코드는 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" 에 화면 목업

iOS 앱인 “Football Gather” 에 화면 목업

백엔드 (Backend)

이 앱은 Vapor 웹 프레임워크로 개발된 웹 앱으로 구동됩니다. Vapor 3 initial articlearticle about Migrating to Vapor 4 에서 앱을 확인할 수 있습니다.

MVC 란? (What Is MVC?)

MVC 는 세상에서 가장 잘 알려진 아키텍처 패턴입니다.

3개의 컴포넌트가 있습니다: ModelView, 그리고 Controller.

Model

  • 모든 데이터 클래스 (data classes), 헬퍼 (helpers), 네트워크 코드 (networking code)를 포함합니다.
  • 앱 고유의 데이터를 모두 가지고 있으며, 데이터를 처리하는 로직을 정의합니다.
  • 앱에서 모델 (model) 은 Utils, Storage, 그리고 Networking 그룹에 있는 것을 나타냅니다.
  • 여러 선수들과 여러 팀 (M:M) 또는 선수들/팀들의 사용자 (1:M) 와 같이 다른 모델 객체들은 1:M 과 M:M 관계를 가질 수 있습니다.
  • View 와 직접적으로 통신하면 안되며 유저 인터페이스에 대해 관여하면 안됩니다.

통신 (Communication)

  • 예를 들어 사용자가 액션을 시작하는 것과 같이 View layer 에서 어떠한 것이 발생하면 Controller 를 통해 Model 로 전달됩니다.
  • 예를 들어 새로운 데이터가 가능한 것과 같이 Model 이 변경되면 Model 은 Controller 로 알립니다.

View

  • View 는 화면에서 유저가 보는 컴포넌트를 나타냅니다.
  • 유저 액션에 반응합니다.
  • View 의 목적은 Model 로 부터 데이터를 보여주고 유저 상호작용을 가능하게 해줍니다.
  • 주요 Apple 프레임워크는 UIKit 과 AppKit 입니다.
  • 앱에서의 예제는 다음과 같습니다: LoadingViewEmptyViewPlayerTableViewCell, 그리고 ScoreStepper.

통신 (Communication)

  • View 는 Model 과 직접적으로 통신할 수 없습니다. 모든 것은 Controller 를 통해 수행됩니다.

Controller

  • MVC 의 핵심 레이어 (core layer) 입니다.
  • View 업데이트를 처리하고 Model 을 변경합니다.
  • Model 업데이트를 처리하고 View 를 업데이트 합니다.
  • Controller 는 다른 객체의 생명주기를 관리하기 위해 설정 메서드나 작업을 가질 수 있습니다.

통신 (Communication)

  • Model 과 View 모든 레이어와 통신할 수 있습니다.
  • Controller 는 사용자 액션을 해석하고 Model 레이어로 부터 데이터 변경사항을 트리거 합니다.
  • 데이터가 변경되면 해당 변경사항이 유저 인터페이스에 전달되어 View 를 업데이트 합니다.

MVC 의 다른 점 (Different Flavours of MVC)

전통적인 MVC 는 Cocoa 의 MVC 와 다릅니다: View 와 Model 레이어는 서로 통신할 수 있습니다.

View 는 상태를 보존하지 않고 Model 이 업데이트 되면 Controller 에 의해 렌더링됩니다.

Smalltalk-79 에서 소개되었으며 여러 디자인 패턴을 기반으로 만들어 졌습니다: 복합 (composite), 전략 (strategy), 그리고 관찰자 (observer). (아래의 정의는 Apple’s Model-View-Controller documentation 기반으로 작성되었습니다.)

복합 (Composite)

“애플리케이션에서 View 객체는 실제로 조정된 방식으로 함께 동작하는 중첩된 View 의 합성물입니다 (이것은 View 계층구조 (hierachy)입니다). 이러한 디스플레이 요소는 window 부터 table view, 버튼과 같은 개별 view 와 같은 복합 view 까지 다양합니다. 사용자 입력과 디스플레이는 복합 구조의 모든 수준에서 수행될 수 있습니다.”

UIView 의 계층구조에 대해 생각해 봅시다. View 는 사용자의 인터페이스의 주요 요소입니다. View 는 다른 subview 를 포함할 수 있습니다. 예를 들어 해당 앱에서 LoginViewController 는 stack view 가 많이 포함된 main view 를 가지고 있으며 그 안에 사용자 이름과 비밀번호를 입력하는 text field 와 로그인 버튼이 있습니다.

전략 (Strategy)

“Controller 객체는 하나 이상의 View 객체에 대한 전략을 구현합니다. View 객체는 시각적 측면에 국한되며 인터페이스 동작의 애플리케이션에 대한 모든 결정을 Controller 에 위임합니다.”

관찰자 (Observer)

“Model 객체는 애플리케이션에서 보통 View 객체에 상태 변경을 알려줍니다.”

기존의 MVC 의 주 단점은 세 레이어가 밀접하게 결합되어 있는 것입니다. 테스트하고 유지관리하고 일부 로직을 재사용 하는데에도 어려움이 있습니다.

어떻게 그리고 언제 MVC 를 사용할까 (How and When To Use MVC)

때에 따라 다릅니다.

제대로 사용한다면 모든 앱에서 사용할 수 있습니다. 명확하게 yes 또는 no 는 없습니다 — 앱, 팀, 조직, 프로젝트의 크기, 개발자의 역량, 일정, 등에 따라 다릅니다.

그러나 고려해야 될 몇가지 취약점이 있습니다:

  • 보시다시피 Controller 는 이 아키텍처 패턴의 중심입니다. Controller 는 View 와 Model 레이어와 강하게 결합되어 있습니다.
  • Controller 는 잘 알려진 Massive View Controller 가 될 수 있습니다.
  • 이 아키텍처 패턴은 테스트하기 어렵습니다.

위에서 언급한 내용을 다루는 방법이 있습니다. 그 중 하나는 View Controller 를 mini-View Controller 로 나누는 것입니다. 여기서 coordinator 로 동작하는 큰 Container/Parent View Controller 를 가지고 있으며 앱의 각 영역은 다르거나 child View Controller 에 의해 처리됩니다.

왜 MVC 를 사용해야 할까:

  • Apple 에서 권장하며 예를 들어 UIKit 과 같은 프레임워크에서 사용됩니다.
  • 많은 개발자들이 이 패턴을 알고 있기 때문에 협업하기 쉽습니다.
  • 다른 아키텍처 패턴보다 코드 작성 속도가 빠릅니다.

코드에 MVC 적용 (Applying MVC to Our Code)

Football Gather 에서 View Controller 로 각 화면을 구현합니다:

Login Screen — LoginViewController

Login Screen

Login Screen

설명 (Description)

  • 사용자가 로그인하거나 새로운 사용자를 생성하기 위한 랜딩 페이지

UI elements

  • usernameTextField — 사용자가 사용자 이름을 작성하는 text field
  • passwordTextField — 암호를 입력하기 위한 보안 text field
  • rememberMeSwitch — 로그인 후 사용자 이름을 키체인에 저장하여 다음 앱을 실행할 때 text field 를 자동으로 채우는  UISwitch
  • loadingView — 서버 호출을 하는 동안 로딩을 보여주기 위해 사용

Services

  • loginService — 입력한 자격증명으로 Login API 호출하기 위해 사용
  • usersService — 새로운 사용자를 생성하는 Register API 를 호출하기 위해 사용

보이는 것 처럼 이 클래스는 세가지 주요 기능이 있습니다: 로그인, 등록, 그리고 사용자 이름 기억. 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()
            }
        }
    }
}

Player List Screen — PlayerListViewController

Player List and Selection Screen

Player List and Selection Screen

설명 (Description)

  • 로그인 한 사용자에 대한 선수를 보여줍니다. 각 선수는 분리된 열로 표시된 주 table view 로 구성됩니다.

UI elements

  • playerTableView — 선수를 보여주는 table view
  • confirmOrAddPlayersButton — 선수를 추가하거나 선택한 선수를 확인할 수 있는 View 의 하단 버튼
  • loadingView — 서버 호출을 하는 동안 로딩을 보여주기 위해 사용
  • emptyView — 사용자가 추가한 선수가 없는 경우 보여줍니다
  • barButtonItem — View 모드에 따라 다른 상태를 가질 수 있는 우상단에 버튼입니다. 선수들을 모으기 위해 선택 모드에선 “Cancel” 타이틀을 가지지만 보기 모드에선 “Select” 타이틀을 가집니다.

Services

  • playersService — 선수의 목록을 조회하고 선수를 삭제하기 위해 사용됩니다.

Models

  • players — 사용자에 의해 생성된 선수의 배열입니다. playerTableView 에서 열로 볼 수 있습니다.
  • selectedPlayersDictionary — 선택된 선수의 열 인덱스를 키로 선택된 선수를 값으로 저장하는 딕셔너리 입니다.

Main.storyboard 를 열어보면 해당 view controller 에서 세개의 segue 를 수행할 수 있다는 것을 확인할 수 있습니다.

  • ConfirmPlayersSegueIdentifier — 모을 선수를 선택한 후에 속할 팀을 지정하는 화면으로 이동합니다.
  • PlayerAddSegueIdentifier — 새로운 선수를 생성할 수 있는 화면으로 이동합니다.
  • PlayerDetailSegueIdentifier — 선수의 상세정보를 볼 수 있는 화면으로 이동합니다.

이 View Controller 에 대한 model 을 검색하는 함수를 다음과 같이 제공합니다.

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

그리고 선수를 삭제하기 원하면 다음과 같이 수행합니다:

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

서비스 호출은 다음과 같습니다:

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

Add Player Screen — PlayerAddViewController

선수 추가하는 화면

선수 추가하는 화면

설명 (Description)

  • 이 화면은 선수를 생성하기 위해 사용됩니다.

UI Elements

  • playerNameTextField — 선수의 이름을 입력하기 위해 사용됩니다.
  • doneButton — 생성할 선수를 확인하고 서비스 호출을 시작하는데 사용되는 Bar 버튼 아이템.
  • loadingView — 서비스 호출이 되는 동안 보여지는 로딩화면 입니다.

Services

  • /api/players 를 포인트로 하는 StandardNetworkService 를 사용합니다. 선수를 추가하기 위해 POST 로 요청합니다.

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

Player Details Screen — PlayerDetailViewController

선수 상세화면

선수 상세화면

설명 (Description)

  • 선수의 정보를 보여주는 화면 (이름, 나이, 포지션, 기술, 그리고 원하는 팀)

UI elements

  • playerDetailTableView — 선수의 정보를 보여주는 table view 입니다.

Model

  • playerPlayerResponseModel 로 선수의 Model 입니다.

ViewController 에는 서비스가 없습니다. 선수의 정보를 업데이트 하는 요청은 PlayerEditViewController 로 부터 수신되고 위임을 통해 PlayerListViewController 로 전달됩니다.

섹션은 factory 패턴으로 만들어 집니다:

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

Edit Player Screen — PlayerEditViewController

선수 수정 — 원하는 포지션, 기술 또는 나이

선수 수정 — 원하는 포지션, 기술 또는 나이

설명 (Description)

  • 선수의 정보를 수정합니다.

UI Elements

  • playerEditTextField — 수정하기 원하는 선수의 정보를 입력하는 필드입니다.
  • playerTableView — 정보를 수정하기 위해 iOS General Setting 과 유사한 동작과 UI 를 원합니다. 이 table view 는 text field 가 있는 하나의 행 또는 선택 동작을 하는 여러개 행을 가집니다.
  • loadingView — 서버 호출을 하는 동안 보여지는 로딩화면 입니다.
  • doneButton — 수정 액션을 수행하는 UIBarButtonItem 입니다.

Services

  • StandardNetworkService 로 사용되는 Player API 업데이트:
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 — .text (키보드로 입력된 선수 정보) 또는 .selection (하나의 셀을 선택하여 선택된 예를 들어 원하는 포지선과 같은 선수 정보) 를 사용할 수 있는 enum 입니다.
  • player — 수정하기 원하는 선수
  • items — 원하는 포지션 또는 기술에 대한 가능한 모든 옵션의 해당 문자열의 배열 입니다. 문자가 수정되면 이 배열은 nil 입니다.

Confirm Screen — ConfirmPlayersViewController

선수 확인 화면

선수 확인 화면

설명 (Description)

  • Gather 화면에 가기 전에 팀에 선수를 배치할 수 있습니다.

UI elements

  • playerTableView — 선택한 선수들을 보여주기 위해 세개의 섹션 (Bench, Team A, 그리고 Team B) 으로 분리된 table view 입니다.
  • startGatherButton — 초기에는 비활성화 되어 있고 탭하면 팀 모집을 시작하기 위한 Network API 를 호출하는 액션을 트리거 하고 마지막으로 다음 화면을 푸쉬합니다.
  • loadingView — 서버 호출을 하는 동안 보여지는 로딩 화면입니다.

Services

  • Create Gather/api/gathers 로 POST 요청을 생성하여 새로운 수집을 추가합니다.
  • Add Player to Gather — 선수의 팀을 선택한 후에 api/gathers/{gather_id}/players/{player_id} 로 POST 요청으로 수집에 선수를 추가합니다.

Models

  • playersDictionary — 각 팀은 선수를 배열로 가지고 있으므로 딕셔너리는 키 (Team A, Team B, 또는 Bench) 로 팀을 가지며, 값으로는 선택된 선수들 (선수 배열) 로 되어 있습니다.

선택 (UI)이 완료되면 새로운 수집은 생성되고 각 선수는 팀에 할당됩니다.

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

선수를 추가하기 위한 for 루프는 아래와 같습니다:

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

Gather Screen — ConfirmPlayersViewController

Gather 화면

Gather 화면

설명 (Description)

  • 수집 모드 (gather mode) 에서 타이머를 시작/일시중지 또는 중단, 경기를 끝내는 애플리케이션의 주요 화면입니다.

UI elements

  • playerTableView — 2개의 섹션 (Team A, Team B) 로 나누어진 선수를 보여주는데 사용됩니다.
  • scoreLabelView — 하나는 Team A 그리고 다른 하나는 Team B 의 점수를 보여주는 2개의 라벨을 가지는 view 입니다.
  • scoreStepper — 팀에 대한 2개의 스테퍼를 가지고 있는 view 입니다.
  • timerLabel — mm:ss 포맷으로 남은 시간을 보여주는데 사용됩니다.
  • timerViewUIPickerView 를 사용하여 수집 시간을 선택하는 view 입니다.
  • timePickerView — 수집 시간을 선택하기 위해 2개의 요소 (분과 초)를 가진 피커 view 입니다.
  • actionTimerButton — 카운트다운 타이머 (다시시작, 일시정지, 그리고 시작)를 관리하는 상태 버튼 입니다.
  • loadingView — 서버 호출을 하는 동안 보여지는 로딩 view 입니다.

Services

  • Update Gather — 수집이 종료되었을 때 PUT 요청으로 승리 팀과 점수를 업데이트 합니다.

Models

  • GatherTime — Int 로 분과 초를 가지는 튜플 입니다.
  • gatherModel — 수집 ID 와 선수 팀 model 의 배열 (선수 응답 model 과 선수가 속한 팀) 을 포함합니다. 생성되고 ConfirmPlayersViewController 로 부터 전달됩니다.
  • timer — 수집의 분과 초를 카운트다운 하는데 사용됩니다.
  • timerState — 3개의 상태를 가질 수 있습니다: stoppedrunning, 그리고 paused. 값 중 하나가 설정되는 것을 관찰하여 actionTimerButton 의 버튼 이름을 변경할 수 있습니다. 일시정지 되면 버튼의 이름은 Resume 이 됩니다. 동작 중일 때는 버튼의 이름은 Pause 가 되고 타이머가 멈추면 Start 로 됩니다.

actionTimerButton 이 탭되면 타이머를 invalidate 또는 시작할 지 확인합니다:

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

타이머를 취소하기 위해 다음의 액션 구현을 가집니다:

@IBAction func cancelTimer(_ sender: Any) {
    // [1] Set the state
    timerState = .stopped
    timer.invalidate()
    // [2] Set to 10 minutes
    selectedTime = Constants.defaultTime
    timerView.isHidden = true
}

매 초마다 updateTimer 가 호출됩니다:

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

수집이 종료되기 전에 우승 팀을 확인합니다:

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)

서버 호출은 다음과 같습니다:

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

private 메서드 updateGatherendGather 로 부터 호출 됩니다:

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

비지니스 로직 테스트 (Testing Our Business Logic)

MVC 를 적용하는 첫번째 데모 앱 Football Gather 를 보았습니다. 물론, 코드를 리팩토링 하고 일부 로직을 더 좋게 만들고 분리하여 다른 클래스로 나눌 수 있지만 연습을 위해 이 코드 베이스를 유지할 것입니다.

클래스에 유닛 테스트를 어떻게 작성할 수 있는지 보시기 바랍니다. GatherViewController 를 기반으로 100% 에 가까운 코드 커버리지에 도달하려고 노력할 것 입니다.

먼저 GatherViewControllerMain storyboard 의 부분입니다. 편리함을 위해 식별자를 사용하고 storyboard.instantiateViewController 메서드로 인스턴스화 합니다. 이 로직을 위해 setUp 메서드를 사용합니다:

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

첫번째 테스트에서 모든 아웃렛 (outlet) 이 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)
}

이제 viewDidLoad 가 호출되는지 확인해 봅니다. 타이틀이 설정되고 일부 프로퍼티가 설정됩니다. public 파라미터를 확인합니다:

func testViewDidLoad_whenViewControllerIsLoadedFromStoryboard_setsVariables() {
    XCTAssertNotNil(sut.title)
    XCTAssertTrue(sut.timerView.isHidden)
    XCTAssertNotNil(sut.timePickerView.delegate)
}

timerView 는 사용자가 경기 타이머를 설정하면 팝업되는 view 입니다.

이제 table view 메서드를 유닛 테스트 해봅시다:

func testNumberOfSections_whenGatherModelIsSet_returnsTwoTeams() {
    XCTAssert(sut.playerTableView?.numberOfSections == Team.allCases.count - 1)
}

두 팀만 존재합니다: Team A 그리고 Team B. Bench 팀은 화면에 보이지 않습니다.

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

table view 는 팀 이름 (Team A 그리고 Team B) 으로 헤더 타이틀이 설정된 2개의 섹션을 가집니다.

행의 갯수를 확인하기 위해 mock 수집 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)
}

그리고 섹션에 행의 수를 확인하는 유닛 테스트와 섹션이 유효하지 않을 때 Nil 시나리오도 추가합니다.

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

func testNumberOfRowsInSection_whenGatherModelIsNil_returnsZero() {
    sut.gatherModel = nil
    XCTAssertEqual(sut.tableView(sut.playerTableView, numberOfRowsInSection: -1), 0)
}

선수 세부정보를 보여주기 위해 일반 table view 를 사용하여 선수의 이름으로 textLabel 로 설정하고 선수의 원하는 포지션으로 detailTextLabel 을 설정합니다.

다음 프로퍼티가 설정되었는지 확인합니다:

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

좋습니다! model 이 성공적으로 테스트 되었습니다. 어렵지는 않지만 비지니스 로직을 확인하기 위해 많은 UI stuff, 스토리보드를 사용하고 table view 위임과 데이터 소스를 확인하기 위해 mock stuff 를 가집니다.

계속해서 pickerView 메서드를 사용합니다.

func testPickerViewNumberOfComponents_returnsAllCountDownCases() {
    XCTAssertEqual(sut.timePickerView.numberOfComponents, GatherViewController.GatherCountDownTimerComponent.allCases.count)
}

컴포넌트의 수를 확인하는 방법은 enum GatherCountDownTimerComponent 을 public 으로 만드는 것 입니다.

컴포넌트에서 행의 갯수를 테스틑 하는 것은 선수 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)
}

행은 분과 초의 수와 같아야 합니다. 그리고 행의 타이틀도 같아야 합니다:

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

IBActions 을 어떻게 테스트 하는지 살펴봅시다.

setTimer 액션은 picker view (선택된 분과 초로 picker view 로 설정합니다. 설정된 시간이 없다면 기본적으로 10 분으로 합니다)를 구성하고 timerView 를 숨깁니다.

public 프로퍼티는 timerView 만 있습니다. setTimer 를 호출하고 timerView 가 숨겨져 있지 않은지 확인합니다:

func testSetTimer_whenActionIsSent_showsTimerView() {
    sut.setTimer(UIButton())
    XCTAssertFalse(sut.timerView.isHidden)
}

cancelTimer 에 대한 동일한 로직 이지만 이번엔 timerView 을 숨겨야 합니다:

func testCancelTimer_whenActionIsSent_hidesTimerView() {
    sut.cancelTimer(UIButton())
    XCTAssertTrue(sut.timerView.isHidden)
}

timerView 팝업에 버튼에 대해 유사한 메서드가 있습니다:

func testTimerCancel_whenActionIsSent_hidesTimerView() {
    sut.timerCancel(UIButton())
    XCTAssertTrue(sut.timerView.isHidden)
}

이것은 점점 더 어려워 집니다:

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

timerView 가 숨겨져 있고 분과 초가 설정되었는지 확인합니다. ViewController 의 기본 시간에 접근하지 못하므로 분 컴포넌트에 대한 XCTAssertGreaterThan 을 사용합니다.

타이머가 행 변경에 반응하는지 살펴 봅시다:

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

이 테스트에서 첫번째 행을 선택하므로 시간은 00:00 이 됩니다. 그러면 timerDone 메서드 와 actionTimer 를 호출합니다. 이것은 guard 구문에 빠질 것입니다:

guard selectedTime.minutes > 0 || selectedTime.seconds > 0 else {
    return
}

이제, 테스트의 즐거움 차례입니다:

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.fulfill()
    }
    
    waitForExpectations(timeout: 5, handler: nil)
}

초기 상태에서 actionTimerButton 타이틀이 Start 인지 확인합니다. actionTimer 호출 후에 actionTimerButton 타이틀은 Pause 여야 합니다 (타이머는 카운팅 되고 경기는 시작했기 때문입니다).

초 컴포넌트를 1로 설정합니다. 2초 후에는 멈춰야 하고 타이머는 무효화 되고 actionTimerButton 는 초기화 상태의 타이틀인 Start 가 되어야 합니다.

기대하는 결과를 기다리기 위해 5초의 타임아웃을 가지는 waitForExpectations 을 사용합니다.

ResumePause 의 변경을 확인하는 것은 유사합니다:

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.fulfill()
    }
    
    waitForExpectations(timeout: 5, handler: nil)
}

초 컴포넌트의 시간을 3초로 더 길게 설정합니다. actionTimer 를 호출하고 1초 후에 다시 함수를 호출합니다. 경기는 일시정지하고 actionTimerButtonResume 타이틀을 가집니다.

selector 가 호출되는지 확인하기 위해 timerLabel 문구를 확인할 수 있습니다:

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

이 테스트에서 분 컴포넌트가 0이 되면 초가 줄어드는지 확인하였습니다.

아웃렛 (outlet) 에 접근하면 더 쉽게 stepperDidChangeValue 위임 (delegate) 을 확인할 수 있습니다:

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

마지막으로 GatherViewController 에서 가장 어렵고 가장 중요한 메서드는 아마도 endGather 메서드 입니다. 다음은 수집 model 을 업데이트 하는 서비스 호출을 수행합니다. 경기의 winnerTeam 과 점수를 전달합니다.

이 메서드는 매우 크고 하나 이상의 작업을 수행하고 private 입니다. (하나의 예제로서 사용합니다: 함수는 크지 않고 하나의 작업을 수행해야 합니다!).

이 함수의 응답은 아래에 자세히 나와있습니다. endGather 은 다음을 수행합니다:

  • scoreLabelViews 에서 점수를 가져옵니다.
  • 점수를 비교하여 우승 팀을 계산합니다.
  • 서비스 호출에 대한 GatherModel 을 생성합니다.
  • 로딩 스피너를 보여줍니다.
  • updateGather 서비스 호출을 수행합니다.
  • 로딩 스피너를 숨깁니다.
  • 성공과 실패를 처리합니다.
  • 성공할 경우 view controller 는 PlayerListViewController 로 pop 됩니다 (이 view 는 스택에 있어야 합니다).
  • 실패할 경우 alert 을 보여줍니다.

이 모든 테스트를 어떻게 수행해야 할까요? (다시 말하지만 이 함수는 여러 함수로 분리되어야 합니다.)

천천히 해보도록 합시다.

mock 서비스를 생성하고 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))

성공 처리를 위한 테스트는 PlayerListViewController 클래스 대신에 프로토콜을 사용하고 테스트 클래스에 mock 합니다:

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?.fulfill()
        }
    }
}

이것은 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)

초기 viewState 를 확인합니다. true 여야 합니다.

유닛 테스트의 나머지는 다음과 같습니다:

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

mock 엔드 포인트를 준비하고 endGather 를 호출합니다.

확인창이 화면에 나타나야 합니다 (UIAlertController). 수집을 종료하기 위해 OK 를 탭합니다.

waitForExpectation 을 사용하여 클로저가 실행될 때까지 기다리고 성공을 확인합니다; viewState 는 이제 false 이어야 합니다.

endGather 는 private 메서드 이므로 이 메서드를 호출하는 IBAction 을 사용해야 합니다. 그리고 alert controller 에서 OK 를 탭하기 위해 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])
    }
}

Swift 다음 버전에서 이 유닛 테스트가 동작하리라 보장할 수 없습니다.

주요지표 (Key Metrics)

코드 라인 수 (Lines of code)

Untitled

유닛 테스트 (Unit tests)

Untitled

빌드 시간 (Build times)

Untitled

  • 테스트는 iOS 14.3, Xcode 12.4 그리고 i9 MacBook Pro 2019 사양에 iPhone 8 시뮬레이터에서 실행했습니다.

결론 (Conclusion)

MVC 는 iOS 개발에서 가장 잘 알려진 아키텍처 패턴입니다.

이 글에서 작은 애플리케이션에 적용해 보았습니다. 각 화면을 View Controller 로 구현하는 간단한 접근법을 사용하였습니다.

실무에서는 많은 동작을 가지는 화면에 대해 이 접근 방식을 따르면 안됩니다. 대신에 분리해야 합니다. 한가지 방법으로는 child view controller 를 사용하는 것입니다.

앱의 각 화면을 가지고 와서 역할을 설명하고 간단한 설명, 화면의 일부 인 UI 요소, 그리고 controller 가 상호 작용하는 model 그리고 주요 메서드의 코드 일부분을 보았습니다.

마지막으로 주요 클래스 인 GatherViewController 의 유닛 테스트를 작성하였습니다. 기대한 만큼 쉽지 않았습니다. 좋지 않은 연습으로 UIAlertController 의 private 메서드를 사용해야 했습니다. Apple 은 후에 릴리즈에서 해당 클래스의 API 를 public 으로 변경하여 유닛 테스트를 중단할 수 있습니다.

그러나 잘 사용하며 MVC 는 여전히 iOS 앱 개발에서 좋은 방식입니다.

주요 지표를 보고 아직 많은 얘기를 할 수 없습니다. 다른 패턴은 어떤지 살펴볼 필요가 있습니다. MVC 에서 코드와 클래스 수가 훨씬 적다고 추측할 수 있습니다. 다른 패턴은 더 많은 레이어를 도입하므로 더 많은 코드 수를 가집니다.

끝까지 읽어 주셔서 감사합니다! 아래 유용한 링크도 참고 바랍니다.

유용한 링크 (Useful Links)