Search

Unit Test 도전해보기

 들어가며

어쩌다가 Unit Test를 하고 싶다는 생각이 들었는가?
“애니또” 프로젝트는 방에 참가해서 다른 사용자들이랑 같이 마니또를 진행한다는 앱 특성상 방을 생성하지 않으면 디버깅하기가 힘듭니다. 그래서 매번 톡방에 방 참여코드를 올려두고 테스트 방 인원을 모집해야하는거죠.
테스트 인원 모집중…
또한, 진행 기간이 정해져있다는 마니또의 특성으로 인해서 진행 기간동안에만 확인할 수 있는 기능이 있고, 진행 기간이 끝나야지만 확인할 수 있는 기능들이 있었습니다. 기능을 확인하기 위해서 매번 확인용 테스트 방을 만들어야 했습니다.
하루는 “이렇게 매번 하는게 맞나?” 라는 생각이 들었습니다. 만약 다른 바빠서 방 참여를 안해주면 나는 테스트를 진행을 못하는건데, “이게 맞나?” 라는 생각이 격하게 들었습니다. 그들이 없어도 디버깅을 할 수 있는 방법, 해당 기간이 아니더라도 그 기간인척하고 테스트할 수 있는 방법이 분명이 있을텐데라는 생각이 들었고, 유닛 테스트를 찾게 되었습니다.
더 안정적인 프로젝트를 위해서 유닛 테스트를 진행하고 싶었습니다. 앱 스토어에 올라가고 나서 앱이 뻑나면 어쩌지 라는 고민.. 더이상 그만하고 싶습니다.
그렇다면 고민을 없애줄 해결법을 배워봅시다. (ง ˘ω˘ )ว

 MVC 패턴과 Unit Test

현재, 프로젝트에서는 MVC 패턴을 사용하고 있습니다. 그래서 View와 Controller를 분리해둔 상태이지요. 저희 프로젝트에서 Controller는 주로 이런 일을 담당합니다.
Controller의 역할
⓵ 뷰 인터렉션에 대한 응답
⓶ 네트워크 관련 로직
⓷ life cycle
⓸ flow logic
⓹ datasource, delegate 메서드
⓺ Maps, Notification, Photo 같은 걸 다루는 로직
이 중에서 저는 ⓵, ⓶, ⓹, ⓺번 로직들에 대한 테스트를 진행하고 싶었습니다. 가장 디버깅할 일이 많고 예상하지 못한 값으로 인해서 문제가 생기면 어쩌지 하고 걱정이 제일 많이 되는 부분이거든요.
단위 테스트 코드를 작성을 하려고 생각해보니 이전에 이런 글을 읽었던게 생각이 났습니다.
MVC 패턴은 단위 테스트에 적합하지 않다.
적합하지 않은 이유가 Controller 자체가 life cycle 관리하는 메서드도 가지고 있기 때문에 메서드들의 흐름이 복잡해져서 MVC 패턴은 적합하지 않다고 했던걸로 가물가물 기억이 납니다.
그렇다면 진짜 적합하지 않은지 테스트 코드를 직접 짜면서 알아보려고 합니다.
대부분의 Unit Test 예시 코드들이 ViewModel, Presenter 내부 로직을 테스트해보는 식으로 구현이 되어 있어서 Controller를 테스트를 하는 예시를 여러 방면으로 찾아다녔습니다.

Unit Test를 진행해보자.

해당 포스팅을 따라서 Unit Test를 진행해보았습니다. 포스팅에서는 현금 인출, 입금 관련한 프로젝트를 만듭니다. 진행하면서 수정한 부분들도 일부 있습니다.

❏ Model Unit Test

1.
먼저 Account 라는 프로젝트를 하나 만들었습니다. 저는 Test를 진행할거라서 하단에 Include Tests 버튼을 활성화시켰습니다.
만약, 이미 프로젝트가 있는 상태라면 프로젝트에서 Targets를 추가해주세요, Unit Testing Bundle를 추가하시면 됩니다.
2.
ViewController 내부에 Model 타입을 넣어줄겁니다.
포스팅 작성자는 ViewController 내부에는 하나의 model type만 reference 해야 하며, 그 방식이 model protocol로 되어야 한다고 말합니다.
이렇게 Model과 ViewController 사이에 ModelProtocol를 두는 겁니다.
이 방식은 model 구현을 하기 전에 이뤄져야 하고, 실제 구현에서 ViewController를 decouple 시켜주는 역할을 한다고 합니다.
위에서 말했듯이 해당 포스팅은 현금 인출, 입금 관련한 프로젝트를 만들고 있습니다. 따라서 Controller는 계좌에서 돈이 인출되고 입금되는 기능에 대해서만 관심을 가지면 됩니다. 이걸 Protocol 형태로 제공해주는 겁니다.
protocol AccountModelProtocol { func transact(deposit: Double, withdraw: Double) -> Double }
Swift
복사
3.
구체적인 Model를 구현해봅시다.
Controller와 연결할 Protocol를 위해서 만들었습니다. 이제 그 Protocol를 구현할 구체적인 Class를 하나 구성해볼겁니다. transact 메서드는 Double 데이터 유형의 잔액을 인출, 입금하는 기능을 합니다.
final class AccountModel: AccountModelProtocol { var balance: Double = 0 func transact(deposit: Double, withdraw: Double) -> Double { balance += deposit balance -= withdraw return balance } }
Swift
복사
4.
이젠 모델 단위의 테스트 케이스를 작성해보겠습니다.
저는 AccountTest.swift 라고 써져있는 파일에 테스트 케이스를 작성해보겠습니다. 아마 내부 템플릿은 완성이 되어있을겁니다.
그리고 내부에 model 변수를 만들고 setUpWithError 시에 model에 AccountModel를 넣어주었습니다. 그리고 tearDownWithError 시에 model 값이 nil 되도록 했습니다.
setUpWithError : 각 테스트 메서드에 초기 상태를 Set up 하는 부분
tearDownWithError : clean up 하는 부분
final class AccountTests: XCTestCase { var model: AccountModel? override func setUpWithError() throws { model = AccountModel() } override func tearDownWithError() throws { model = nil } func testExample() throws { } func testPerformanceExample() throws { self.measure { } } }
Swift
복사
AccountTests 마지막 부분에 현금 인출, 입금에 대한 테스트를 진행하는 메서드를 추가했습니다. 어떤 테스트를 진행하는지 직관적으로 이해하는 걸 돕기 위해서 한글을 함께 사용해서 네이밍을 했어요.
func test_account_입금_인출_금액이_제대로_반영되는가() { let result = model?.transact(deposit: 10, withdraw: 6) XCTAssertEqual(4, result) }
Swift
복사
5.
테스트 해보기
옆에 있는 다이아몬드 모양을 누르면 테스트를 진행합니다.
테스트가 성공한다면, 테스트 성공 표시와 함께 다이아몬드 색깔이 초록색으로 변경됩니다.
반대로 실패하게 된다면, 빨간색으로 색깔이 변경되고 왜 테스트에 실패했는지에 대한 이유를 알려줍니다.
테스트에 실패한 이유를 의미있게 알기 위해서는 Assertion을 올바르게 사용해야 합니다. 그게 무슨 뜻이냐?
위에 있는 테스트 케이스에 XCTAssertTrue 를 사용해서 6 == result 인지도 확인하려고 합니다. 물론 아니라서 fail이 뜨겠죠.
func test_account_입금_인출_금액이_제대로_반영되는가() { let result = model?.transact(deposit: 10, withdraw: 6) XCTAssertTrue(6 == result) XCTAssertEqual(6, result) }
Swift
복사
각각 fail이 나는 이유를 보면 XCTAssertEqual 은 자세하게 나와있지만,
XCTAssertTrue 은 failed가 되었다는 것만 알 수 있습니다.
테스트를 통해서 왜 잘못됐는지에 대한 정보를 제대로 알 수 있는 Assertion를 사용하면 의미있는 테스트 결과를 얻을 수 있겠죠. 아무튼 계속 진행해보자면!

❏ View Unit Test

이번에는 ViewController를 테스트 해보려고 합니다.
1.
ViewController 내부에 View 타입을 넣어줄겁니다.
이전에 Model에서 사용했던 거처럼 간단한 프로토콜을 만들어서 View를 만들겁니다. 해당 포스팅에 따르면 프로토콜을 만드는 것이 MVC 패턴을 이해하는데 도움을 주고 쉬운 테스트를 진행하는데 도움을 준다고 합니다. 이전에 ModelProtocol을 사용해서 쉬운 테스트를 진행한 것처럼 ViewProtocol도 비슷한 역할을 하나보네요.
ViewController 에서 View Protocol를 사용해서 함수를 구성하는 겁니다.
protocol AccountViewProtocol { var withdrawalValue: String { get } var depositValue: String { get } func setBalanceValue(balanceAmount: String) func setController(controller: ViewController) }
Swift
복사
2.
Protocol로 View Controller를 구성해봅니다.
ViewProtocol과 ModelProtocol를 ViewController에 넣어줍니다.
import UIKit final class ViewController: UIViewController { var accountView: AccountViewProtocol? var accountModel: AccountModelProtocol? override func viewDidLoad() { super.viewDidLoad() } }
Swift
복사
3.
의존성 주입을 위해서 ViewController에 Setter Method를 추가해봅시다.
Controller가 사용자 인터페이스와 독립적으로 실행되도록 만들어주기 때문에 Unit Test 시에 중요합니다. setter method는 internal하게 만들면 이후에 테스트를 위한 Mock Value를 넣어서 사용할 수 있습니다.
func setAccountView(_ accountView :AccountViewProtocol){ self.accountView = accountView self.accountView?.setController(controller: self) } func setAccountModel(_ accountModel: AccountModelProtocol){ self.accountModel = accountModel }
Swift
복사
4.
ViewController에 Protocol를 위한 기본값을 설정합니다.
뷰가 설정되지 않은 경우에 뷰가 로드되면 기본값을 설정합니다. 이 경우는 테스트를 하지 않고 앱을 정상적으로 실행하는 경우입니다.
fileprivate func setupView() { if let accountView = self.view as? AccountViewProtocol { self.setAccountView(accountView) } } fileprivate func setupModel() { if self.accountModel == nil { self.setAccountModel(AccountModel()) } }
Swift
복사
setupView 에서는 현재 뷰가 설정되지 않으면 뷰를 설정합니다. setupModel 에서는 accountModel이 주입되지 않은 경우 기본 모델을 주입합니다.
override func viewDidLoad() { super.viewDidLoad() self.setupView() self.setupModel() }
Swift
복사
5.
Presentation를 위한 로직을 추가합니다.
transact 를 처리하는 기능을 추가합니다. 입출금 금액을 String 형식으로 가져와서 Double 형식으로 변환하여 잔액을 반환하는 기능을 만들겁니다.
func processTransactionRequest() { let depositString = self.accountView?.depositValue let withdrawalString = self.accountView?.withdrawalValue let deposit = self.getValue(depositString) let withdrawal = self.getValue(withdrawalString) let balance = self.accountModel?.transact(deposit: deposit, withdraw: withdrawal) self.accountView?.setBalanceValue(balanceAmount: String(format:"$%.02f", balance ?? 0)) } private func getValue(_ text: String?) -> Double { if let text = text { return Double(text) ?? 0 } return 0 }
Swift
복사
6.
ViewController를 위한 Unit Test를 진행해봅시다.
ViewControllerTests.swift 파일을 하나 만들어볼게요. 하단에 있는 파일로 만드시면 아까 템플릿을 가진 파일을 만드실 수 있습니다.
그리고 파일 내부를 이렇게 구성했습니다. 마지막 함수를 통해서 트랜잭션 요청을 잘 처리하고 있는지 확인할 겁니다.
import XCTest @testable import Account final class ViewControllerTests: XCTestCase { let viewController: ViewController = ViewController() override func setUpWithError() throws { viewController.setAccountModel(AccountModel()) } override func tearDownWithError() throws { } func testExample() throws { } func testPerformanceExample() throws { self.measure { } } func test_viewcontroller_트랜잭션요청을_잘처리하는가() { } }
Swift
복사
7.
Mock View를 사용해서 ViewController 테스트 하기
실제 뷰 없이 ViewController를 테스트해보려고 합니다. Mock View를 하나 만들고 해당 뷰를 ViewController에 주입할 겁니다. Mock View는 아까 만들어둔 ViewProtocol를 사용해서 만들 수 있습니다.
import Foundation @testable import Account final class MockView: AccountViewProtocol { // MARK: - property var balance: String? var withdrawalValue: String { return "10" } var depositValue: String { return "11" } // MARK: - method func setController(controller: Account.ViewController) { print(controller) } func setBalanceValue(balanceAmount: String) { self.balance = balanceAmount } }
Swift
복사
8.
ViewController에 Mock View를 주입합니다.
Controller에 mockView와 model를 넣어서 단위 테스트 기능을 구현해보겠습니다. 먼저 초기 상태를 셋팅해주겠습니다.
final class ViewControllerTests: XCTestCase { let viewController: ViewController = ViewController() let mockView: MockView = MockView() override func setUpWithError() throws { self.viewController.setAccountView(self.mockView) self.viewController.setAccountModel(AccountModel()) } }
Swift
복사
test_viewcontroller_트랜잭션요청을_잘처리하는가 메서드에 잔액 문자열을 확인해서 processTransactionRequest 가 잘 작동했는지 테스트해보겠습니다.
func test_viewcontroller_트랜잭션요청을_잘처리하는가() { self.viewController.processTransactionRequest() XCTAssertEqual("$1.00", self.mockView.balance) }
Swift
복사
9.
ViewController Unit Test 결과를 확인합니다.
Controller 내부에 있는 로직으로 Unit Test를 진행했습니다. 실제 UI를 제공하지 않고도 구성 요소에 대한 단위 테스트를 구현할 수 있었습니다. Model과 Controller 로직은 캡슐화하면서 말이죠!

❏ Real UI View

원래 예제에서는 Xib 파일을 만들어서 진행하지만 저는 Code-base로 짜볼게요. 그 이유는 제가 현재 진행하고 있는 프로젝트가 Code-based로 되어 있기 때문에, 이 방식으로 하는 것이 현재 제가 진행하고 있는 프로젝트에 적용해보기 쉬울 것 같습니다. ٩( ᐛ )و
1.
AccountView라는 UIView를 하나 만들어줍니다.
AccountViewAccountViewProtocol를 conform합니다.
import UIKit final class AccountView: UIView, AccountViewProtocol { // MARK: - property var withdrawalValue: String { "" } var depositValue: String { "" } // MARK: - init override init(frame: CGRect) { super.init(frame: .zero) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - func func setBalanceValue(balanceAmount: String) { } func setController(controller: ViewController) { } }
Swift
복사
2.
내부 UI Component를 구성해줍니다.
직접 레이아웃, Configuration를 잡는 것이 귀찮다면 Xib를 만들어도 좋습니다!
import UIKit final class AccountView: UIView, AccountViewProtocol { // MARK: - ui component private let balanceTitleLabel: UILabel = UILabel() private let withdrawalTitleLabel: UILabel = UILabel() private let depositTitleLabel: UILabel = UILabel() private let balanceValueLabel: UILabel = UILabel() private let withdrawalValueTextField: UITextField = UITextField() private let depositValueTextField: UITextField = UITextField() private let submitButton: UIButton = UIButton(type: .system) // MARK: - property private var controller: ViewController? var withdrawalValue: String { "" } var depositValue: String { "" } // MARK: - init override init(frame: CGRect) { super.init(frame: .zero) self.setupLayout() self.configureUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - func private func setupLayout() { [self.balanceTitleLabel, self.withdrawalTitleLabel, self.depositTitleLabel, self.balanceValueLabel, self.withdrawalValueTextField, self.depositValueTextField, self.submitButton].forEach { self.addSubview($0) $0.translatesAutoresizingMaskIntoConstraints = false } NSLayoutConstraint.activate([ self.balanceTitleLabel.topAnchor.constraint(equalTo: self.safeAreaLayoutGuide.topAnchor, constant: 20), self.balanceTitleLabel.leadingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.leadingAnchor, constant: 20), self.balanceValueLabel.topAnchor.constraint(equalTo: self.balanceTitleLabel.bottomAnchor, constant: 10), self.balanceValueLabel.leadingAnchor.constraint(equalTo: self.balanceTitleLabel.leadingAnchor), self.depositTitleLabel.topAnchor.constraint(equalTo: self.balanceValueLabel.bottomAnchor, constant: 20), self.depositTitleLabel.leadingAnchor.constraint(equalTo: self.balanceTitleLabel.leadingAnchor), self.depositValueTextField.topAnchor.constraint(equalTo: self.depositTitleLabel.bottomAnchor, constant: 5), self.depositValueTextField.leadingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.leadingAnchor, constant: 20), self.depositValueTextField.trailingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.trailingAnchor, constant: -20), self.depositValueTextField.heightAnchor.constraint(equalToConstant: 30), self.withdrawalTitleLabel.topAnchor.constraint(equalTo: self.depositValueTextField.bottomAnchor, constant: 10), self.withdrawalTitleLabel.leadingAnchor.constraint(equalTo: self.balanceTitleLabel.leadingAnchor), self.withdrawalValueTextField.topAnchor.constraint(equalTo: self.withdrawalTitleLabel.bottomAnchor, constant: 5), self.withdrawalValueTextField.leadingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.leadingAnchor, constant: 20), self.withdrawalValueTextField.trailingAnchor.constraint(equalTo: self.safeAreaLayoutGuide.trailingAnchor, constant: -20), self.withdrawalValueTextField.heightAnchor.constraint(equalToConstant: 30), self.submitButton.topAnchor.constraint(equalTo: self.withdrawalValueTextField.bottomAnchor, constant: 30), self.submitButton.centerXAnchor.constraint(equalTo: self.centerXAnchor) ]) } private func configureUI() { self.backgroundColor = .white self.balanceTitleLabel.text = "balance" self.balanceValueLabel.text = "$0" self.depositTitleLabel.text = "deposit" self.withdrawalTitleLabel.text = "withdrawal" self.submitButton.setTitle("submit", for: .normal) self.balanceValueLabel.font = .systemFont(ofSize: 30) self.depositValueTextField.borderStyle = .bezel self.withdrawalValueTextField.borderStyle = .bezel } func setBalanceValue(balanceAmount: String) { } func setController(controller: ViewController) { } }
Swift
복사
3.
Protocol 변수, 함수 내부에 코드를 구성합니다.
withdrawal, deposit 값은 textfield에서 Value를 가져가도록 했습니다.
var withdrawalValue: String { return self.withdrawalValueTextField.text ?? "" } var depositValue: String { return self.depositValueTextField.text ?? "" }
Swift
복사
balance 값이 설정되면 balanceLabel에 값이 들어가도록 코드를 짰습니다.
func setBalanceValue(balanceAmount: String) { self.balanceValueLabel.text = balanceAmount } func setController(controller: ViewController) { self.controller = controller }
Swift
복사
4.
submit 기능을 구현합니다.
뷰 내부에 submit 버튼이 있어서 해당 버튼을 누르면 balance 값을 라벨에 display 해줘야 합니다. 버튼을 눌렀을 때 transact가 실행되도록 코드를 짜줍니다.
private func setupButtonAction() { let submitAction = UIAction { [weak self] _ in self?.controller?.processTransactionRequest() } self.submitButton.addAction(submitAction, for: .touchUpInside) }
Swift
복사
5.
ViewController에 View가 보일 수 있도록 띄워줍니다.
override func loadView() { self.view = AccountView() }
Swift
복사
6.
프로젝트를 빌드해봅니다.

 +⍺

유닛 테스트가 가능한 MVC 프로젝트를 만들었지만, 만들면서 의문인 부분이 있었습니다.
View가 Controller를 알아도 되는가..?
해당 미디엄에 같은 생각을 하고 계신 분이 댓글을 달아뒀더라구요. 제 생각이 틀리진 않았다는 확신이 들어서 현재 만들어둔 프로젝트를 조금 더 변형해보기로 했습니다. Delegate를 사용하는 방식으로요!
일단 View 내부에서 Controller를 연결해둔 부분을 지웠습니다. 이런 부분들이요.
private var controller: ViewController? func setController(controller: ViewController) { self.controller = controller }
Swift
복사
그리고 delegate를 통해서 View에서 일어난 일을 Controller가 대신 처리할 수 있도록 했습니다.
AccountView 내부에는 configureDelegation를 작성하고, ViewController에도 동일하게 작성해줬습니다. ViewController에서 AccountViewDelegate를 conform했기 때문에 위임이 가능합니다.
// AccountView.swift func configureDelegation(_ delegate: AccountViewDelegate) { self.delegate = delegate } // ViewController.swift private func configureDelegation() { self.contentView.configureDelegation(self) }
Swift
복사
이제 연결을 마쳤으니 대신할 일을 줘야겠죠? 넘겨주는 코드를 짜볼게요. 지금 대신할 일은 submit 버튼을 눌렀을 때 processTransactionRequest 를 실행시키는 일입니다. 따라서, 버튼을 누르면 “버튼을 눌렀으니 대신 해줘!” 라는 트리거를 만들어 보겠습니다.
먼저, Protocol 내부에 함수를 만들어줬어요. 제출 버튼을 눌렀을 때 발생하는 일이니, “제출 버튼 누름!” 이라는 함수를 만들었습니다.
protocol AccountViewDelegate: AnyObject { func submitButtonDidTap() }
Swift
복사
그리고 submit 버튼의 Action에서 이를 실행하도록 했습니다. 이제 버튼을 누르면 해당 함수가 호출될겁니다.
private func setupButtonAction() { let submitAction = UIAction { [weak self] _ in self?.delegate?.submitButtonDidTap() } self.submitButton.addAction(submitAction, for: .touchUpInside) }
Swift
복사
함수가 호출되면 실행할 함수를 가지고 있는 ViewController가 submitButtonDidTap 메서드를 실행할겁니다.
해당 메서드 내부에 뭘 넣어줘야 할까요? processTransactionRequest 를 실행시키는 일을 했었으니깐 processTransacionRequest 가 호출되도록 해볼게요.
extension ViewController: AccountViewDelegate { func submitButtonDidTap() { self.processTransactionRequest() } }
Swift
복사
한 번 실행해보겠습니다. 잘 됩니다.

 마치며

MVC 패턴으로 Unit Test를 진행해봤습니다. 실제로 구현해보니 재밌네요. 얼른 프로젝트도 적용해보고 싶습니다. 하지만 MVC 패턴 내부에 적용하는 건 생각을 조금 더 해봐야겠습니다. untestable한 코드에 unit test를 작성하는 것은 오히려 시간과 에너지를 버리는 일일 수 있으니깐요. 신중하게 여러 아키텍쳐들을 조금 더 둘러보고서 진행해봐야겠습니다.
테스트 하는 부분이 다른 구성 요소와 독립적으로 존재해야 테스트하기 좋다고 들었는데, 지금 제가 사용하는 Controller, 예시에서의 Controller는 독립적인 느낌은 아니어서..
프로토콜로 다른 클래스와 분리된 클래스를 하나 만드는 게 방법일 것 같습니다. 그런 공간을 사용한게 MVP에서 Presenter, MVVM에서 ViewModel인 게 아닐까 하는 추측은 해보지만 아직 자세하게 공부한 건 아니라서 자세하게 공부하러 갑니다.. ٩( 'ω' )و
 깃허브 레포에서 코드 보려면
실제 프로젝트에 적용하고 나면 해당 포스팅에도 링크 첨부해두겠습니다.

 참고자료