이전글 - Concurrency(5) Intermediate async/await & Checked Continuation
Modern Concurrency in Swift 를 읽고 간단 정리
Apple에서 XCTest로 비동기 코드를 테스트하는 것은 역사적으로 복잡했었다.
XCTWaiter, expectations 같은 방법을 사용해야 했고, 코드가 완료될 때 까지 기다렸다가 그 출력 값을 검증해야 한다.
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 스킴을 처리하는 규칙의 집합을 의미
URLProtocol
잠시 URLProtocol의 기본 개념에 대해서 간략하게 살펴보겠다.
URLProtocol은 네트워킹 작업을 처리하는 더 낮은 수준의 API로 아래 작업 수행 가능
- 네트워크 스킴 처리
- 다양한 URL 스킴(ex: HTTP, HTTPs)에 맞는 네트워크를 열고 요청을 전송
- 요청 전송 및 응답 수신
- 네트워크 요청을 전송하고 서버로부터 받은 응답을 처리
- 커스터마이징
- 네트워크 동작을 세부적으로 제어하거나 테스트 환경에서 네트워크 요청을 가로채는 등의 작업
서브클래스를 직접 초기화하면 안된다.
- 대신 앱이 지원하는 커스텀 프로토콜이나 URL 스킴에 대해 서브클래스를 생성해야 한다.
- 다운로드가 시작되면 시스템이 해당 URL 요청을 처리하기 위해 적절한 프로토콜 객체를 생성한다.
앱 시작 시 프로토콜 등록
- 앱의 실행 시점에 registerClass(_:) 메서드를 호출하여 시스템이 해당 프로토콜 클래스를 인식하도록 한다.
// register
URLProtocol.registerClass(TestUrlProtocol.self)
// unregister
URLProtocol.unregisterClass(TestUrlProtocol.self)
URLRequset extension
- URLRequset 클래스의 extension하여 특정 프로토콜에 맞는 요청을 저장해두고 사용할 수 있다.
- property(forKey:in:), setProperty(_:forKey:in)
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)
...
}
서브클래싱시 주의사항
- 시스템은 task 매개변수를 포함한 메서드를 우선 호출한다는 것을 인지해야 한다.
- 따라서 task-based 메서드를 오버라이딩 해야 한다.
- ex. init(task:cachedResponse:client:) 를 init(request:cachedResponse) 대신 오버라이딩
- ex. canInit(with:)를 canInit(with:) 대신 오버라이딩
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
}()
}
- 테스트 대상인 BlabberModel을 만들고 테스트를 위한 username을 설정
- TestURLProtocol을 사용하는 URLSessionConfiguration 설정
- 모델이 해당 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
...
}
}
여전히 존재하는 두 가지 문제
- await은 타임아웃이 없다. 테스트가 영원히 끝나지 않는다.
- for await 루프를 돌기 시작할 때 countdown(to:)는 이미 종료되어 request가 방출되지 않는다.
1번부터 하나씩 해결해보자.
Adding TimeoutTask for Safer Testing
- await은 타임아웃이 없다. 테스트가 영원히 끝나지 않는다.
- 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이 먼저 끝나버리는 문제를 해결해보자.
- await은 타임아웃이 없다. 테스트가 영원히 끝나지 않는다.
- 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)
}
'iOS > Concurrency' 카테고리의 다른 글
Concurrency (8) Getting Started With Actors (0) | 2024.09.29 |
---|---|
Concurrency (7) Concurrent Code With TaskGroup (0) | 2024.09.15 |
Concurrency (5) Intermediate async/await & Checked Continuation (0) | 2024.09.01 |
Concurrency (4) Custom Asynchronous Sequences With AsyncStream (0) | 2024.08.24 |
Concurrency (3) AsyncSequence & Intermediate Task (0) | 2024.08.15 |