Skip to content

Commit 52084f7

Browse files
committed
add post
1 parent 9a6544e commit 52084f7

File tree

1 file changed

+359
-0
lines changed

1 file changed

+359
-0
lines changed
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
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

Comments
 (0)