devxoul/LetsGitHubSearch
Let'Swift 18 Workshop - Let's TDD. Contribute to devxoul/LetsGitHubSearch development by creating an account on GitHub.
github.com
TDD의 핵심
- RED
- 테스트 작성 → 구현이 없어서 실패
- GREEN
- 테스트를 통과할 수 있는 최소한의 구현
- REFACTOR
- 테스트의 존재 덕분에 안전한 리펙토링 가능
TDD는 단순하지만, 테스트를 작성하는 능력이 더 중요하다
나의 앱에 TDD를 도입하기 어려운 이유
- 의존성 주입이 되어있지 않음 (모든 곳에 커플링이 걸려있음 → 네트워크를 분리하기 어려움)
- 뷰와 애니메이션이 너무 많이 엮여 있어서 테스트하기 용이하지 못함
*TOPICs
- Unit Test부터 연습하고 TDD를 숙련한다.
- 외부 세계와의 접점에는 Mock을 활용한다.
- Internal 구현체도 테스트에 활용할 수 있다.
- 뷰 테스트는 상태에 따라 변하는 값을 테스트한다.
- private 메서드는 만들기 전에 테스트되고 있어야 한다.
- TDD로만 모든 코드를 작성하지는 않는다.
- Cocoa Framework 의 작동 방식을 다양하게 알아둔다.
- AppDelegate도 테스트 할 수 있다.*
네트워크 서비스 비동기 테스트
테스트 기법 중의 하나로, 기존의 서비스 메서드에서 네트워크 혹은 여러 side-effect에 강하게 coupling되어있는 객체를 가지고 테스트를 할 때는 외부에서 가짜 데이터를 제공하는 Stub(Mock) 객체를 넣고 그 동작을 테스트하는 테스트 기법이 있다.
- 실제 프로덕션 앱에서 사용하는 네트워크 매니저와 테스트에서 사용하는 네트워크 매니저를 구분한다.
- 테스트 환경에서는 실제 Alamofire 의 SessionManager를 흉내만 내는 Mock Object를 사용한다.
RepositoryService.swift (수정 전) - 테스트 부적합
import Alamofire
final class RepositoryService {
@discardableResult
class func search(keyword: String, completionHandler: @escaping (Result<RepositorySearchResult>) -> Void) -> DataRequest {
let url = "https://api.github.com/search/repositories"
let parameters: Parameters = ["q": keyword]
// Alamofire의 클래스의 인스턴스를 얻어와서 네트워크 요청을 보낸다.
// 네트워크에 의존성을 강하게 가지고 있으므로 수정이 필요하다.
return SessionManager.default.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)
}
}
}
RepositoryService.swift (수정 후) - 테스트 용이
...
private let sessionManager: SessionManagerProtocol
// Mock Object를 사용하기 위해서 의존성 주입이 필요하다.
// 해당 클래스 그 자체를 주입받는 대신에 한번 간접화를 시킨 프로토콜을 주입받는다.
init(sessionManager: SessionManagerProtocol) {
self.sessionManager = sessionManager
}
func search(keyword: String, completionHandler: @escaping (Result<RepositorySearchResult>) -> Void) -> DataRequest {
...
return self.sessionManager.default.request(url,
method: .get,
parameters: parameters,
encoding: URLEncoding(),
headers: nil)
...
SessionManagerProtocol.swift
import Alamofire
/// request의 결과를 가짜 데이터로 채워야하기 때문에 SessionManager와 똑같은 인터페이스를 가진 Protocol을 만든다.
protocol SessionManagerProtocol {
@discardableResult
func request(_ url: URLConvertible,
method: HTTPMethod,
parameters: Parameters?,
encoding: ParameterEncoding,
headers: HTTPHeaders?) -> DataRequest
}
// 프로토콜 적용
extension SessionManager: SessionManagerProtocol {}
RepositoryServiceTests.swift
import Alamofire
import XCTest
@testable import LetsGitHubSearch
final class RepositoryServiceTests: XCTestCase {
func testSearch_callsSearchAPIWithParameters() {
// given
let sessionManagerStub = SessionManagerStub()
let service = RepositoryService(sessionManager: sessionManagerStub)
// when
service.search(keyword: "ReactorKit", completionHandler: { _ in })
// then
let expectedURL = "https://api.github.com/search/repositories"
let actualURL = try? sessionManagerStub.requestParameters?.url.asURL().absoluteString
XCTAssertEqual(actualURL, expectedURL)
let expectedMethod = HTTPMethod.get
let actualMethod = sessionManagerStub.requestParameters?.method
XCTAssertEqual(actualMethod, expectedMethod)
let expectedParameters = ["q": "ReactorKit"]
let actualParameters = sessionManagerStub.requestParameters?.parameters as? [String: String]
XCTAssertEqual(actualParameters, expectedParameters)
}
}
SessionManagerStub.swift
@testable import Alamofire
@testable import LetsGitHubSearch
/// SessionManagerProtocol을 따르는 Mock Object.
final class SessionManagerStub: SessionManagerProtocol {
var requestParameters: (url: URLConvertible, method: HTTPMethod, parameters: Parameters?)?
func request(_ url: URLConvertible,
method: HTTPMethod,
parameters: Parameters?,
encoding: ParameterEncoding,
headers: HTTPHeaders?) -> DataRequest {
// 가짜 request가 발생되면 가장 최근에 불러진 request 메서드의 파라미터가 requestParameters에 기록된다.
self.requestParameters = (url, method, parameters)
// @testable import를 하면 public한 생성자가 없더라도 dummy를 생성할 수 있다.
return DataRequest(session: URLSession(), requestTask: .data(nil, nil))
}
}
테스트 확인
-
테스트 코드가 잘 작동되는지 확인하기 위해 잘못된 url을 넣어본다.
...
@discardableResult
func search(keyword: String, completionHandler: @escaping (Result<RepositorySearchResult>) -> Void) -> DataRequest {
let url = "https://api.github.com/search/repositories!!!%^##$~!~!"
...
뷰컨트롤러 테스트
1. testSearchBar_whenSearchBarSearchButtonClicked_searchWithText()
- 키워드로 "ReactorKit"를 searchBar에 넣어놓고
- searchBarSearchButtonClicked 가 호출되었을때
- 서비스 객체(RepositoryServiceStub())에 주입한 searchParameters 가 "ReactorKit"와 일치하는지 테스트한다.
AppDelegate.swift
...
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
self.appDependency.firebaseApp.configure()
if let rootViewController = self.rootViewController() {
let repositoryService = RepositoryService(
sessionManager: SessionManager.default //
)
rootViewController.repositoryService = repositoryService
}
return true
}
private func rootViewController() -> SearchRepositoryViewController? {
let navigationController = self.window?.rootViewController as? UINavigationController
return navigationController?.viewControllers.first as? SearchRepositoryViewController
}
...
SearchRepositoryViewController.swift
class SearchRepositoryViewController: UIViewController {
// AppDelegate에서 주입
var repositoryService: RepositoryService!
...
private func search(keyword: String) {
self.cancelPreviousSearchRequest()
self.setLoading(true)
self.currentSearchRequest = self.repositoryService.search(keyword: keyword) { [weak self] result in
...
}
}
...
SearchRepositoryViewControllerTests.swift
import XCTest
@testable import LetsGitHubSearch
final class SearchRepositoryViewControllerTests: XCTestCase {
override func setUp() {
super.setUp()
self.repositoryService = RepositoryServiceStub()
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let identifier = "SearchRepositoryViewController"
self.viewController = storyboard.instantiateViewController(withIdentifier: identifier) as? SearchRepositoryViewController
self.viewController.loadViewIfNeeded()
self.viewController.repositoryService = self.repositoryService
}
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")
}
...
RepositoryServiceStub.swift
@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))
}
}
테스트 확인
-
테스트 코드가 잘 작동되는지 확인하기 위해 잘못된 키워드를 넣어본다.
...
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
...
self.search(keyword: text + "!@##@%!@#")
...
Code Coverage 기능 켜기 Edit Scheme → Test (Debug) → Gather coverage for all targets line by line으로 이 라인이 실제 테스트가 되었는지 아닌지 확인이 가능하다.
댓글