Skip to content

Commit 58f5577

Browse files
committed
Create reusable/generic async LoaderSpy
1 parent 4d6583b commit 58f5577

File tree

3 files changed

+94
-60
lines changed

3 files changed

+94
-60
lines changed

EssentialApp/EssentialApp.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
0832C9D0238D2811002314C9 /* SceneDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0832C9CF238D2811002314C9 /* SceneDelegateTests.swift */; };
2727
0835BF6D24850F9800A793D2 /* CombineHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0835BF6C24850F9800A793D2 /* CombineHelpers.swift */; };
2828
08367CD82486FB51009CD536 /* UIView+TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08367CD72486FB51009CD536 /* UIView+TestHelpers.swift */; };
29+
084BE5342EB38EC5006886E9 /* LoaderSpy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084BE5332EB38EC5006886E9 /* LoaderSpy.swift */; };
2930
0851CDAC239AB13100C19B1D /* HTTPClientStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0851CDAB239AB13100C19B1D /* HTTPClientStub.swift */; };
3031
088B441925309AA300D75AAD /* CommentsUIIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 088B441825309AA300D75AAD /* CommentsUIIntegrationTests.swift */; };
3132
088B441C25309B6E00D75AAD /* CommentsUIComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 088B441B25309B6E00D75AAD /* CommentsUIComposer.swift */; };
@@ -84,6 +85,7 @@
8485
0832C9CF238D2811002314C9 /* SceneDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegateTests.swift; sourceTree = "<group>"; };
8586
0835BF6C24850F9800A793D2 /* CombineHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineHelpers.swift; sourceTree = "<group>"; };
8687
08367CD72486FB51009CD536 /* UIView+TestHelpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+TestHelpers.swift"; sourceTree = "<group>"; };
88+
084BE5332EB38EC5006886E9 /* LoaderSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoaderSpy.swift; sourceTree = "<group>"; };
8789
0851CDAB239AB13100C19B1D /* HTTPClientStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClientStub.swift; sourceTree = "<group>"; };
8890
088B441825309AA300D75AAD /* CommentsUIIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsUIIntegrationTests.swift; sourceTree = "<group>"; };
8991
088B441B25309B6E00D75AAD /* CommentsUIComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsUIComposer.swift; sourceTree = "<group>"; };
@@ -137,6 +139,7 @@
137139
082C00032359E46C008927D3 /* XCTestCase+MemoryLeakTracking.swift */,
138140
082C00052359E4C6008927D3 /* SharedTestHelpers.swift */,
139141
0851CDAB239AB13100C19B1D /* HTTPClientStub.swift */,
142+
084BE5332EB38EC5006886E9 /* LoaderSpy.swift */,
140143
);
141144
path = Helpers;
142145
sourceTree = "<group>";
@@ -325,6 +328,7 @@
325328
082C00062359E4C6008927D3 /* SharedTestHelpers.swift in Sources */,
326329
088B441925309AA300D75AAD /* CommentsUIIntegrationTests.swift in Sources */,
327330
08073B57238D2E1000A75DC6 /* UIControl+TestHelpers.swift in Sources */,
331+
084BE5342EB38EC5006886E9 /* LoaderSpy.swift in Sources */,
328332
08073B56238D2E1000A75DC6 /* FeedUIIntegrationTests+Assertions.swift in Sources */,
329333
0832C9D0238D2811002314C9 /* SceneDelegateTests.swift in Sources */,
330334
08073B5B238D2E1000A75DC6 /* ListViewController+TestHelpers.swift in Sources */,

EssentialApp/EssentialAppTests/Helpers/FeedUIIntegrationTests+LoaderSpy.swift

Lines changed: 10 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -65,87 +65,37 @@ extension FeedUIIntegrationTests {
6565

6666
// MARK: - FeedImageDataLoader
6767

68-
private var imageRequests = [(
69-
url: URL,
70-
publisher: AsyncThrowingStream<Data, Error>,
71-
continuation: AsyncThrowingStream<Data, Error>.Continuation,
72-
result: AsyncResult?
73-
)]()
74-
75-
enum AsyncResult {
76-
case success
77-
case failure
78-
case cancelled
79-
}
68+
private var imageLoader = EssentialAppTests.LoaderSpy<URL, Data>()
8069

8170
var loadedImageURLs: [URL] {
82-
return imageRequests.map { $0.url }
71+
return imageLoader.requests.map { $0.param }
8372
}
8473

85-
private(set) var cancelledImageURLs = [URL]()
74+
var cancelledImageURLs: [URL] {
75+
return imageLoader.requests.filter({ $0.result == .cancelled }).map { $0.param }
76+
}
8677

8778
private struct NoResponse: Error {}
8879
private struct Timeout: Error {}
8980

9081
func loadImageData(from url: URL) async throws -> Data {
91-
let (stream, continuation) = AsyncThrowingStream<Data, Error>.makeStream()
92-
let index = imageRequests.count
93-
imageRequests.append((url, stream, continuation, nil))
94-
95-
do {
96-
for try await result in stream {
97-
try Task.checkCancellation()
98-
imageRequests[index].result = .success
99-
return result
100-
}
101-
102-
try Task.checkCancellation()
103-
104-
throw NoResponse()
105-
} catch {
106-
if Task.isCancelled {
107-
cancelledImageURLs.append(url)
108-
imageRequests[index].result = .cancelled
109-
} else {
110-
imageRequests[index].result = .failure
111-
}
112-
throw error
113-
}
82+
try await imageLoader.load(url)
11483
}
11584

11685
func completeImageLoading(with imageData: Data = Data(), at index: Int = 0) {
117-
imageRequests[index].continuation.yield(imageData)
118-
imageRequests[index].continuation.finish()
119-
120-
while imageRequests[index].result == nil { RunLoop.current.run(until: Date()) }
86+
imageLoader.complete(with: imageData, at: index)
12187
}
12288

12389
func completeImageLoadingWithError(at index: Int = 0) {
124-
imageRequests[index].continuation.finish(throwing: anyNSError())
125-
126-
while imageRequests[index].result == nil { RunLoop.current.run(until: Date()) }
90+
imageLoader.fail(with: anyNSError(), at: index)
12791
}
12892

12993
func imageResult(at index: Int, timeout: TimeInterval = 1) async throws -> AsyncResult {
130-
let maxDate = Date() + timeout
131-
132-
while Date() <= maxDate {
133-
if let result = imageRequests[index].result {
134-
return result
135-
}
136-
137-
await Task.yield()
138-
}
139-
140-
throw Timeout()
94+
try await imageLoader.result(at: index, timeout: timeout)
14195
}
14296

14397
func cancelPendingRequests() async throws {
144-
for (index, request) in imageRequests.enumerated() where request.result == nil {
145-
request.continuation.finish(throwing: CancellationError())
146-
147-
while imageRequests[index].result == nil { await Task.yield() }
148-
}
98+
try await imageLoader.cancelPendingRequests()
14999
}
150100
}
151101

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
//
2+
// Copyright © Essential Developer. All rights reserved.
3+
//
4+
5+
import Foundation
6+
7+
enum AsyncResult {
8+
case success
9+
case failure
10+
case cancelled
11+
}
12+
13+
@MainActor
14+
class LoaderSpy<Param, Resource: Sendable> {
15+
private(set) var requests = [(
16+
param: Param,
17+
stream: AsyncThrowingStream<Resource, Error>,
18+
continuation: AsyncThrowingStream<Resource, Error>.Continuation,
19+
result: AsyncResult?
20+
)]()
21+
22+
private struct NoResponse: Error {}
23+
private struct Timeout: Error {}
24+
25+
func load(_ param: Param) async throws -> Resource {
26+
let (stream, continuation) = AsyncThrowingStream<Resource, Error>.makeStream()
27+
let index = requests.count
28+
requests.append((param, stream, continuation, nil))
29+
30+
do {
31+
for try await result in stream {
32+
try Task.checkCancellation()
33+
requests[index].result = .success
34+
return result
35+
}
36+
37+
try Task.checkCancellation()
38+
39+
throw NoResponse()
40+
} catch {
41+
requests[index].result = Task.isCancelled ? .cancelled : .failure
42+
throw error
43+
}
44+
}
45+
46+
func complete(with resource: Resource, at index: Int) {
47+
requests[index].continuation.yield(resource)
48+
requests[index].continuation.finish()
49+
50+
while requests[index].result == nil { RunLoop.current.run(until: Date()) }
51+
}
52+
53+
func fail(with error: Error, at index: Int) {
54+
requests[index].continuation.finish(throwing: error)
55+
56+
while requests[index].result == nil { RunLoop.current.run(until: Date()) }
57+
}
58+
59+
func result(at index: Int, timeout: TimeInterval = 1) async throws -> AsyncResult {
60+
let maxDate = Date() + timeout
61+
62+
while Date() <= maxDate {
63+
if let result = requests[index].result {
64+
return result
65+
}
66+
67+
await Task.yield()
68+
}
69+
70+
throw Timeout()
71+
}
72+
73+
func cancelPendingRequests() async throws {
74+
for (index, request) in requests.enumerated() where request.result == nil {
75+
request.continuation.finish(throwing: CancellationError())
76+
77+
while requests[index].result == nil { await Task.yield() }
78+
}
79+
}
80+
}

0 commit comments

Comments
 (0)