(Suyeol Jeon)
(1)과 (2)에서 한 것을 요약해보면
기존에 있던 코드를 테스트하기 쉽게 만들었고
그 테스트하기 쉽게 만든 코드를 가지고 유닛테스트를 작성을 했다.
그 다음에는 ActivityIndicator가 상황이 반전되어있는 버그를 잡았다.
이 버그를 잡기위해서 구현을 먼저 변경하기 전에 테스트를 먼저 작성했다.
테스트를 먼저 작성하고 구현을 변경해서 변경한 구현이 테스트에 통과하는 것을 확인하였다.
이것이 TDD의 시작이다.
피쳐? feature? 특징?
테이블 뷰 셀을 랜더링할 때 이름뿐만 아니라 스타개수(?)까지 랜더링하는지를 검증할 것이다.
반복되는 코드는 setup함수나 유틸리티 함수로 빼줘야한다.
⭐⭐ when이나 given보다 then을 먼저 작성하는 습관을 들이는 것이 좋다.⭐⭐



XCTAssertEqual(cell?.textLabel?.text, "")
위와 같은 코드로 어떤 텍스트가 들어가 있는지 검증할 수 있다.
이것을 위해서는 컴플리션 핸들러에서 원하는 값을 내려줄 수 있도록 바꿀 것이다.
이전에는 단순히 로딩이 끝났음을 확인하기 위해서
when부분의 service.searchParamaters에서 failure를 줬는데
이번에는 실제 데이터가 필요하기 때문에 success를 사용해야한다.
// when
let repositories = [
Repository(name: "A"),
Repository(name: "B"),
]
위의 코드는 만들어놓은 가짜 데이터이다.

위의 이미지는 search에 성공했을 때 그 search의 result가
repository A랑 repository B일 때
테이블 뷰의 첫번째 셀을 꺼냈을 때의 셀의 text 값이 A인지를 검증을 하는 코드이다.
두번째로 cell의 detailTextLabel에 있는 text를 검증할 것이다.
XCTAssertEqual(cell0?.detailTextLabel?.text, "321")
위의 코드는 cell의 detailTextLabel에 있는 text에 스타 개수가 321개인지를 검증하는 코드이다.
위의 테스트 코드만 작성하고 테스트를 돌리면 테스트 코드 외에 아무것도 작성하지 않았기 때문에 테스트에 실패하게 된다.
Tip :
숫자를 다룰 때는 컴마를 넣을 지를 많이 생각을 한다고 하는데
전수열님은 항상 바운더리에 있는 것을 많이 생각한다고 한다.
XCTAssertEqual(cell0?.detailTextLabel?.text, "1,321")
따라서 321을 테스트하는 것보다 1,321을 테스트하는 것이 훨씬 더 좋은 테스트 코드라고 한다.
왜냐하면 1000이 넘어갔을 때 컴마가 있는지 없는지를 같이 테스트할 수 있기 때문이다.

위의 이미지와 같이 변경하면
Repository 모델 클래스에는 starCount가 정의가 안되어있으르모 에러가 난다.
아래와 같이 만들어주자
하지만 아직까지 detailTextLabel에 아무 값도 입력하지 않았기 때문에
테스트를 통과하지 못한다.
스토리보드를 사용을 하고 있기 때문에 detailTextLabel을 사용을 하기 위해서는
먼저 텍스트 셀의 스타일을 바꿔줘야한다. 이때는 subtitle로 변경한다.

cellForRowAt 테이블 뷰 함수가 핵심이다.

콤마도 넣어야하므로 위와 같이 formatter()를 만들어준다.
그리고 난 후에 테스트를 실행해보면 테스트에 성공한다.
이제 리팩토링만 남았다.
코드가 굉장히 지저분하므로 아래와 같이 메서드를 추출한다.

테스트에 통과했다.
여기까지 하면 리팩토링 단계인 Blue까지 끝나서 TDD의 한 사이클이 돌게 된다.
사실은 이게 TDD의 전부라고 한다.
이 사이클의 리팩토링 단계에서 formattedStarCount라는 함수를 만들었는데 이 함수는 private 메서드이다.
자주 받는 질문 중에 하나로 private 메서드도 테스트를 하는지를 많이 질문한다.
⭐⭐ private 메서드는 만들기 전부터 이미 테스트가 되고 있어야 한다고 한다. ⭐⭐
⭐⭐ private 메서드는 처음부터 만들지 않는다. ⭐⭐
⭐⭐ 왜냐하면 TDD로 만들 때는 최소한의 테스트를 통과할 정도로 구현을 해놓고 그 다음에 리팩토링을 하면서 만드는 것이 private 메소드라고 한다.⭐⭐
그래서 private 메서드는 테스트 대상인지 묻는 질문은 잘못된 질문이라고 한다.
private 메서드는 이미 생성되기 전부터 테스트가 되고 있는 코드여야 한다고 한다.
//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")
}
}