Files
firebase-ios-sdk/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncSequenceTests.swift
T
Peter Friese d250c11a04 feat(RemoteConfig): add AsyncSequence for config updates (#15352)
Introduces a new `configUpdates` property to `RemoteConfig`, providing an `AsyncSequence` for consuming real-time configuration updates. 

This modern, Swift Concurrency-native API offers a cleaner alternative to the existing closure-based listener, allowing developers to handle config updates using `for await` loops.

Key changes:
- Added `RemoteConfig.configUpdates` returning `RemoteConfigUpdateSequence`.
- Implemented `RemoteConfigUpdateSequence` with a built-in iterator that manages the lifecycle of the underlying listener.
- Aligned implementation with the recently merged Firestore AsyncSequence patterns.
- Comprehensive unit tests verified for listener registration, event yielding, and graceful termination.
2026-03-31 18:21:21 +02:00

242 lines
8.0 KiB
Swift

// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import FirebaseCore
@testable import FirebaseRemoteConfig
import XCTest
#if SWIFT_PACKAGE
import RemoteConfigFakeConsoleObjC
#endif
// MARK: - Mock Objects for Testing
/// A mock listener registration that allows tests to verify that its `remove()` method was called.
class MockListenerRegistration: ConfigUpdateListenerRegistration, @unchecked Sendable {
var wasRemoveCalled = false
override func remove() {
wasRemoveCalled = true
}
}
/// A mock for the RCNConfigRealtime component that allows tests to control the config update
/// listener.
class MockRealtime: RCNConfigRealtime, @unchecked Sendable {
/// The listener closure captured from the `configUpdates` async stream.
var listener: ((RemoteConfigUpdate?, Error?) -> Void)?
let mockRegistration = MockListenerRegistration()
var listenerAttachedExpectation: XCTestExpectation?
override func addConfigUpdateListener(_ listener: @escaping (RemoteConfigUpdate?, Error?)
-> Void) -> ConfigUpdateListenerRegistration {
self.listener = listener
listenerAttachedExpectation?.fulfill()
return mockRegistration
}
/// Simulates the backend sending a successful configuration update.
func sendUpdate(keys: [String]) {
let update = RemoteConfigUpdate(updatedKeys: Set(keys))
listener?(update, nil)
}
/// Simulates the backend sending an error.
func sendError(_ error: Error) {
listener?(nil, error)
}
/// Simulates the listener completing without an update or error.
func sendCompletion() {
listener?(nil, nil)
}
}
// MARK: - AsyncSequenceTests
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
class AsyncSequenceTests: XCTestCase {
var app: FirebaseApp!
var config: RemoteConfig!
var mockRealtime: MockRealtime!
struct TestError: Error, Equatable {}
override func setUpWithError() throws {
try super.setUpWithError()
// Perform one-time setup of the FirebaseApp for testing.
if FirebaseApp.app() == nil {
let options = FirebaseOptions(googleAppID: "1:123:ios:123abc",
gcmSenderID: "correct_gcm_sender_id")
options.apiKey = "A23456789012345678901234567890123456789"
options.projectID = "Fake_Project"
FirebaseApp.configure(options: options)
}
app = FirebaseApp.app()!
config = RemoteConfig.remoteConfig(app: app)
// Install the mock realtime service.
mockRealtime = MockRealtime()
config.configRealtime = mockRealtime
}
override func tearDownWithError() throws {
app = nil
config = nil
mockRealtime = nil
try super.tearDownWithError()
}
func testSequenceYieldsUpdate_whenUpdateIsSent() async throws {
let expectation = self.expectation(description: "Sequence should yield an update.")
let keysToUpdate = ["foo", "bar"]
let listenerAttachedExpectation = self.expectation(description: "Listener should be attached.")
mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation
let listeningTask = Task {
for try await update in config.configUpdates {
XCTAssertEqual(update.updatedKeys, Set(keysToUpdate))
expectation.fulfill()
break // End the loop after receiving the expected update.
}
}
// Wait for the listener to be attached before sending the update.
await fulfillment(of: [listenerAttachedExpectation], timeout: 1.0)
mockRealtime.sendUpdate(keys: keysToUpdate)
await fulfillment(of: [expectation], timeout: 1.0)
listeningTask.cancel()
}
func testSequenceFinishes_whenErrorIsSent() async throws {
let expectation = self.expectation(description: "Sequence should throw an error.")
let testError = TestError()
let listenerAttachedExpectation = self.expectation(description: "Listener should be attached.")
mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation
let listeningTask = Task {
do {
for try await _ in config.configUpdates {
XCTFail("Stream should not have yielded any updates.")
}
} catch {
XCTAssertEqual(error as? TestError, testError)
expectation.fulfill()
}
}
// Wait for the listener to be attached before sending the error.
await fulfillment(of: [listenerAttachedExpectation], timeout: 1.0)
mockRealtime.sendError(testError)
await fulfillment(of: [expectation], timeout: 1.0)
listeningTask.cancel()
}
func testSequenceCancellation_callsRemoveOnListener() async throws {
let listenerAttachedExpectation = expectation(description: "Listener should be attached.")
mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation
let listeningTask = Task {
for try await _ in config.configUpdates {
// We will cancel the task, so it should not reach here.
}
}
// Wait for the listener to be attached.
await fulfillment(of: [listenerAttachedExpectation], timeout: 1.0)
// Verify the listener has not been removed yet.
XCTAssertFalse(mockRealtime.mockRegistration.wasRemoveCalled)
// Cancel the task, which should trigger the stream's onTermination handler.
listeningTask.cancel()
// Give the cancellation a moment to propagate.
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
// Verify the listener was removed.
XCTAssertTrue(mockRealtime.mockRegistration.wasRemoveCalled)
}
func testSequenceFinishesGracefully_whenListenerSendsNil() async throws {
let expectation = self.expectation(description: "Sequence should finish without error.")
let listenerAttachedExpectation = self.expectation(description: "Listener should be attached.")
mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation
let listeningTask = Task {
var updateCount = 0
do {
for try await _ in config.configUpdates {
updateCount += 1
}
// The loop finished without throwing, which is the success condition.
XCTAssertEqual(updateCount, 0, "No updates should have been received.")
expectation.fulfill()
} catch {
XCTFail("Stream should not have thrown an error, but threw \(error).")
}
}
await fulfillment(of: [listenerAttachedExpectation], timeout: 1.0)
mockRealtime.sendCompletion()
await fulfillment(of: [expectation], timeout: 1.0)
listeningTask.cancel()
}
func testSequenceYieldsMultipleUpdates_whenMultipleUpdatesAreSent() async throws {
let expectation = self.expectation(description: "Sequence should receive two updates.")
expectation.expectedFulfillmentCount = 2
let updatesToSend = [
Set(["key1", "key2"]),
Set(["key3"]),
]
var receivedUpdates: [Set<String>] = []
let listenerAttachedExpectation = self.expectation(description: "Listener should be attached.")
mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation
let listeningTask = Task {
for try await update in config.configUpdates {
receivedUpdates.append(update.updatedKeys)
expectation.fulfill()
if receivedUpdates.count == updatesToSend.count {
break
}
}
return receivedUpdates
}
await fulfillment(of: [listenerAttachedExpectation], timeout: 1.0)
mockRealtime.sendUpdate(keys: Array(updatesToSend[0]))
mockRealtime.sendUpdate(keys: Array(updatesToSend[1]))
await fulfillment(of: [expectation], timeout: 2.0)
let finalUpdates = try await listeningTask.value
XCTAssertEqual(finalUpdates, updatesToSend)
listeningTask.cancel()
}
}