본문 바로가기

iOS/Concurrency

Concurrency (6) Testing Asynchronous Code

이전글 - Concurrency(5) Intermediate async/await & Checked Continuation
 
Modern Concurrency in Swift 를 읽고 간단 정리


https://www.kodeco.com/books/modern-concurrency-in-swift/v1.0/chapters/6-testing-asynchronous-code

 

 

Apple에서 XCTest로 비동기 코드를 테스트하는 것은 역사적으로 복잡했었다.

XCTWaiter, expectations 같은 방법을 사용해야 했고, 코드가 완료될 때 까지 기다렸다가 그 출력 값을 검증해야 한다.

 

https://www.kodeco.com/books/modern-concurrency-in-swift/v1.0/chapters/6-testing-asynchronous-code

 

 

 Swift의 동시성 모델에서는 더 간단하다.

  • 비동기 테스트를 위한 메서드에 async를 선언
  • await 키워드를 만나면 실행을 중단하고, 완료된 후 재개
  • 비동기 코드를 동기 코드처럼 자연스럽게 작성할 수 있음
  • 보다 복잡한 비동기 동작, 시퀀스 및 스트림과 관련된 경우에는 직접 테스트 인프라를 구축 필요
func testAsyncFunction() async throws {
  let result = await someAsyncFunction()
  XCTAssertTrue(result)
}

 

Capturing Network Calls Under Test

네트워크 호출을 테스트하는 두 가지 방법

  • mock URLSeesion 주입하여 테스트에서 요청을 가로채기
  • URLSession을 테스트 환경에서 다르게 동작하도록 구성하여, 테스트 코드에서 요청을 검증하기

 

이 책에서는 위 두 번째 방법에 대해 다룬다.

 

URLSession.configuration을 통해 네트워킹 스택에 커스텀 URL 핸들러를 추가할 수 있다.

  • 예를 들면 tell:// 로 시작하는 모든 링크를 가로채서 앱 내에서 오디오 콜을 할 수 있음
  • 또는 https://youtube.com으로 시작하는 URL을 처리하여 사용자가 Youtube 앱으로 전환하는 것을 방지할 수 있음

 

이러한 핸들러는 URLProtocol의 서브클래스이다.

  • 프로토콜이 아닌 클래스
  • 여기서 Protocol은 Swift의 프로토콜이 아님
  • URL 스킴을 처리하는 규칙의 집합을 의미

https://www.kodeco.com/books/modern-concurrency-in-swift/v1.0/chapters/6-testing-asynchronous-code

 

URLProtocol

잠시 URLProtocol의 기본 개념에 대해서 간략하게 살펴보겠다.

https://developer.apple.com/wwdc18/417

 

 

URLProtocol은 네트워킹 작업을 처리하는 더 낮은 수준의 API로 아래 작업 수행 가능

  • 네트워크 스킴 처리 
    • 다양한 URL 스킴(ex: HTTP, HTTPs)에 맞는 네트워크를 열고 요청을 전송
  • 요청 전송 및 응답 수신
    • 네트워크 요청을 전송하고 서버로부터 받은 응답을 처리
  • 커스터마이징
    • 네트워크 동작을 세부적으로 제어하거나 테스트 환경에서 네트워크 요청을 가로채는 등의 작업

URLProtocol

 

URLProtocol | Apple Developer Documentation

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

developer.apple.com

 

서브클래스를 직접 초기화하면 안된다. 

  • 대신 앱이 지원하는 커스텀 프로토콜이나 URL 스킴에 대해 서브클래스를 생성해야 한다.
  • 다운로드가 시작되면 시스템이 해당 URL 요청을 처리하기 위해 적절한 프로토콜 객체를 생성한다.

앱 시작 시 프로토콜 등록

  • 앱의 실행 시점에 registerClass(_:) 메서드를 호출하여 시스템이 해당 프로토콜 클래스를 인식하도록 한다.
// register
URLProtocol.registerClass(TestUrlProtocol.self)

// unregister
URLProtocol.unregisterClass(TestUrlProtocol.self)

 

URLRequset extension

 

extension URLRequest {
    var customData: String? {
        get {
            return URLProtocol.property(forKey: "CustomData", in: self) as? String
        }
        set {
            URLProtocol.setProperty(newValue, forKey: "CustomData", in: self)
        }
    }
}

 

 

URLResponse 생성

  • 서브클래스가 요청을 성공적으로 처리하면, URLResponse 객체를 생성하여 클라이언트에 반환해야 한다.
class TestURLProtocol: URLProtocol {
  ...
  
  override func startLoading() {
    ...
    let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)
    client?.urlProtocol(self, didReceive: response!, cacheStoragePolicy: .notAllowed)
    ...
  }

 

 

서브클래싱시 주의사항

Implementing a Custom URLProtocol

URLProtocol을 상속받는 TestURLProtocol을 만들어 보자.  

  • 네트워크 Request에 대한 검증 목적을 위한 TestURLProtocol을 선언
이 책에서 다루고 있는 BlabberModel은 단순한 입/출력 함수가 아니라 데이터를 처리한 후 서버로 전송하는 방식으로 구현됐다. 비동기 테스트를 위해 올바른 데이터가 서버에 전달되는지 검증해야 한다.
class TestURLProtocol: URLProtocol {
  override class func canInit(with task: URLSessionTask) -> Bool {
    return true
  }

  override class func canonicalRequest(for request: URLRequest) -> URLRequest {
    return request
  }
  
  override func startLoading() {
    guard let client = self.client,
          let url = self.request.url,
          let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)
    else { 
      fatalError("Client or URL missing") 
    }
    
    client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
    client.urlProtocol(self, didLoad: Data())
    client.urlProtocolDidFinishLoading(self)
  }

  override func stopLoading() {
    // do nothing
  }
}
  • canInit(with:) : 주어진 task를 처리할 수 있는지 여부
    • 모든 task를 처리하고 싶으니 단순히 true를 return
  • canoncialRequest(for:) : request를 실시간을 변경할 수 있는 기능을 제공
    • 현재 테스트들에서 request에 대한 조작이 필요하지는 않다. 매개변수를 그대로 반환한다.
  • startLoading() : 네트워크 요청을 처리하고 클라이언트에게 응답을 반환하는 메서드
    • 우선은 응답 값이 아닌 서버로의 요청에 대한 테스트를 하고 싶다. 빈 데이터를 리턴하도록 구현했다.
  • stopLoading() : 네트워크 요청이 취소되거나 세션에서 더이상 요청을 처리하지. 않아야할 때 호출되는 메서드
    • 테스트를 위한 URLProtocol에서 별도 처리가 필요하지 않아 다루지 않았다.

 

다음으로 마지막 응답에 대한 검증을 하기 위해 lastRequest를 추가

  • URLProtocol의 인스턴스에 직접 접근하는 것이 까다롭기에 static으로 선언함
class TestURLProtocol: URLProtocol {
  
  static var lastRequest: URLRequest?
  ...
}

 

그리고 startLoading()에서 request를 저장하도록 한다.

class TestURLProtocol: URLProtocol {
  
  static var lastRequest: URLRequest?
  
  ...
  
  override func startLoading() {
    ...
    
    guard let stream = self.request.httpBodyStream else {
      fatalError("Unexpected test scenario")
    }
    
    var request = self.request
    request.httpBody = stream.data
    Self.lastRequest = request
  }
  ...
}

 

 

Creating a Model for Testing

이제 Test를 위한 sut (system under test)를 선언

class BlabberTests: XCTestCase {
  @MainActor
  let sut: BlabberModel = {
    // 1
    let model = BlabberModel()
    model.username = "test"
    
    // 2
    let testConfiguation = URLSessionConfiguration.default
    testConfiguation.protocolClasses = [TestURLProtocol.self]
    
    // 3
    model.urlSession = URLSession(configuration: testConfiguation)
    
    return model
  }()
}

 

  1. 테스트 대상인 BlabberModel을 만들고 테스트를 위한 username을 설정
  2. TestURLProtocol을 사용하는 URLSessionConfiguration 설정
  3. 모델이 해당 configuration을 사용하도록 설정

Adding a Simple Asynchronous Test

Request에 대한 urlString, body 값에 대한 검증을 수행하는 테스트 코드를 추가해보자.

테스트 메서드는 async 키워드를 포함한다.

class BlabberTests: XCTestCase {
  ...
  
  func test_say_url() async throws {
    // given
    let messageToRequest = "Hello!"
    let expectedRequestUrlString = "http://localhost:8080/chat/say"
    
    // when
    try await self.sut.say(messageToRequest)
    
    // then
    let request = try XCTUnwrap(TestURLProtocol.lastRequest)
    XCTAssertEqual(request.url?.absoluteString, expectedRequestUrlString)
    
    let httpBody = try XCTUnwrap(request.httpBody)
    let message = try XCTUnwrap(try? JSONDecoder().decode(Message.self, from: httpBody))
    
    XCTAssertEqual(message.message, messageToRequest)
  }
}

 

given

  • request에 전달할 메세지와 예상되는 request url을 정의했다.

when

  • await 키워드로 테스트할 메서드를 호출한다. 응답 값을 받을 때 까지 일시 중단된다.

thed

  • request에 대한 검증을 한다.

Testing Values Over Time With AsyncStream

AsyncStram을 사용하는 countdown(to:) 에 대한 테스트를 진행해보려 한다.

테스트할 메서드의 구현은 아래와 같다.

class BlabberModel: ObservableObject { 
  ...
  func countdown(to message: String) async throws {
    guard !message.isEmpty else { return }
    var countdown = 3
    let counter = AsyncStream<String> {
      guard countdown >= 0 else { return nil }
      do {
        try await Task.sleep(for: .seconds(1))
      } catch {
        return nil
      }
      defer { countdown -= 1 }
      if countdown == 0 {
        return "🎉 " + message
      } else {
        return "\(countdown)..."
      }
    }

    try await counter.forEach {
      try await self.say($0)
    }
  }
}

 

countDown 메서드는 총 4번의 request를 진행한다.

  • say("3...")
  • say("2...")
  • say("1...")
  • say("🎉 " + message)

따라서 우리는 lastRequest의 검증만으로 모든 request들이 성공적으로 이뤄졌는지 알 수 없다.

 

시간에 따라 변경되는 값들의 검증을 위해 테스트를 위한 AsyncStream을 추가하여 검증하는 아이디어를 소개한다.

 

4장에서 학습한 AsyncStream을 TestURLProtocol에 추가하여 모든 Request들을 Stream으로 관리해보자.

class TestURLProtocol: URLProtocol {
  // 1
  static var requests: AsyncStream<URLRequest> = {
    AsyncStream { continuation in
      TestURLProtocol.continuation = continuation
    }
  }()
  
  // 3
  static var lastRequest: URLRequest? {
    didSet {
      if let request = Self.lastRequest {
        Self.continuation?.yield(request)
      }
    }
  }
  
  // 2
  static private var continuation: AsyncStream<URLRequest>.Continuation?
  ...
}

 

1. requests

  • 각 URLReqeust를 방출하기 위한 AsyncStream을 생성한다.

2. continutation

  • asyncStream의 continuation을 static property로 들고 있는다.

3. lastRequest

  • lastRequest에 값이 설정될 때 저장해둔 continuation에 request 값을 방출한다.

Completing the Countdown Test

class BlabberTests: XCTestCase {
  ...
  
  func test_countdown() async throws {
    // given
    let messageToRequest = "Tada!"
    
    // when
    try await self.sut.countdown(to: messageToRequest)
    for await request in TestURLProtocol.requests {
      // then
      ...    
    }
  }

 

여전히 존재하는 두 가지 문제

  1. await은 타임아웃이 없다. 테스트가 영원히 끝나지 않는다.
  2. for await 루프를 돌기 시작할 때 countdown(to:)는 이미 종료되어 request가 방출되지 않는다.

1번부터 하나씩 해결해보자.

Adding TimeoutTask for Safer Testing

  1. await은 타임아웃이 없다. 테스트가 영원히 끝나지 않는다.
  2. for await 루프를 돌기 시작할 때 countdown(to:)는 이미 종료되어 request가 방출되지 않는다.

TimeoutTask라는 Wrapper Task를 만들어서 테스트가 잘 수행되도록 지원해보겠다.

class TimeoutTask<Success> {
    
}

extension TimeoutTask {
  struct TimeoutError: LocalizedError {
    var errorDescription: String? {
      return "The operation timed out."
    }
  }
}

 

제네릭 타입을 사용하여 Success 라는 결과를 반환하는 TimeoutTask를 선언했다.

  • 결과를 반환하지 않으면, Success 타입은 Void
  • 타임아웃될 경우에 던질 에러를 정의

이는 Swift의 Task와 유사하게 동작하며, Task가 성공했을 때 반환하는 결과 타입을 나타낸다.

@frozen
struct Task<Success, Failure> where Success : Sendable, Failure : Error

 

이제 TimeoutTask를 위해 필요한 기본 프로퍼티들을 추가한다.

  • seconds
    • 타임아웃을 위해 정의된 max duration
  • operation
    • 수행할 비동기 작업
class TimeoutTask<Success> {
  let seconds: Int
  let operation: @Sendable () async throws -> Success
  
  init(
    seconds: Int,
    operation: @escaping @Sendable () async throws -> Success
  ) {
    self.seconds = seconds
    self.operation = operation
  }
  
}

 

@Sendable

챕터 8 Actor에서 자세히 더 살펴본다 !

Swift의 동시성 모델에서 클로저나 함수 타입이 안전하게 다른 동시성 도메인(Concurrency Domain)간에 전달될 수 있음을 나타내는 키워드이다. Swift의 동시성 시스템에서는 데이터를 안전하게 공유하기 위해 특정 규칙을 따라야 하며, 이때 Sendable 프로토콜은 여러 스레드나 Task간에 안전하게 전송될 수 있음을 보장한다.

 

Starting the Task and Returing its Result

operation 결과를 반환할 value 프로퍼티를 추가해보자.

  • 작업을 시작하고 그 결과를 비동기적으로 반환하는 방식
  • get async
  • 테스트 시 작업의 실행 타이밍을 더 세밀하게 제어할 수 있다.
  • 이 속성이 호출되는 순간 작업을 시작하고 비동기적으로 그 결과를 반환한다.
  • withCheckedThrowingContinuation(_:)를 사용하여 완료될 때 결과를 반환하거나 타임아웃과 같은 에러를 던질 수 있는 제어권을 갖는다.

 

class TimeoutTask<Success> {
  ...
  
  private var continuation: CheckedContinuation<Success, Error>?
  
  var value: Success {
    get async throws {
      try await withCheckedThrowingContinuation { continuation in
        self.continuation = continuation
        // 여기서 결과를 반환하거나 타임아웃 에러를 던짐!
      }
    }
  }
}

 

그럼 이제 성공과 타임아웃 에러를 반환해보자.

  var value: Success {
    get async throws {
      try await withCheckedThrowingContinuation { continuation in
        self.continuation = continuation
        
        Task {
          try await Task.sleep(for: .seconds(self.seconds))
          self.continuation?.resume(throwing: TimeoutError())
          self.continuation = nil
        }
        
        Task {
          let result = try await self.operation()
          self.continuation?.resume(returning: result)
          self.continuation = nil
        }
      }
    }
  }

 

두 개의 Task가 동시에 continuation을 사용하려고 시도하는 경우 crash가 발생할 수 있다. (공유 자원과 관련된 동시성 문제) 이 문제를 해결하기 위해 Swift는 actor 타입을 제공하는데 이는 이후 챕터에서 살펴보도록 하자.

 

Canceling Your Task

기존 Task 처럼 cancel() 지원하기

class TimeoutTask<Success> {
  ...
  private var continuation: CheckedContinuation<Success, Error>?
  ...
  
  func cancel() {
    self.continuation?.resume(throwing: CancellationError())
    self.continuation = nil
  }
}

 

마지막으로 테스트로 돌아와서 for await 루프를 TimeoutTask로 감싼다. 

class BlabberTests: XCTestCase {
  ...
  func test_countdown() async throws {
    // given
    let messageToRequest = "Tada!"
    
    // when
    try await self.sut.countdown(to: messageToRequest)
    
    try await TimeoutTask(seconds: 10) {
      for await request in TestURLProtocol.requests {
        ...
      }
    }.value
  }
  ...
}

 

Using async let to Produce Effects and Observe Them at the Same Time

아래의 문제 중 타임아웃 관련 문제는 TimeoutTask를 구현하여 해결하였다. 이제 countdown이 먼저 끝나버리는 문제를 해결해보자.

  1. await은 타임아웃이 없다. 테스트가 영원히 끝나지 않는다.
  2. for await 루프를 돌기 시작할 때 countdown(to:)는 이미 종료되어 request가 방출되지 않는다.

2장에서 배운 asnyc let 바인딩을 사용하면 countdown이 호출될 때 suspend 처리하지 않고 병렬로 수행시킬 수 있다.

  func test_countdown() async throws {
    // given
    let messageToRequest = "Tada!"
    
    // when
    async let countdown: Void = self.sut.countdown(to: messageToRequest)
    async let messages = TimeoutTask(seconds: 10) {
      await TestURLProtocol.requests
        .prefix(4)
        .compactMap(\.httpBody)
        .compactMap { data in
          try? JSONDecoder()
            .decode(Message.self, from: data)
            .message
        }
        .reduce(into: []) { result, message in
          result.append(message)
        }
    }.value
    
    // then
    let (messagesResult, _) = try await (messages, countdown)
    XCTAssertEqual(["3...", "2...", "1...", "🎉 " + messageToRequest], messagesResult)
  }