본문 바로가기
Programming Paradigm/TDD

Test Driven Development in Swift

by 탄이. 2020. 3. 8.

Let's TDD - 전수열

devxoul/LetsGitHubSearch

 

devxoul/LetsGitHubSearch

Let'Swift 18 Workshop - Let's TDD. Contribute to devxoul/LetsGitHubSearch development by creating an account on GitHub.

github.com

TDD의 핵심

  1. RED
    • 테스트 작성 → 구현이 없어서 실패
  2. GREEN
    • 테스트를 통과할 수 있는 최소한의 구현
  3. REFACTOR
    • 테스트의 존재 덕분에 안전한 리펙토링 가능

TDD는 단순하지만, 테스트를 작성하는 능력이 더 중요하다

나의 앱에 TDD를 도입하기 어려운 이유

  1. 의존성 주입이 되어있지 않음 (모든 곳에 커플링이 걸려있음 → 네트워크를 분리하기 어려움)
  2. 뷰와 애니메이션이 너무 많이 엮여 있어서 테스트하기 용이하지 못함

*TOPICs

  1. Unit Test부터 연습하고 TDD를 숙련한다.
  2. 외부 세계와의 접점에는 Mock을 활용한다.
  3. Internal 구현체도 테스트에 활용할 수 있다.
  4. 뷰 테스트는 상태에 따라 변하는 값을 테스트한다.
  5. private 메서드는 만들기 전에 테스트되고 있어야 한다.
  6. TDD로만 모든 코드를 작성하지는 않는다.
  7. Cocoa Framework 의 작동 방식을 다양하게 알아둔다.
  8. 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으로 이 라인이 실제 테스트가 되었는지 아닌지 확인이 가능하다.

댓글