|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +title: "[Swift6] Swift에서 비동기 호출을 검증하기: Polling 기반 테스트 헬퍼 |
| 4 | +tags: [] |
| 5 | +--- |
| 6 | +{% include JB/setup %} |
| 7 | +
|
| 8 | +Swift 5.5 부터 Concurrency가 도입되면서 비동기 함수를 훨씬 직관적으로 다룰 수 있게 되었습니다. 하지만 테스트 코드에서는 여전히 문제가 남아 있습니다. |
| 9 | +“호출 횟수가 정확히 증가했는지”, “여러 callCount 변수가 특정 값에 도달했는지”를 검증하려면 단순히 XCTAssertEqual만으로는 부족합니다. 왜냐하면 비동기 작업의 완료 시점이 불확실하기 때문입니다. |
| 10 | +
|
| 11 | +이 글에서 Polling 테스트 헬퍼를 구현하고, 어떻게 적용할 수 있을지를 살펴보겠습니다. |
| 12 | +
|
| 13 | +## 문제 상황 |
| 14 | +
|
| 15 | +일반적으로 async 함수의 호출 여부를 확인하려면 다음과 같이 작성합니다. |
| 16 | +
|
| 17 | +```swift |
| 18 | +@Test |
| 19 | +func testAsyncFunction() async throws { |
| 20 | + var callCount = 0 |
| 21 | + |
| 22 | + Task.detached { |
| 23 | + try? await Task.sleep(nanoseconds: 100_000_000) |
| 24 | + callCount += 1 |
| 25 | + } |
| 26 | + |
| 27 | + #expect(callCount == 1) |
| 28 | +} |
| 29 | +``` |
| 30 | +
|
| 31 | +이 코드는 대부분 실패합니다. Task.detached 내부의 비동기 호출이 끝나기 전에 검증 코드가 실행되기 때문입니다. 이를 해결하려고 sleep을 넣으면, 테스트가 느려지고, 환경에 따라 테스트가 실패할 수 있습니다. |
| 32 | +
|
| 33 | +```swift |
| 34 | +/// Bad example |
| 35 | +@Test |
| 36 | +func testAsyncFunction() async throws { |
| 37 | + var callCount = 0 |
| 38 | +
|
| 39 | + Task.detached { |
| 40 | + try? await Task.sleep(nanoseconds: 100_000_000) |
| 41 | + callCount += 1 |
| 42 | + } |
| 43 | + try await Task.sleep(nanoseconds: 200_000_000) |
| 44 | + #expect(callCount == 1) |
| 45 | +} |
| 46 | +``` |
| 47 | +
|
| 48 | +## 해결 방법 1 - XCTestExpectation |
| 49 | +
|
| 50 | +XCTest가 제공하는 공식적인 방법은 XCTestExpectation을 사용하는 것입니다. |
| 51 | +
|
| 52 | +다음 코드는 앞의 예제 코드에서 사용했던 sleep 대신 XCTestExpectation를 사용합니다. |
| 53 | +
|
| 54 | +```swift |
| 55 | +func testAsyncFunction() { |
| 56 | + let exp = XCTestExpectation(description: "callCount 증가") |
| 57 | + var callCount = 0 |
| 58 | + |
| 59 | + Task.detached { |
| 60 | + try? await Task.sleep(nanoseconds: 100_000_000) |
| 61 | + callCount += 1 |
| 62 | + exp.fulfill() |
| 63 | + } |
| 64 | + |
| 65 | + wait(for: [exp], timeout: 1.0) |
| 66 | + XCTAssertEqual(callCount, 1) |
| 67 | +} |
| 68 | +``` |
| 69 | +
|
| 70 | +이 방식으로 비동기 함수의 호출 여부를 검증할 수 있습니다. 하지만 여러 개의 callCount를 동시에 검증하거나, 복잡한 조건이 있다면 해당 코드는 복잡해집니다. |
| 71 | +
|
| 72 | +예를 들어 A, B, C 세 가지 값이 각각 1, 2, 3이 되어야 한다면, expectation도 세 개를 만들고, fulfill도 제각각 호출해줘야 합니다. |
| 73 | +이 경우 테스트 코드는 `검증`이 아니라 `제어 흐름 관리`에 치중하게 되어, 가독성이 심하게 떨어지는 문제가 있습니다. |
| 74 | + |
| 75 | +## 해결 방법 2 - Polling |
| 76 | + |
| 77 | +보다 나은 방법은 “값이 원하는 상태에 도달할 때까지 일정 간격으로 검사(polling)”하는 것입니다. |
| 78 | +이를 위해 assertEventuallyEqual이라는 헬퍼를 만듭니다. |
| 79 | + |
| 80 | +```swift |
| 81 | +public enum EventuallyError<T: Equatable & Sendable>: |
| 82 | + Error, |
| 83 | + CustomStringConvertible { |
| 84 | + case timeout(last: T, expected: T) |
| 85 | +
|
| 86 | + public var description: String { |
| 87 | + switch self { |
| 88 | + case let .timeout(last, expected): |
| 89 | + return "Timed out. last=\(last), expected=\(expected)" |
| 90 | + } |
| 91 | + } |
| 92 | +} |
| 93 | +
|
| 94 | +public func assertEventuallyEqual<T: Equatable & Sendable>( |
| 95 | + _ expected: T, |
| 96 | + _ current: @escaping @Sendable () async -> T, |
| 97 | + timeout: TimeInterval = 2.0, |
| 98 | + interval: TimeInterval = 0.02, |
| 99 | + file: StaticString = #filePath, |
| 100 | + line: UInt = #line |
| 101 | +) async throws { |
| 102 | + let deadline = Date().addingTimeInterval(timeout) |
| 103 | + while Date() < deadline { |
| 104 | + let value = await current() |
| 105 | + if value == expected { |
| 106 | + return |
| 107 | + } |
| 108 | + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) |
| 109 | + } |
| 110 | + let last = await current() |
| 111 | + throw EventuallyError.timeout(last: last, expected: expected) |
| 112 | +} |
| 113 | +``` |
| 114 | + |
| 115 | +앞의 예제 코드에서 `assertEventuallyEqual` 를 이용하여 테스트 코드를 작성합니다. |
| 116 | + |
| 117 | +```swift |
| 118 | +@Test |
| 119 | +func testAsyncFunction() async throws { |
| 120 | + let count = Counter() |
| 121 | +
|
| 122 | + Task { |
| 123 | + try? await Task.sleep(nanoseconds: 100_000_000) |
| 124 | + count.call() |
| 125 | + } |
| 126 | +
|
| 127 | + try await assertEventuallyEqual(1, { count.callCount }, timeout: 5.0) |
| 128 | +} |
| 129 | +``` |
| 130 | + |
| 131 | +이제 XCTAssertEqual 대신 assertEventuallyEqual을 쓰면 비동기 코드도 안정적으로 검증할 수 있고, |
| 132 | +여러 조건이 있는 경우에도 XCTestExpectation보다 훨씬 간결하게 표현할 수 있습니다. |
| 133 | + |
| 134 | +## 해결 방법 3 - 다중 조건 검증 |
| 135 | + |
| 136 | +여러 callCount 변수를 동시에 검증해야 하는 경우도 자주 있습니다. |
| 137 | + |
| 138 | +예를 들어 A, B 두 개의 호출 카운트가 각각 1, 2가 되어야 한다면, assertEventuallyAllEqual을 활용할 수 있습니다. |
| 139 | + |
| 140 | +```swift |
| 141 | +public enum EventuallyErrorAll: Error, CustomStringConvertible { |
| 142 | + case timeoutAll(String) |
| 143 | +
|
| 144 | + public var description: String { |
| 145 | + switch self { |
| 146 | + case let .timeoutAll(message): message |
| 147 | + } |
| 148 | + } |
| 149 | +} |
| 150 | +
|
| 151 | +public struct Check<T: Equatable & Sendable>: Sendable { |
| 152 | + public let name: String |
| 153 | + public let expected: T |
| 154 | + public let current: @Sendable () async -> T |
| 155 | +
|
| 156 | + public init( |
| 157 | + name: String, |
| 158 | + expected: T, |
| 159 | + current: @escaping @Sendable () async -> T |
| 160 | + ) { |
| 161 | + self.name = name |
| 162 | + self.expected = expected |
| 163 | + self.current = current |
| 164 | + } |
| 165 | +} |
| 166 | +
|
| 167 | +public func assertEventuallyAllEqual<T: Equatable & Sendable>( |
| 168 | + _ checks: [Check<T>], |
| 169 | + timeout: TimeInterval = 2.0, |
| 170 | + interval: TimeInterval = 0.02, |
| 171 | + file: StaticString = #filePath, |
| 172 | + line: UInt = #line |
| 173 | +) async throws { |
| 174 | + let deadline = Date().addingTimeInterval(timeout) |
| 175 | +
|
| 176 | + while Date() < deadline { |
| 177 | + let currents: [T] = await withTaskGroup(of: T.self) { group in |
| 178 | + for c in checks { |
| 179 | + group.addTask { await c.current() } |
| 180 | + } |
| 181 | + return await group.reduce(into: []) { $0.append($1) } |
| 182 | + } |
| 183 | +
|
| 184 | + let allMatch = zip(checks, currents).allSatisfy { $0.expected == $1 } |
| 185 | + if allMatch { return } |
| 186 | +
|
| 187 | + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) |
| 188 | + } |
| 189 | +
|
| 190 | + let results: [(String, T, T)] = await withTaskGroup(of: (String, T, T).self) { group in |
| 191 | + for c in checks { |
| 192 | + group.addTask { |
| 193 | + let v = await c.current() |
| 194 | + return (c.name, v, c.expected) |
| 195 | + } |
| 196 | + } |
| 197 | + return await group.reduce(into: []) { $0.append($1) } |
| 198 | + } |
| 199 | +
|
| 200 | + let diff = results |
| 201 | + .filter { $0.1 != $0.2 } |
| 202 | + .map { "• \($0.0): last=\($0.1) expected=\($0.2)" } |
| 203 | + .joined(separator: "\n") |
| 204 | +
|
| 205 | + throw EventuallyErrorAll.timeoutAll("Timed out waiting for all equal.\n\(diff)\nfile: \(file), line: \(line)")") |
| 206 | +} |
| 207 | +``` |
| 208 | + |
| 209 | +이제 `assertEventuallyAllEqual`을 사용하여 테스트 코드를 작성합니다. |
| 210 | + |
| 211 | +```swift |
| 212 | +final class Counter: @unchecked Sendable { |
| 213 | + private(set) var aCallCount = 0 |
| 214 | + private(set) var bCallCount = 0 |
| 215 | + func call() { |
| 216 | + aCallCount += 1 |
| 217 | + bCallCount += 1 |
| 218 | + } |
| 219 | +} |
| 220 | +
|
| 221 | +@Test |
| 222 | +func testAsyncFunction() async throws { |
| 223 | + let count = Counter() |
| 224 | +
|
| 225 | + Task.detached { |
| 226 | + try? await Task.sleep(nanoseconds: 100_000_000) |
| 227 | + count.call() |
| 228 | + } |
| 229 | +
|
| 230 | + try await assertEventuallyAllEqual( |
| 231 | + [ |
| 232 | + Check(name: "A Count", expected: 1, current: { count.aCallCount }), |
| 233 | + Check(name: "B Count", expected: 1, current: { count.bCallCount }) |
| 234 | + ], |
| 235 | + timeout: 5.0) |
| 236 | +} |
| 237 | +``` |
| 238 | + |
| 239 | +assertEventuallyAllEqual 함수를 사용하여, 코드가 짧고, 실패 시에는 어떤 값이 기대치와 달랐는지도 상세하게 출력합니다. 그리고 Expectation을 늘어놓는 것보다 훨씬 직관적입니다. |
| 240 | + |
| 241 | +## 마무리 |
| 242 | + |
| 243 | +XCTestExpectation은 비동기 검증의 표준이지만, 조건이 많아질수록 테스트 코드의 가독성이 크게 떨어집니다. |
| 244 | + |
| 245 | +Polling 기반의 헬퍼는 이런 한계를 보완하면서도 다음과 같은 장점을 제공합니다 |
| 246 | +- 조건 충족까지 대기 → sleep 의존 제거 |
| 247 | +- 여러 변수 동시 검증 → expectation 난립 방지 |
| 248 | +- 상세 실패 메시지 출력 → 디버깅 편의성 향상 |
| 249 | + |
| 250 | +즉, “테스트 코드가 제어 흐름 관리에 매몰되지 않고 검증 로직 자체에 집중할 수 있게 된다”는 점이 가장 큰 장점입니다. |
| 251 | + |
| 252 | +<br/> |
| 253 | + |
| 254 | +--- |
| 255 | + |
| 256 | +## 전체 코드 - [Gist](https://gist.github.com/minsOne/f30cc00217bd88edd6d6dd2716876afb) |
| 257 | + |
| 258 | +```swift |
| 259 | +public enum EventuallyError<T: Equatable & Sendable>: |
| 260 | + Error, |
| 261 | + CustomStringConvertible |
| 262 | +{ |
| 263 | + case timeout(last: T, expected: T) |
| 264 | + |
| 265 | + public var description: String { |
| 266 | + switch self { |
| 267 | + case let .timeout(last, expected): |
| 268 | + "Timed out. last=\(last), expected=\(expected)" |
| 269 | + } |
| 270 | + } |
| 271 | +} |
| 272 | + |
| 273 | +public enum EventuallyErrorAll: Error, CustomStringConvertible { |
| 274 | + case timeoutAll(String) |
| 275 | + |
| 276 | + public var description: String { |
| 277 | + switch self { |
| 278 | + case let .timeoutAll(message): message |
| 279 | + } |
| 280 | + } |
| 281 | +} |
| 282 | + |
| 283 | +public struct Check<T: Equatable & Sendable>: Sendable { |
| 284 | + public let name: String |
| 285 | + public let expected: T |
| 286 | + public let current: @Sendable () async -> T |
| 287 | + |
| 288 | + public init( |
| 289 | + name: String, |
| 290 | + expected: T, |
| 291 | + current: @escaping @Sendable () async -> T |
| 292 | + ) { |
| 293 | + self.name = name |
| 294 | + self.expected = expected |
| 295 | + self.current = current |
| 296 | + } |
| 297 | +} |
| 298 | + |
| 299 | +public func assertEventuallyEqual<T: Equatable & Sendable>( |
| 300 | + _ expected: T, |
| 301 | + _ current: @escaping @Sendable () async -> T, |
| 302 | + timeout: TimeInterval = 2.0, |
| 303 | + interval: TimeInterval = 0.02, |
| 304 | + file: StaticString = #filePath, |
| 305 | + line: UInt = #line |
| 306 | +) async throws { |
| 307 | + let deadline = Date().addingTimeInterval(timeout) |
| 308 | + while Date() < deadline { |
| 309 | + let value = await current() |
| 310 | + if value == expected { |
| 311 | + return |
| 312 | + } |
| 313 | + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) |
| 314 | + } |
| 315 | + let last = await current() |
| 316 | + throw EventuallyError.timeout(last: last, expected: expected) |
| 317 | +} |
| 318 | + |
| 319 | +public func assertEventuallyAllEqual<T: Equatable & Sendable>( |
| 320 | + _ checks: [Check<T>], |
| 321 | + timeout: TimeInterval = 2.0, |
| 322 | + interval: TimeInterval = 0.02, |
| 323 | + file: StaticString = #filePath, |
| 324 | + line: UInt = #line |
| 325 | +) async throws { |
| 326 | + let deadline = Date().addingTimeInterval(timeout) |
| 327 | + |
| 328 | + while Date() < deadline { |
| 329 | + let currents: [T] = await withTaskGroup(of: T.self) { group in |
| 330 | + for c in checks { |
| 331 | + group.addTask { await c.current() } |
| 332 | + } |
| 333 | + return await group.reduce(into: []) { $0.append($1) } |
| 334 | + } |
| 335 | + |
| 336 | + let allMatch = zip(checks, currents).allSatisfy { $0.expected == $1 } |
| 337 | + if allMatch { return } |
| 338 | + |
| 339 | + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) |
| 340 | + } |
| 341 | + |
| 342 | + let results: [(String, T, T)] = await withTaskGroup(of: (String, T, T).self) { group in |
| 343 | + for c in checks { |
| 344 | + group.addTask { |
| 345 | + let v = await c.current() |
| 346 | + return (c.name, v, c.expected) |
| 347 | + } |
| 348 | + } |
| 349 | + return await group.reduce(into: []) { $0.append($1) } |
| 350 | + } |
| 351 | + |
| 352 | + let diff = results |
| 353 | + .filter { $0.1 != $0.2 } |
| 354 | + .map { "• \($0.0): last=\($0.1) expected=\($0.2)" } |
| 355 | + .joined(separator: "\n") |
| 356 | + |
| 357 | + throw EventuallyErrorAll.timeoutAll("Timed out waiting for all equal.\n\(diff)\nfile: \(file), line: \(line)") |
| 358 | +} |
| 359 | +``` |
0 commit comments