Do.

iOS Framework - 네트워크 모듈 테스트 (URLSession, Unit Test) 본문

iOS

iOS Framework - 네트워크 모듈 테스트 (URLSession, Unit Test)

Hey_Hen 2023. 3. 31. 02:12

Intro

서비스를 개발하면 반드시 네트워크에 대한 요청 및 응답 로직을 작성해야 하는데 개발하는 과정에서 로직이 간단한 경우는 최소 한두번, 복잡한 경우는 수십번은 테스트 과정에 네트워크 동작이 발생할 수 있습니다. 뿐만 아니라 몇몇 상황을 고려할 때 실제 서비스(Dev 서버라 하더라도) 를 호출하는 것은 문제점이 많습니다.

따라서 네트워크 모듈 단위 테스트를 실시할 때 어떤 방법이 있을지 알아보려고 합니다.

실제 네트워크 요청시 문제점

우선 네트워크 단위 테스트를 할 때 실제 네트워크를 요청하게 되면 어떤 문제가 있을까요?

물론 간단한 서비스의 경우는 실제 네트워크 요청이 일어나더라도 크게 문제 없을 수도 있습니다. 하지만 여러분이 하는 대부분의 서비스는 복잡하고, 무겁고 어려울 수 있습니다.

그러면 네트워크 모듈 테스트할 때 실제 네트워크를 요청하면 어떤 문제점이 있을까요?

네트워크 환경

  • Swift에서 사용하는 가장 기본적인 테스트 Framework는 XCTest를 사용합니다. 네트워크는 대부분? 비동기 실행이기 때문에 expectation을 선언하고 wait를 하게 됩니다. 이때 wait에 timeout 을 설정하게 되는데요, 실제 네트워크는 필연적으로 요청 및 응답에 대한 지연시간을 가지기 때문에 실제 예상되는 시간을 작성해야 합니다.
func testExample() throws {
  let exp = expectation(description: "network")
  let session = URLSession.shared
  session.dataTask(with: URLRequest(url: URL(string: "http://do-programming/index")!)) { data, response, error in
    exp.fulfill()
    XCTAssert(true)
  }.resume()

  wait(for: [exp], timeout: 0.1)
 }

간단히 비동기 테스트 코드를 작성해 보았는데요. 위 코드대로라면 0.1초 동안 해당 테스트는 지연됩니다. 그런데 만약 실제 네트워크 요청시 환경 문제로 네트워크 응답에 대한 지연시간이 0.1초 보다 크게 되면 해당 테스트는 실패하게 됩니다. 0.1초 안으로 응답이 들어오지 않았거든요.

그러면 timeout을 실제 네트워크 지연시간 만큼 늘리면 어떻게 될까요?

func testExample() throws {
  let exp = expectation(description: "network")
  let session = URLSession.shared
  session.dataTask(with: URLRequest(url: URL(string: "http://do-programming/index")!)) { data, response, error in
    exp.fulfill()
    XCTAssert(true)
  }.resume()

  wait(for: [exp], timeout: 1) // as-is 0.1 to-be 1.0
 }

그러면 안정적으로 테스트는 성공하게 되겠지만 이 테스트 케이스 한번 돌리는데 1초라는 시간이 들어갑니다.

만약 여러분이 TDD(Test Driven Development)를 하고 있다면 동일한 테스트에 대해 최소 3번의 테스트 실행 시켜야 하는데요, 그러면 3초네요, 만약 만들고자 하는 네트워크 모듈의 테스트 케이스가 총 10개라면 전체 테스트를 한번 돌리는데 무려 10초, 그리고 반복적으로 테스트를 하게 되므로 소모되는 시간이 어마무시 하죠? 따라서 네트워크 모듈에 대한 단위 테스트를 작성할 때는 실제 네트워크 서버를 이용하지 않는 것이 좋습니다.

서버 부하

  • 실제 서버에 대한 부담이 커질 수 있습니다. 네트워크 요청이 고작 짧은 json이라면 크게 문제되지 않을 수 있지만 테스트하는 모듈이 이미지나 동영상 또한 어떤 패키지(s3 등)이라면 한번의 요청만 해도 수 메가바이트가 넘어갈 수 있죠, 마찬가지로 테스트 케이스가 많을 수록, 기능이 복잡해질 수록 서버에 대한 부담이 커집니다.

외부 요인

  • 테스트가 외부 요인에 대해 의존하는 것 자체가 바람직 하지 못합니다. 예를들어 여러분이 테스트 작업을 하는 시간에 Dev서버가 잠시 셧다운이 될 수도 있고 (☕️) 혹은 문제가 생겨서 장시간 셧다운이 될 수도 있습니다. 이외에도 서버를 이전한다던가 하는 각종 외적인 요소들에 이해 업무에 장애가 생기는 경우가 있을 수 있습니다.
  • 이외에도 네트워크 모듈이라 하면 다양한 성공 케이스가 있을 수 있고, 다양한 실패 케이스가 있을 수 있습니다. 예를들어 HTTP Status Code가 200, 304, 401, 408 등등에 대해 다르게 동작해야 할 수 있는데, 백엔드 엔지니어에게 상황에 맞게 변경해달라고 한다던가, 그들이 테스트 할 수 있는 API를 뚫어 준다던가 하는 의존성이 생깁니다.

따라서 상기 이유에 따라 네트워크 모듈 단위 테스트는 모의 객체(Mock Object)를 통해 이루어 져야 합니다.

또 한번 익혀두면 사실 그간 있었던 불편한 점을 모두 해결할 수 있으므로 사실 해서 손해볼 것은 없습니다,

URLProtocol

일반적으로 단위 테스트할 때 의존하는 객체에 대해서 Mock이 필요하면 의존 객체의 인터페이스를 채용하는 Mock 모델을 만들게 됩니다.

protocol UseCase {
  func fetchList() -> [String]
}

final class MockUseCase: UseCase {
  func fetchList() -> [String] {
    [
      "안녕!",
      "잘가!",
    ]
  }
}

final class TestTests: XCTestCase {

  private var sut: ViewModel!

  override func setUpWithError() throws {
    try super.setUpWithError()
    sut = ViewModel(useCase: MockUseCase())
  }

  override func tearDownWithError() throws {
    sut = nil
    try super.tearDownWithError()
  }
}

UseCase를 의존하는 ViewModel이 있으면 구체적인 UseCase를 동작하기 전에 MockUseCase를 적용하는 흔한 테스트 방법입니다.

이 경우는 UseCase를 저희가 직접 작성하기 때문에 Mock object를 손쉽게 작성할 수 있습니다.

그런데 만드는 것이 네트워크 모듈이라면 애플 환경에서는 필연적으로 URLSession을 의존하게 되죠!

Downloader -> URLSession

그러면 MockURLSession을 만들어야 하는데 어떻게 만드나요?

바로 URLProtocol 입니다.

https://developer.apple.com/documentation/foundation/urlprotocol

[URLProtocol | Apple Developer Documentation

An abstract class that handles the loading of protocol-specific URL data.

developer.apple.com](https://developer.apple.com/documentation/foundation/urlprotocol)

URLSession을 통해 Task를 수행할 때 URLProtocol 서브클래스들 내용을 따를 수 있는데요, 이를 통해서 Mock URLProtocol subcless를 만들어서 네트워크 결과를 수정할 수 있습니다.

MockURLProtocol

뭔가 큰 파일을 보내는 서비스가 있다고 가정 해보겠습니다.

이 서비스는 송신되는 데이터 사이즈를 절약하기 위해 데이터를 압축해서 보냅니다.

클라이언트에서는 이 압축된 데이터를 받아서 압축 해제하는 과정을 거치게 되죠.

여기에 추가로 데이터를 더 절약하기 위해 클라이언트에서도 데이터를 캐싱하기로 했습니다. Etag를 기반으로 캐싱한다고 가정하죠.

Etag가 일치 하는지 안하는지 서버에 요청을 보내고 일치하면 200, 일치하지 않으면 304를 받게 됩니다.

제 생각에는 클라이언트에서 압축 해제를 매번 하는 것도 디바이스에 따라 많이 부담이 되는 작업이기 때문에 서버에서 보내준 상태 코드가 200이고 패키지 내용이 바뀌었을 때만 압축 해제를 수행하고 그렇지 않을 경우에는 이미 캐싱하고 있던 압축 해제된 데이터를 사용하고 싶습니다.

그러면 이제 이 역할을 수행하는 모델을 Downloader라고 정의해보죠

이 Downloader는 Response의 StatusCode에 따라 다르게 핸들링 할 수 있도록 결과를 반환하고 싶습니다.

if statusCode == 304 {
  completion(data, .useCache, nil)
} else if statusCode == 200 {
  completion(data, .refreshCache, nil)
} else {
  completion(nil, .failure, nil)
}

다시 테스트로 돌아가보죠, 그럼 Downloader가 요청 결과에 따라 로직대로 바르게 반환하는지 테스트해 볼 수 있을 것 같네요.

각 분기가 모두 커버리지에 들어갈 수 있도록 테스트 코드를 짜야합니다!

func test_download_sucess() throws {}

테스트할 성공 케이스의 경우는 첫 번째 요청의 etag와 두 번째 요청의 etag가 다를 때, 200을 내려줘서 캐싱된 로컬 데이터가 아닌 서버 데이터르 사용하도록 해야 합니다. 이제 서버에서 반응해줄 동작을 URLProtocol을 통해 구현해보게습니다.

final class MockURLProtocol: URLProtocol {}

Tests Target쪽에 우선 MockURLProtocol 서브클래스를 만들고...

override class func canInit(with request: URLRequest) -> Bool {
  return true
}

우선 canInit(with:) 메서드를 오버라이드 합니다. 해당 메서드를 true로 반환해야지 MockURLProtocol의 동작을 수행하도록 할 수 있습니다.

그 다음은 canonicalRequest(for:) 메서드를 오버라이딩 하는데, 구현 안하면 터지기 때문에 인자의 request를 바로 반환하는 코드로 작성 해 줍니다. canonicalRequest를 어떨 때 쓸 수 있는지는 따로 문서를 읽어보는게 좋을 것 같습니다.

override class func canonicalRequest(for request: URLRequest) -> URLRequest {
  request
}

그 다음은 startLoading() 메서드를 오버라이딩 합니다. startLoading()은 request 요청에 대해 동작을 정의할 수 있습니다. 즉 테스트 용으로 서버에서 그 응답이 온 것처럼 할 수 있다는 것이죠

override func startLoading() {
  let response = HTTPURLResponse(
    url: request.url!,
    statusCode: 200,
    httpVersion: nil,
    headerFields: [:])!

  client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowed)
  client?.urlProtocolDidFinishLoading(self)
}

이제 어떤 요청에도 Request에 대한 응답을 200으로 내려줄 수 있습니다.

이제 이걸 응용해서 서버에서 Etag를 내려주는 것 처럼 할 수 있죠

static var serverEtag: String?

override func startLoading() {
  let response = HTTPURLResponse(
    url: request.url!,
    statusCode: 200,
    httpVersion: nil,
    headerFields: headers())!

  client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowed)
  client?.urlProtocolDidFinishLoading(self)
}

private func headers() -> [String: String] {
  guard let etag = Self.serverEtag else { return [:] }
  return ["Etag": etag]
}

serverEtag가 값이 있으면 서버에서 etag를 내려주는 듯한 테스트를 할 수 있습니다. 클라이언트에서는 URLSession.DataTask의 결과로 etag가 포함된 URLResponse를 받을 수 있게됩니다.

여기에 statusCode도 다르게 줘보겠습니다.

private func statusCode() -> Int {
  return Self.serverEtag == request.allHTTPHeaderFields?["Etag"] as? String ? 304 : 200
}

URLRequest에 etag가 포함되어 있고 만약 지정한 etag랑 같으면 304, 다르면 200으로 statusCode를 내려줄 수 있습니다.

만약 download의 로직에 response에 있는 etag를 저장하고 다음 request에 요청 헤더에 Etag를 잘 포함시켜서 보낸다면 아래 테스트 코드는 성공할 것입니다.

func test_download_use_cache() throws {
  let exp = expectation(description: "")
  // Given
  let etag = UUID().uuidString
  MockURLProtocol.serverEtag = etag
  // When
  sut.download(url) { [unowned self] _, _, _ in
    sut.download(url) { _, result, _ in
      exp.fulfill()
      // Then
      XCTAssertEqual(result, .useCache)
    }
  }
  wait(for: [exp], timeout: 0.1)
}

만약 첫번째 download 이후 etag를 수정하고 두번 째 download를 실시하면 etag가 달라진 상황도 테스트 할 수 있겠네요!

 

OHHTTPStubs

이러한 네트워크 테스트를 좀더 편하게 해주는 라이브러리가 바로 OHHTTPStubs입니다. OHHTTPStubs는 테스트 타겟 뿐만이 아니라 본 프로젝트에서도 디버그 용도로 활용하기 수월하므로 만약 네트워크 통신 테스트를 중요하게 생각하신다면 충분히 선택해볼한 라이브러리 인 것 같습니다.

 

Conclusion

무엇보다 클라이언트 개발자 입장에서 백엔드 API는 항상 늦게 나오는데 기능을 좀더 테스트 해보기 위해서 꼭 필요한 것 같습니다. 실제 테스트를 위해 코드에 덕지덕지 디버그용 코드를 써넣거나 백엔드 API에 의존하지 않고 기능을 마무리 지을 수 있으니까요. 

 

Comments