(Suyeol Jeon)
SearchRepositoryViewController 테스트
잘 테스트되고 있는 서비스를 가지고 있다.
그 서비스를 뷰 컨트롤러에서 잘 쓰고 있는지를 테스트한다.
searchBarDidClick 관련된 Delegate 메소드가 호출되었을 때
잘 만들어 놓은 RepositoryService의 search라는 메소드를 잘 호출하는지를 테스트 해볼 것이다.
⭐ 이때 RepositoryService 대신에 테스트할 객체 RepositoryServiceStub을 만들어서 테스트한다.
⭐ RepositoryServiceProtocol을 만들어서 RepositoryServiceStub가 준수하게 하여 간접화시킨다.
"보일러 플레이트가 많습니다. 역시 스토리보드는 안좋은 것 같아요"
보일러 플레이트란?
컴퓨터 프로그래밍에서 보일러플레이트 또는 보일러플레이트 코드라고 부르는 것은
최소한의 변경으로 여러곳에서 재사용되며, 반복적으로 비슷한 형태를 띄는 코드를 말한다.
즉 프로그래밍에서 상용구를 의미한다.
보일러 플레이트가 많은데 스토리보드가 안좋다는 건 무슨 말이지?
아래의 문구를 읽어보면 무슨 뜻인지 알 수 있다.
"보일러플레이트는 프로그래밍에서 상용구 코드를 말합니다.
어떤 일을 하기위해서 꼭 작성해야 하는 코드로서
자바에서는 클래스의 getter, setter 메소드를 말합니다.
자바에서 getter, setter는 꼭 필요하지만
코드의 길이를 길어지게 하고 개발자에게 의미없는 노동을 강요하게 됩니다."
즉, 스토리보드를 쓰므로서 의미없이 써야하는 코드가 길어지는 단점을 지적하는 것이다.
given은 주어진 환경이고 -> 생성한 뷰 컨트롤러 코드
when은 트리거이다. -> searchBarSearchButtonClicked 메서드
then은 트리거로 발생하는 이벤트를 의미한다. -> 4번
final class RepositoryServiceStub {
}
아래 이미지와 같이 만든 RepositoryServiceStub 클래스의 인스턴스를 생성해서
viewController의 repositoryService에 삽입시켜준다.
XCTAssertEqual(self.repositoryService.searchParameters?.keyword, "여기에 비교할 키워드를 설정한다.")
// LetsGitHubSearch/LetsGitHubSearch/Sources/Services/RepositoryService.swift
import Alamofire
protocol RepositoryServiceProtocol {
@discardableResult
func search(keyword: String, completionHandler: @escaping (Result<RepositorySearchResult>) -> Void) -> DataRequest
}
final class RepositoryService: RepositoryServiceProtocol {
private let sessionManager: SessionManagerProtocol
init(sessionManager: SessionManagerProtocol) {
self.sessionManager = sessionManager
}
@discardableResult
func search(keyword: String, completionHandler: @escaping (Result<RepositorySearchResult>) -> Void) -> DataRequest {
let url = "https://api.github.com/search/repositories"
let parameters: Parameters = ["q": keyword]
return self.sessionManager.request(url, method: .get, parameters: parameters, encoding: URLEncoding(), headers: nil)
.responseData { response in
let decoder = JSONDecoder()
let result = response.result.flatMap {
try decoder.decode(RepositorySearchResult.self, from: $0)
}
completionHandler(result)
}
}
}
final class RepositoryServiceStub: RepositoryServiceProtocol{
var searchParameters: String?
@discardableResult
func search(keyword: String, completionHandler: @escaping (Result<RepositorySearchResult>)) -> Void) -> DataRequest{
self.searchParameters = keyword
return DataRequest.init(session: URLSession(), requestTask: .data(nil, nil))
}
}
@discardableResult 속성 조사하기!
XCTAssertEqual(service.searchParameters, "Hello, world!")
concrete class :
모든 연산에 대한 구현을 가지고 있는 클래스를 concrete class 라고 할 수 있다.
추상 클래스가 아닌 클래스는 모두 concrete class라고 할 수 있다.
정의한 모든 연산에 대한 구현을 가지고 있는 완전한 클래스이므로
우리는 이 클래스의 인스턴스를 만들 수 있다.
Tip : 프로토콜 타입 변수 혹은 상수는 해당 프로토콜을 준수하는 타입의 어떤 인스턴스라도 할당할 수 있다.
class SearchRepositoryViewController: UIViewController {
var repositoryService: RepositoryServiceProtocol!
...
}
@testable import Alamofire
@testable import LetsGitHubSearch
final class RepositoryServiceStub: RepositoryServiceProtocol {
var searchParameters: (keyword: String, completionHandler: (Result<RepositorySearchResult>) -> Void)?
@discardableResult
func search(keyword: String, completionHandler: @escaping (Result<RepositorySearchResult>) -> Void) -> DataRequest {
self.searchParameters = (keyword, completionHandler)
return DataRequest(session: URLSession(), requestTask: .data(nil, nil))
}
}
5번에서 만든 RepositoryServiceProtocol 프로토콜을 RepositoryService 클래스에 적용시킨다.
영상에서 스토리보드의 viewController의 인스턴스를 얻어올 때는
해당 viewController의 identifier가 있어야하는데 이를 깜빡하면 테스트에 실패한다. 주의하자!
얻어오고자하는 SearchRepositoryViewController의 identifier를 작성한다.
viewController.loadViewIfNeeded()
loadViewIfNeeded() 조사하기
테스트 코드에 통과하였다.
테스트 코드가 잘작성되었는지를 확인하기 위해서 잘못된 키워드를 넣어서 테스트에 실패하는지 확인해봐야한다. 영상에서 잘못된 키워드를 넣으면 테스트에 실패하게 된다.
Code Coverage가 뭐지? 커버가 되다? 조사하기
코드 커버리지는 소프트웨어의 테스트 케이스가 얼마나 충족되었는지를 나타내는 지표 중 하나이다. 테스트를 진행하였을 때 ‘코드 자체가 얼마나 실행되었느냐’는 것이고,
이는 수치를 통해 확인할 수 있다.
코드 커버리지는 소스 코드를 기반으로 수행하는 화이트 박스 테스트를 통해 측정한다.
처음 로딩할 때 인디케이터가 도는 것의 상태가 반전되어 있는 상태였다.
그 반전되어 있는 인디케이터 상태에 대한 테스트를 작성하면서 인디케이터를 고쳐보도록 하자.
위에서 뷰컨트롤러를 테스트하기 위해 작성했던 testSearchBar_search() 메소드를 복사해서 붙여 놓은 후 이 함수의 이름을 testActivityIndicator_animating_whenLoading()으로 변경하자.
검색중일 때 Activity indicator가 로딩되는 것에 대한 테스트를 해보자.
현재 Activity indicator가 정상적으로 작동하고 있지 않기 때문에 아래와 같은 테스트는 실패해야한다.
testActivityIndicator_animating_whenLoading() 함수 안의
복사 붙여넣기로 인해서 then에 있는 기존의 키워드 파라메터를 위한 테스트 코드를
아래 코드와 같이 바꿔주자.
XCTAssertEqual(
viewController.activityIndicatorView.isAnimating,
true
)
위의 setLoading 메서드에 잘못된 코드가 들어있다.
stopAnimating()과 startAnimating()을 바꾸면 정상적인 코드가 된다.
이제 테스트를 하면 테스트에 통과한다.
왜냐하면 위의 이미지에서 위에 있는 빨간색 네모 코드에 대한 테스트만 2번에서 작성하였기 때문이다.
이제 아래있는 빨간색 네모 코드에 대한 테스트 코드도 작성을 해보도록 하자.
XCTAssertEqual(
viewController.activityIndicatorView.isAnimating,
false
)
컴플리션 핸들러를 직접 호출하기 위해서는 컴플리션 핸들러의 참조를 가지고 있어야 한다.
XCTAssertEqual(service.searchParameters?.keyword, "Hello, world!")
service.searchParameters?.completionHandler(
.failure(NSError(domain: "", code: 0, userInfo: nil))
)
이 11번까지의 과정이 뷰 테스트이다.
뷰를 테스트하는 것은 눈에 보이는 것을 테스트하는 것이기 때문에
많은 사람들이 막연하게 생각하고 어려워한다.
설마 layout도 테스트해야하는지 물어본다.(그래서??? 해야함????)
뷰를 테스트할 때는 layout을 테스트할 때와는 별개로
상태에 따라 뷰의 속성이 변하는 경우에만 그 속성을 테스트하면 된다.
이 예시가 바로
현재의 로딩 상태에 따라
activityIndicator가 애니메션 중인지 아닌지를 나타내는 속성이 달라지는 것이다.
이와 같은 테스트 코드를 먼저 작성해보면 뷰를 테스트하는 것에 대한 감을 잡을 수 있을 것이라고 한다.
추가적으로 setLoading함수에서 self.tableView.isHisdden이라는 속성이
현재 로딩중인지 아닌지의 상태에 따라 변하는 것을 테스트해보면
뷰 테스트를 하는 감을 잡는데 도움이 될 수 있을 거라고 한다.
search에 관한 테스트만 작성했음에도 벌써
SearchRepositoryViewController.swft 파일의 커버리지가
81.1%인 것을 확인할 수 있다.
만약 여기에 테이블 뷰 셀이 잘 맞는 데이터를 랜더링하는지와 같은 테스트까지 모두 작성하면 커버리지가 100%가 나온다고 한다. 시간관계상 테이블뷰 테스트를 작성하는 것은 생략한다고 한다.
렌더링(rendering) : 렌더링(Rendering)은 컴퓨터 프로그램을 사용하여 모델(또는 이들을 모아놓은 장면인 씬(scene) 파일)로부터 완성된 화면이나 영상을 만들어내는 과정을 말한다.
//LetsGitHubSearch/LetsGitHubSearch/Sources/ViewController/SearchRepositoryViewController.swift
import UIKit
import Alamofire
class SearchRepositoryViewController: UIViewController {
struct Dependency {
let repositoryService: RepositoryServiceProtocol!
let urlOpener: URLOpenerProtocol!
let firebaseAnalytics: FirebaseAnalyticsProtocol.Type!
}
var dependency: Dependency!
let searchController = UISearchController(searchResultsController: nil)
@IBOutlet private(set) var tableView: UITableView!
@IBOutlet private(set) var activityIndicatorView: UIActivityIndicatorView!
var currentSearchRequest: DataRequest?
var repositories: [Repository] = []
override func viewDidLoad() {
super.viewDidLoad()
self.definesPresentationContext = true
self.searchController.searchBar.delegate = self
self.searchController.searchBar.autocapitalizationType = .none
self.navigationItem.searchController = self.searchController
self.navigationItem.hidesSearchBarWhenScrolling = false
}
}
extension SearchRepositoryViewController: UISearchBarDelegate {
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
if let text = searchBar.text {
self.search(keyword: text)
self.dependency.firebaseAnalytics.logEvent("search", parameters: ["keyword": text])
}
self.searchController.dismiss(animated: true, completion: nil)
}
private func search(keyword: String) {
self.cancelPreviousSearchRequest()
self.setLoading(true)
self.currentSearchRequest = self.dependency.repositoryService.search(keyword: keyword) { [weak self] result in
guard let self = self else { return }
self.setLoading(false)
switch result {
case let .success(searchResult):
self.setSearchResult(searchResult)
case let .failure(error):
self.showErrorAlert(error: error)
}
}
}
private func cancelPreviousSearchRequest() {
self.currentSearchRequest?.cancel()
}
private func setLoading(_ isLoading: Bool) {
if isLoading {
self.activityIndicatorView.startAnimating()
self.tableView.isHidden = true
} else {
self.activityIndicatorView.stopAnimating()
self.tableView.isHidden = false
}
}
private func setSearchResult(_ searchResult: RepositorySearchResult) {
self.repositories = searchResult.items
self.tableView.reloadData()
}
private func showErrorAlert(error: Error) {
let alertController = UIAlertController(title: "⚠️", message: error.localizedDescription, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
self.present(alertController, animated: true, completion: nil)
}
}
extension SearchRepositoryViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.repositories.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let repository = self.repositories[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: "cell")!
cell.textLabel?.text = repository.fullName
cell.detailTextLabel?.text = self.formattedStargazersCount(repository.stargazersCount)
return cell
}
private func formattedStargazersCount(_ count: Int) -> String? {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
guard let formattedCount = formatter.string(from: count as NSNumber) else { return nil }
return "⭐️ \(formattedCount)"
}
}
extension SearchRepositoryViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let repository = self.repositories[indexPath.row]
let urlString = "https://github.com/\(repository.fullName)"
guard let url = URL(string: urlString) else { return }
self.dependency.urlOpener.open(url, options: [:], completionHandler: nil)
}
}
//LetsGitHubSearch/LetsGitHubSearchTests/Sources/ViewControllers/SearchRepositoryViewControllerTests.swift
import XCTest
@testable import LetsGitHubSearch
final class SearchRepositoryViewControllerTests: XCTestCase {
private var repositoryService: RepositoryServiceStub!
private var urlOpener: URLOpenerStub!
private var firebaseAnalytics: FirebaseAnalyticsStub.Type!
private var viewController: SearchRepositoryViewController!
override func setUp() {
super.setUp()
self.repositoryService = RepositoryServiceStub()
self.urlOpener = URLOpenerStub()
self.firebaseAnalytics = FirebaseAnalyticsStub.self
self.firebaseAnalytics.logEventParameters = nil
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let identifier = "SearchRepositoryViewController"
self.viewController = storyboard.instantiateViewController(withIdentifier: identifier) as? SearchRepositoryViewController
self.viewController.dependency = .init(
repositoryService: self.repositoryService,
urlOpener: self.urlOpener,
firebaseAnalytics: self.firebaseAnalytics
)
self.viewController.loadViewIfNeeded()
}
func testSearchBar_whenSearchBarSearchButtonClicked_searchWithText() {
// when
let searchBar = self.viewController.searchController.searchBar
searchBar.text = "ReactorKit"
searchBar.delegate?.searchBarSearchButtonClicked?(searchBar)
// then
XCTAssertEqual(self.repositoryService.searchParameters?.keyword, "ReactorKit")
}
func testSearchBar_whenSearchBarSearchButtonClicked_logAnalyticsSearchEvent() {
// when
let searchBar = self.viewController.searchController.searchBar
searchBar.text = "Let'Swift 18"
searchBar.delegate?.searchBarSearchButtonClicked?(searchBar)
// then
let parameters = self.firebaseAnalytics.logEventParameters
XCTAssertEqual(parameters?.name, "search")
XCTAssertEqual(parameters?.parameters as? [String: String], ["keyword": "Let'Swift 18"])
}
func testActivityIndicatorView_isAnimating_whileSearching() {
// when
let searchBar = self.viewController.searchController.searchBar
searchBar.text = "ReactorKit"
searchBar.delegate?.searchBarSearchButtonClicked?(searchBar)
// then
XCTAssertTrue(self.viewController.activityIndicatorView.isAnimating)
}
func testActivityIndicatorView_isNotAnimating_afterSearching() {
// given
let searchBar = self.viewController.searchController.searchBar
searchBar.text = "ReactorKit"
searchBar.delegate?.searchBarSearchButtonClicked?(searchBar)
// when
self.repositoryService.searchParameters?.completionHandler(.failure(TestError()))
// then
XCTAssertFalse(self.viewController.activityIndicatorView.isAnimating)
}
func testTableView_isHidden_whileSearching() {
// when
let searchBar = self.viewController.searchController.searchBar
searchBar.text = "ReactorKit"
searchBar.delegate?.searchBarSearchButtonClicked?(searchBar)
// then
XCTAssertTrue(self.viewController.tableView.isHidden)
}
func testTableView_isVisible_afterSearching() {
// given
let searchBar = self.viewController.searchController.searchBar
searchBar.text = "ReactorKit"
searchBar.delegate?.searchBarSearchButtonClicked?(searchBar)
// when
self.repositoryService.searchParameters?.completionHandler(.failure(TestError()))
// then
XCTAssertFalse(self.viewController.tableView.isHidden)
}
func testTableView_configureRepositoryCell_afterSearching() {
// given
let searchBar = self.viewController.searchController.searchBar
searchBar.text = "ReactorKit"
searchBar.delegate?.searchBarSearchButtonClicked?(searchBar)
// when
let repositories = [
Repository(name: "ReactorKit1", fullName: "devxoul/ReactorKit1", stargazersCount: 1289),
Repository(name: "ReactorKit2", fullName: "younatics/ReactorKit2", stargazersCount: 987),
Repository(name: "ReactorKit3", fullName: "cruisediary/ReactorKit3", stargazersCount: 543),
]
let searchResult = RepositorySearchResult(totalCount: 3, items: repositories)
self.repositoryService.searchParameters?.completionHandler(.success(searchResult))
// then
let numberOfRows = self.viewController.tableView.numberOfRows(inSection: 0)
XCTAssertEqual(numberOfRows, 3)
let cell0 = self.viewController.tableView.cellForRow(at: IndexPath(row: 0, section: 0))
XCTAssertEqual(cell0?.textLabel?.text, "devxoul/ReactorKit1")
XCTAssertEqual(cell0?.detailTextLabel?.text?.contains("1,289"), true)
let cell1 = self.viewController.tableView.cellForRow(at: IndexPath(row: 1, section: 0))
XCTAssertEqual(cell1?.textLabel?.text, "younatics/ReactorKit2")
XCTAssertEqual(cell1?.detailTextLabel?.text?.contains("987"), true)
let cell2 = self.viewController.tableView.cellForRow(at: IndexPath(row: 2, section: 0))
XCTAssertEqual(cell2?.textLabel?.text, "cruisediary/ReactorKit3")
XCTAssertEqual(cell2?.detailTextLabel?.text?.contains("543"), true)
}
func testTableView_openRepositoryWebPage_whenSelectItem() {
// given
let searchBar = self.viewController.searchController.searchBar
searchBar.text = "ReactorKit"
searchBar.delegate?.searchBarSearchButtonClicked?(searchBar)
let repositories = [
Repository(name: "ReactorKit1", fullName: "devxoul/ReactorKit1", stargazersCount: 1289),
Repository(name: "ReactorKit2", fullName: "younatics/ReactorKit2", stargazersCount: 987),
Repository(name: "ReactorKit3", fullName: "cruisediary/ReactorKit3", stargazersCount: 543),
]
let searchResult = RepositorySearchResult(totalCount: 3, items: repositories)
self.repositoryService.searchParameters?.completionHandler(.success(searchResult))
// when
let tableView = self.viewController.tableView!
tableView.delegate?.tableView?(tableView, didSelectRowAt: IndexPath(row: 0, section: 0))
// then
XCTAssertEqual(self.urlOpener.openParameters?.absoluteString, "https://github.com/devxoul/ReactorKit1")
}
}