[TDD] 네트워크 클라이언트

Updated:

네트워크 클라이언트 static shared property

네트워크를 이용한 데이터 요청을 할 때마다 관련 클래스를 새로 생성하는 것은 네트워크 성능이나 기기의 배터리 상태 영향을 줄 수 있다.
이러한 비용을 줄이기 위해서 단 하나만 생성하여 사용할 수 있도록 하는 싱글톤 패턴을 이용하여 static shared property를 만들어 줄 수 있다.

class DogPatchClient {
  
  let baseURL: URL
  let session: URLSession
  
  static let shared = DogPatchClient(
    baseURL: URL(string: "https://dogpatchserver.herokuapp.com/api/v1")!,
    session: URLSession.shared,
  )
  
  init(baseURL: URL,
       session: URLSession) {
    self.baseURL = baseURL
    self.session = session
  }
  
  .
  .
  .

그리고 이 shared의 프러퍼티를 테스트한다.

class DogPatchClientTests: XCTestCase {

  .
  .
  .

  // shared가 가진 base url이 올바른지 테스트
  func test_shared_setBaseURL() {
    // given
    let baseURL = URL(string: "https://dogpatchserver.herokuapp.com/api/v1")!
    
    // then
    XCTAssertEqual(DogPatchClient.shared.baseURL, baseURL)
  }
  
  // shared가 가진 session이 올바른지 테스트
  func test_shared_setsSession() {
    // given
    let session = URLSession.shared
    
    // then
    XCTAssertEqual(DogPatchClient.shared.session, session)
  }
  
  .
  .
  .
  

네트워크 클라이언트의 데이터 요청 테스트하기

위에서 만든 DogPatchClient의 shared 프로퍼티를 바료 이용하여 테스트를 하면 다음과 같이 고려해야 할 점이 있다.

  • 인터넷 연결이 필요한 실제 네트워크 통신을 하게 된다.
  • 인터넷 연결이 되어 있지 않으면 네트워크 통신 관련 테스트는 불가능하게 된다.
  • 인터넷 연결이 되어 네트워크 통신이 가능하게 되더라도 가변적인 데이터 값을 예측할 수 없다. 그러므로 테스트가 어렵다.
  • 네트워크 데이터 요청을 통해 데이터를 받기 까지 소모되는 시간이 부담된다.

테스트를 할 때는 실제 앱에서 사용할 클래스를 가지고 사용하지 않고 mock 네트워크 클라이언트를 만들어야 한다. 그리고 다음과 같이 두가지 방법이 있다.

  1. 위에서 만들었던 네트워크 클라이언트 클래스를 상속해서 관련 메서드를 오버라이딩 한다. 이 방법은 특정 메서드를 실수로 오버라이딩하지 못할 경우 실제 네트워크 요청을 하게 될 가능성이 있다.
  2. 네트워크 클라이언트 protocol 을 정의하고 의존성을 주입해서 사용한다. 이렇게 하면 실제 네트워크 요청을 할 가능성을 없앨 수 있다.
protocol DogPatchService {
  func getDogs(completion: @escaping ([Dog]?, Error?) -> Void) -> URLSessionDataTask
}

extension DogPatchClient: DogPatchService {
  getDogs(completion: @escaping ([Dog]?, Error?) -> Void) -> URLSessionDataTask {
    .
    .
    .
  }
}

이제 테스트에 사용하게 될 mock 네트워크 클라이언트를 만든다. mock 네트워크 클라이언트는 앱 코드에 영향을 미치지 않고 테스트를 사용할 용도로만 사용하게 된다.

@testable import DogPatch
import Foundation

class MockDogPatchService: DogPatchService {
  
  var getDogsCallCount = 0
  var getDogsDataTask = URLSessionDataTask()
  var getDogsCompletion: (([Dog]?, Error?) -> Void)!
  
  func getDogs(completion: @escaping ([Dog]?, Error?) -> Void) -> URLSessionDataTask {
    getDogsCallCount += 1
    getDogsCompletion = completion
    return getDogsDataTask
  }
}

위에서 만든 Mock 네트워크 클라이언트 클래스를 테스트에 사용하는 예시는 다음과 같다.

class ListingsViewControllerTests: XCTestCase {
  .
  .
  .
  func test_refreshData_setsRequest() {
      // given
      mockNetworkClient = MockDogPatchService()
      sut.networkClient = mockNetworkClient

      // when
      sut.refreshData()

      // then
      XCTAssertEqual(sut.dataTask, mockNetworkClient.getDogsDataTask)
  }
  .
  .
  .

위의 refreshData 메서드는 유저가 테이블 뷰를 당겼을 때도 호출될 수 있기 때문에 refreshData가 중복되서 요청이 될 수가 있다. 이러한 불필요한 중복요청을 없애기 위한 테스트 코드를 작성해보면 다음과 같다.

class ListingsViewControllerTests: XCTestCase {
  .
  .
  .
  func test_refreshData_ifAlreadyRefreshing_doesntCallAgain() {
    // given
    givenMockNetworkClient()
    
    // when
    sut.refreshData()
    sut.refreshData()
    
    // then
    XCTAssertEqual(mockNetworkClient.getDogsCallCount, 1) // 1번만 호출될 것을 예상
  }
  

TDD 사이클 기준으로 위의 테스트 코드는 실패하기 때문에 앱코드로 돌아가서 테스트 코드가 성공할 수 있도록 코드를 수정해야한다.

class ListingsViewController: UIViewController {
  .
  .
  .
  // MARK: - Refresh
  @objc func refreshData() {
    guard dataTask == nil else { return } // 추가
    
    
    dataTask = networkClient.getDogs { dogs, error in
      self.dataTask = nil //  추가
      self.tableView.reloadData()
    }
  }

tableView Mooking 하여 테스트하기

class ListingsViewControllerTests: XCTestCase {
  .
  .
  .

  func test_refreshData_givenDogsResponse_reloadsTableView() {
      // given
      givenMockNetworkClient()
      let dogs = givenDogs()

      // 1
      class MockTableView: UITableView {
        var calledReloadData = false
        override func reloadData() {
          calledReloadData = true
        }
      }

      // 2
      let mockTableView = MockTableView()
      sut.tableView = mockTableView

      // when
      sut.refreshData()
      mockNetworkClient.getDogsCompletion(dogs, nil)

      // then

      // 3
      XCTAssertTrue(mockTableView.calledReloadData)
  }

Leave a comment