Skip to content

Commit 68e3229

Browse files
committed
Tests WIP
More tests WIP Tests WIP. This one seems to work. Migrate to Swift Testing Fix handing tests Fix tests
1 parent 5cb5b43 commit 68e3229

File tree

2 files changed

+208
-127
lines changed

2 files changed

+208
-127
lines changed
Lines changed: 196 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -1,178 +1,247 @@
1-
/*
2-
* Copyright 2025 Google LLC
3-
*
4-
* Licensed under the Apache License, Version 2.0 (the "License");
5-
* you may not use this file except in compliance with the License.
6-
* You may obtain a copy of the License at
7-
*
8-
* http://www.apache.org/licenses/LICENSE-2.0
9-
*
10-
* Unless required by applicable law or agreed to in writing, software
11-
* distributed under the License is distributed on an "AS IS" BASIS,
12-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13-
* See the License for the specific language governing permissions and
14-
* limitations under the License.
15-
*/
16-
1+
// SPDX-License-Identifier: Apache-2.0
2+
//
3+
// Copyright 2024 Google LLC
4+
//
5+
// Licensed under the Apache License, Version 2.0 (the "License");
6+
// you may not use this file except in compliance with the License.
7+
// You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing, software
12+
// distributed under the License is distributed on an "AS IS" BASIS,
13+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
// See the License for the specific language governing permissions and
15+
// limitations under the License.
16+
17+
import Testing
18+
import FirebaseCore
1719
@testable import FirebaseFirestore
18-
import XCTest
19-
20-
// MARK: - Mock Objects for Testing
21-
22-
private class MockListenerRegistration: ListenerRegistration {
23-
var isRemoved = false
24-
func remove() {
25-
isRemoved = true
26-
}
27-
}
2820

29-
private typealias SnapshotListener = (QuerySnapshot?, Error?) -> Void
30-
private typealias DocumentSnapshotListener = (DocumentSnapshot?, Error?) -> Void
21+
private final class AssociationKey: Sendable {}
3122

32-
private class MockQuery: Query {
33-
var capturedListener: SnapshotListener?
34-
let mockListenerRegistration = MockListenerRegistration()
23+
/// A wrapper to safely pass a non-Sendable closure to a Sendable context.
24+
///
25+
/// This is safe in this specific test because the listener closure originates from
26+
/// an `AsyncStream` continuation, which is guaranteed to be thread-safe.
27+
private final class SendableListenerWrapper: @unchecked Sendable {
28+
let listener: (QuerySnapshot?, Error?) -> Void
3529

36-
override func addSnapshotListener(_ listener: @escaping SnapshotListener)
37-
-> ListenerRegistration {
38-
capturedListener = listener
39-
return mockListenerRegistration
40-
}
41-
42-
override func addSnapshotListener(includeMetadataChanges: Bool,
43-
listener: @escaping SnapshotListener) -> ListenerRegistration {
44-
capturedListener = listener
45-
return mockListenerRegistration
30+
init(_ listener: @escaping (QuerySnapshot?, Error?) -> Void) {
31+
self.listener = listener
4632
}
4733
}
4834

49-
private class MockDocumentReference: DocumentReference {
50-
var capturedListener: DocumentSnapshotListener?
51-
let mockListenerRegistration = MockListenerRegistration()
35+
@Suite("Query AsyncSequence Tests")
36+
struct AsyncSequenceTests {
37+
fileprivate static let associationKey = AssociationKey()
38+
39+
// This static property handles the one-time setup for FirebaseApp.
40+
@MainActor
41+
private static let firebaseApp: FirebaseApp = {
42+
let options = FirebaseOptions(googleAppID: "1:1234567890:ios:abcdef",
43+
gcmSenderID: "1234s567890")
44+
options.projectID = "Firestore-Testing-Project"
45+
FirebaseApp.configure(options: options)
46+
return FirebaseApp.app()!
47+
}()
48+
49+
// Swizzling is managed by this helper struct using a simple RAII pattern.
50+
// The swizzling is active for the lifetime of the struct instance.
51+
private struct Swizzler: ~Copyable {
52+
init() {
53+
Self.swizzle(
54+
Query.self,
55+
originalSelector: #selector(Query.addSnapshotListener(includeMetadataChanges:listener:)),
56+
swizzledSelector: #selector(Query.swizzled_addSnapshotListener(includeMetadataChanges:listener:))
57+
)
58+
}
5259

53-
override func addSnapshotListener(_ listener: @escaping DocumentSnapshotListener)
54-
-> ListenerRegistration {
55-
capturedListener = listener
56-
return mockListenerRegistration
57-
}
60+
deinit {
61+
Self.swizzle(
62+
Query.self,
63+
originalSelector: #selector(Query.swizzled_addSnapshotListener(includeMetadataChanges:listener:)),
64+
swizzledSelector: #selector(Query.addSnapshotListener(includeMetadataChanges:listener:))
65+
)
66+
}
5867

59-
override func addSnapshotListener(includeMetadataChanges: Bool,
60-
listener: @escaping DocumentSnapshotListener)
61-
-> ListenerRegistration {
62-
capturedListener = listener
63-
return mockListenerRegistration
68+
private static func swizzle(_ cls: AnyClass, originalSelector: Selector, swizzledSelector: Selector) {
69+
guard let originalMethod = class_getInstanceMethod(cls, originalSelector),
70+
let swizzledMethod = class_getInstanceMethod(cls, swizzledSelector) else {
71+
#expect(false, "Failed to get methods for swizzling")
72+
return
73+
}
74+
method_exchangeImplementations(originalMethod, swizzledMethod)
75+
}
6476
}
65-
}
66-
67-
// MARK: - AsyncSequenceTests
6877

69-
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
70-
class AsyncSequenceTests: XCTestCase {
71-
func testQuerySnapshotsYieldsValues() async throws {
72-
let mockQuery = MockQuery()
73-
let expectation = XCTestExpectation(description: "Received snapshot")
78+
@available(iOS 18.0, *)
79+
@MainActor
80+
@Test("Stream handles cancellation correctly")
81+
func test_snapshotStream_handlesCancellationCorrectly() async throws {
82+
// Ensure Firebase is configured before swizzling, as interacting with the
83+
// Query class can trigger SDK initialization that requires a configured app.
84+
let app = Self.firebaseApp
85+
let swizzler = Swizzler()
86+
defer { withExtendedLifetime(swizzler) {} }
87+
88+
let actor = TestStateActor()
89+
let query = Firestore.firestore(app: app)
90+
.collection("test-\(UUID().uuidString)")
91+
let key = Unmanaged.passUnretained(Self.associationKey).toOpaque()
92+
objc_setAssociatedObject(query, key, actor, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
7493

7594
let task = Task {
76-
for try await _ in mockQuery.snapshots {
77-
expectation.fulfill()
78-
break // Exit after first result
95+
for try await _ in query.snapshots {
96+
// Do nothing
7997
}
8098
}
8199

82-
// Ensure the listener has been set up
83-
XCTAssertNotNil(mockQuery.capturedListener)
84-
85-
// Simulate a snapshot event
86-
mockQuery.capturedListener?(QuerySnapshot(), nil)
87-
88-
await fulfillment(of: [expectation], timeout: 1.0)
100+
await actor.waitForListenerSetup()
89101
task.cancel()
102+
await actor.waitForListenerRemoval()
90103
}
91104

92-
func testQuerySnapshotsThrowsErrors() async throws {
93-
let mockQuery = MockQuery()
94-
let expectedError = NSError(domain: "TestError", code: 123, userInfo: nil)
95-
var receivedError: Error?
105+
@available(iOS 18.0, *)
106+
@MainActor
107+
@Test("Stream propagates errors")
108+
func test_snapshotStream_propagatesErrors() async throws {
109+
// Ensure Firebase is configured before swizzling, as interacting with the
110+
// Query class can trigger SDK initialization that requires a configured app.
111+
let app = Self.firebaseApp
112+
let swizzler = Swizzler()
113+
defer { withExtendedLifetime(swizzler) {} }
114+
115+
let actor = TestStateActor()
116+
let query = Firestore.firestore(app: app)
117+
.collection("test-\(UUID().uuidString)")
118+
let key = Unmanaged.passUnretained(Self.associationKey).toOpaque()
119+
objc_setAssociatedObject(query, key, actor, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
96120

97121
let task = Task {
98122
do {
99-
for try await _ in mockQuery.snapshots {
100-
XCTFail("Should not have received a value.")
101-
}
123+
for try await _ in query.snapshots {}
124+
throw "Stream did not throw"
102125
} catch {
103-
receivedError = error
126+
return error
104127
}
105128
}
106129

107-
// Ensure the listener has been set up
108-
XCTAssertNotNil(mockQuery.capturedListener)
109-
110-
// Simulate an error event
111-
mockQuery.capturedListener?(nil, expectedError)
130+
await actor.waitForListenerSetup()
131+
await actor.invokeListener(withSnapshot: nil, error: TestError.mockError)
112132

113-
// Allow the task to process the error
114-
try await Task.sleep(nanoseconds: 100_000_000)
115-
116-
XCTAssertNotNil(receivedError)
117-
XCTAssertEqual((receivedError as NSError?)?.domain, expectedError.domain)
118-
XCTAssertEqual((receivedError as NSError?)?.code, expectedError.code)
133+
let caughtError = await task.value
134+
#expect(caughtError as? TestError == .mockError)
119135
task.cancel()
120136
}
121137

122-
func testQuerySnapshotsCancellationRemovesListener() async throws {
123-
let mockQuery = MockQuery()
138+
@available(iOS 18.0, *)
139+
@MainActor
140+
@Test("Stream handles (nil, nil) events gracefully")
141+
func test_snapshotStream_handlesNilSnapshotAndNilErrorGracefully() async throws {
142+
// Ensure Firebase is configured before swizzling, as interacting with the
143+
// Query class can trigger SDK initialization that requires a configured app.
144+
let app = Self.firebaseApp
145+
let swizzler = Swizzler()
146+
defer { withExtendedLifetime(swizzler) {} }
147+
148+
let actor = TestStateActor()
149+
let query = Firestore.firestore(app: app)
150+
.collection("test-\(UUID().uuidString)")
151+
let key = Unmanaged.passUnretained(Self.associationKey).toOpaque()
152+
objc_setAssociatedObject(query, key, actor, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
124153

125154
let task = Task {
126-
for try await _ in mockQuery.snapshots {
127-
XCTFail("Should not receive any values as the task is cancelled immediately.")
155+
for try await _ in query.snapshots {
156+
#expect(false, "The stream should not have produced any values.")
128157
}
129158
}
130159

131-
// Ensure the listener was attached before we cancel
132-
XCTAssertNotNil(mockQuery.capturedListener)
133-
XCTAssertFalse(mockQuery.mockListenerRegistration.isRemoved)
134-
160+
await actor.waitForListenerSetup()
161+
await actor.invokeListener(withSnapshot: nil, error: nil)
135162
task.cancel()
163+
await actor.waitForListenerRemoval()
136164

137-
// Allow time for the cancellation handler to execute
138-
try await Task.sleep(nanoseconds: 100_000_000)
139-
140-
XCTAssertTrue(mockQuery.mockListenerRegistration.isRemoved)
165+
// Awaiting the task will rethrow a CancellationError, which is expected
166+
// and handled by the `throws` on the test function.
167+
try await task.value
141168
}
169+
}
170+
171+
private enum TestError: Error, Equatable {
172+
case mockError
173+
}
142174

143-
func testDocumentReferenceSnapshotsYieldsValues() async throws {
144-
let mockDocRef = MockDocumentReference()
145-
let expectation = XCTestExpectation(description: "Received document snapshot")
175+
// We can finally use a real actor, which is much safer and cleaner.
176+
private actor TestStateActor {
177+
private var capturedListenerWrapper: SendableListenerWrapper?
178+
private var listenerSetupContinuation: CheckedContinuation<Void, Never>?
179+
private var listenerRemovedContinuation: CheckedContinuation<Void, Never>?
146180

147-
let task = Task {
148-
for try await _ in mockDocRef.snapshots {
149-
expectation.fulfill()
150-
break
151-
}
181+
private var hasSetUpListener = false
182+
private var hasRemovedListener = false
183+
184+
func waitForListenerSetup() async {
185+
if hasSetUpListener { return }
186+
await withCheckedContinuation { continuation in
187+
self.listenerSetupContinuation = continuation
152188
}
189+
}
153190

154-
XCTAssertNotNil(mockDocRef.capturedListener)
155-
mockDocRef.capturedListener?(DocumentSnapshot(), nil)
191+
func waitForListenerRemoval() async {
192+
if hasRemovedListener { return }
193+
await withCheckedContinuation { continuation in
194+
self.listenerRemovedContinuation = continuation
195+
}
196+
}
156197

157-
await fulfillment(of: [expectation], timeout: 1.0)
158-
task.cancel()
198+
func listenerDidSetUp(wrapper: SendableListenerWrapper) {
199+
capturedListenerWrapper = wrapper
200+
hasSetUpListener = true
201+
listenerSetupContinuation?.resume()
202+
listenerSetupContinuation = nil
159203
}
160204

161-
func testDocumentReferenceSnapshotsCancellationRemovesListener() async throws {
162-
let mockDocRef = MockDocumentReference()
205+
func listenerDidRemove() {
206+
hasRemovedListener = true
207+
listenerRemovedContinuation?.resume()
208+
listenerRemovedContinuation = nil
209+
}
163210

164-
let task = Task {
165-
for try await _ in mockDocRef.snapshots {
166-
XCTFail("Should not receive values.")
167-
}
168-
}
211+
func invokeListener(withSnapshot snapshot: QuerySnapshot?, error: Error?) {
212+
capturedListenerWrapper?.listener(snapshot, error)
213+
}
214+
}
169215

170-
XCTAssertNotNil(mockDocRef.capturedListener)
171-
XCTAssertFalse(mockDocRef.mockListenerRegistration.isRemoved)
216+
private final class MockListenerRegistration: NSObject, ListenerRegistration {
217+
private var actor: TestStateActor
172218

173-
task.cancel()
174-
try await Task.sleep(nanoseconds: 100_000_000)
219+
init(actor: TestStateActor) {
220+
self.actor = actor
221+
}
175222

176-
XCTAssertTrue(mockDocRef.mockListenerRegistration.isRemoved)
223+
func remove() {
224+
let actor = self.actor
225+
Task {
226+
await actor.listenerDidRemove()
227+
}
177228
}
178229
}
230+
231+
extension Query {
232+
@objc func swizzled_addSnapshotListener(
233+
includeMetadataChanges: Bool,
234+
listener: @escaping (QuerySnapshot?, Error?) -> Void
235+
) -> ListenerRegistration {
236+
let key = Unmanaged.passUnretained(AsyncSequenceTests.associationKey).toOpaque()
237+
let actor = objc_getAssociatedObject(self, key) as! TestStateActor
238+
let registration = MockListenerRegistration(actor: actor)
239+
let wrapper = SendableListenerWrapper(listener)
240+
Task {
241+
await actor.listenerDidSetUp(wrapper: wrapper)
242+
}
243+
return registration
244+
}
245+
}
246+
247+
extension String: Error {}

Package.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1552,6 +1552,18 @@ func firestoreTargets() -> [Target] {
15521552
.swiftLanguageMode(SwiftLanguageMode.v5),
15531553
]
15541554
),
1555+
.testTarget(
1556+
name: "FirebaseFirestoreTests",
1557+
dependencies: [
1558+
"Firebase",
1559+
"FirebaseCore",
1560+
"FirebaseFirestoreTarget"
1561+
],
1562+
path: "Firestore/Swift/Tests/Unit",
1563+
cSettings: [
1564+
.headerSearchPath("../../../"),
1565+
]
1566+
),
15551567
]
15561568
}
15571569

0 commit comments

Comments
 (0)