Testing AsyncStreams created within a reducer's long-living effect #1752
-
Let's say that we have an action that returns a long-living effect that listens to notification updates (like device orientation updates): switch action {
case .registerForOrientationChanges:
return .run { send in
for await orientation in deviceOrientationStream() {
await send(.orientationUpdate(TaskResult { orientation }))
}
}
case let .orientationUpdate(.success(orientation)):
state.orientation = orientation
}
func deviceOrientationStream() -> AsyncStream<UIInterfaceOrientation> {
/* Code to generate an AsyncStream<UIInterfaceOrientation> instance */
} The test I would like to write would look something like this: let store = TestStore(
initialState: DeviceState.State(),
reducer: DeviceState()
)
let task = await store.send(.registerForOrientationChanges)
await store.receive(.orientationUpdate(TaskResult { .landscapeLeft })) {
$0.orientation = .landscapeLeft
}
// ??? (something like task.sendStream(.portrait)) ???
await store.receive(.orientationUpdate(TaskResult { .portrait })) {
$0.orientation = .portrait
} However, I'm at a loss for how to send new values to the AsyncStream instance since I don't have access to it. I do have access to Is it currently possible or do I have to find other ways to mimic this kind of behavior? |
Beta Was this translation helpful? Give feedback.
Replies: 3 comments 2 replies
-
Hey @acosmicflamingo! You should probably model this stream as a |
Beta Was this translation helpful? Give feedback.
-
Wow @tgrapperon you seem to really have all the answers! :D This works perfectly now for me; thank you! I totally forgot that their case study examples also include respective tests, which really helped me understand what to do. For anyone who wants to see the code I ended up using, here you go. First, the tests: @MainActor
final class DeviceStateFeatureTests: XCTestCase {
func testRegisteringForOrientationChanges() async {
let (interfaceOrientation, interfaceOrientationUpdate) = AsyncStream<UIInterfaceOrientation>.streamWithContinuation()
let store = TestStore(
initialState: DeviceState.State(),
reducer: DeviceState()
)
store.dependencies.deviceClient.interfaceOrientationStream = { interfaceOrientation }
let task = await store.send(.delegate(.registerForOrientationChanges))
interfaceOrientationUpdate.yield(.landscapeLeft)
await store.receive(._internal(.orientationUpdate(TaskResult { .landscapeLeft }))) {
$0.orientation = .landscapeLeft
}
await store.receive(.delegate(.didReceiveOrientationUpdate(.landscapeLeft)))
await task.cancel()
// Simulate interfaceOrientation updates to show no effects are executed
interfaceOrientationUpdate.yield(.landscapeLeft)
}
} In another discussion, I defined a function that @tgrapperon had generously provided withing the public struct DeviceState: ReducerProtocol {
...
@Dependency(\.deviceClient.interfaceOrientationStream) var interfaceOrientationStream
public var body: some ReducerProtocol<State, Action> {
Reduce { state, action in
switch action {
...
case .registerForOrientationChanges:
return .run { send in
for await orientation in interfaceOrientationStream() {
await send(.orientationUpdate(TaskResult { orientation }))
}
}
... This is the code for public struct DeviceClient {
public var interfaceOrientation: () -> UIInterfaceOrientation
public var interfaceOrientationStream: () -> AsyncStream<UIInterfaceOrientation>
}
extension DeviceClient: DependencyKey {
public static var liveValue: DeviceClient {
let interfaceOrientation: () -> UIInterfaceOrientation = {
guard
let scene = UIApplication.shared.connectedScenes.first,
let windowSceneDelegate = scene.delegate as? UIWindowSceneDelegate,
let window = windowSceneDelegate.window
else {
return .unknown
}
return window?.windowScene?.interfaceOrientation ?? .unknown
}
return Self(
interfaceOrientation: interfaceOrientation,
interfaceOrientationStream: {
AsyncStream(
UIInterfaceOrientation.self,
bufferingPolicy: .bufferingNewest(1)
) { [interfaceOrientation] continuation in
let task = Task { @MainActor in
continuation.yield(interfaceOrientation())
for await _ in NotificationCenter.default.notifications(named: UIDevice.orientationDidChangeNotification) {
continuation.yield(interfaceOrientation())
}
}
continuation.onTermination = { _ in
task.cancel()
}
}
}
)
}
}
extension DependencyValues {
public var deviceClient: DeviceClient {
get { self[DeviceClient.self] }
set { self[DeviceClient.self] = newValue }
}
}
extension DeviceClient: TestDependencyKey {
public static let previewValue = Self.noop
public static let testValue = Self.unimplemented
}
extension DeviceClient {
public static var noop: Self {
var interfaceOrientation: () -> UIInterfaceOrientation = { .unknown }
return Self(
interfaceOrientation: interfaceOrientation,
interfaceOrientationStream: { .init(unfolding: interfaceOrientation) }
)
}
public static let unimplemented = Self(
interfaceOrientation: XCTUnimplemented("\(Self.self).interfaceOrientation"),
interfaceOrientationStream: XCTUnimplemented("\(Self.self).interfaceOrientationStream")
)
} |
Beta Was this translation helpful? Give feedback.
-
This is more of a note to myself but I've learned a lot since I last asked this question, and my DeviceClient now looks like this: // swiftlint:disable attributes
import ApplicationDependency
import Combine
import Dependencies
@_spi(Internals) import DependenciesAdditionsBasics
import Foundation
import NotificationCenterDependency
import UIKit
import Utilities
public struct DeviceClient: Sendable, ConfigurableProxy {
@_spi(Internals) public var _implementation: Implementation
public struct Implementation: Sendable {
@ReadOnlyProxy public var idiom: UIUserInterfaceIdiom
@FunctionProxy public var interfaceOrientation:
@MainActor @Sendable (Application) -> UIInterfaceOrientation
@FunctionProxy public var interfaceOrientationPublisher:
@MainActor @Sendable (
Application,
NotificationCenter.Dependency
) -> AnyPublisher<UIInterfaceOrientation, Never>
// swiftformat:disable indent
init(
idiom: ReadOnlyProxy<UIUserInterfaceIdiom>,
interfaceOrientation: FunctionProxy<
@MainActor @Sendable (Application) -> UIInterfaceOrientation
>,
interfaceOrientationPublisher: FunctionProxy<
@MainActor @Sendable (
Application,
NotificationCenter.Dependency
) -> AnyPublisher<UIInterfaceOrientation, Never>
>
) {
self._idiom = idiom
self._interfaceOrientation = interfaceOrientation
self._interfaceOrientationPublisher = interfaceOrientationPublisher
}
// swiftformat:enable indent
}
}
extension DeviceClient {
public var idiom: UIUserInterfaceIdiom {
_implementation.idiom
}
@MainActor
public var interfaceOrientation: UIInterfaceOrientation {
@Dependency(\.application) var application
return _implementation.interfaceOrientation(application)
}
@MainActor
public var interfaceOrientationPublisher: AnyPublisher<
UIInterfaceOrientation, Never
> {
@Dependency(\.application) var application
@Dependency(\.notificationCenter) var notificationCenter
return _implementation.interfaceOrientationPublisher(
application, notificationCenter
)
}
}
// swiftlint:enable attributes LiveKey.swift import ApplicationDependency
import Combine
import Dependencies
import UIKit
extension DeviceClient: DependencyKey {
public static var liveValue: Self {
let interfaceOrientationGenerator = {
@MainActor @Sendable (application: Application) -> UIInterfaceOrientation in
guard
let scene = application.connectedScenes.first,
let windowSceneDelegate = scene.delegate as? UIWindowSceneDelegate,
let window = windowSceneDelegate.window
else {
return .unknown
}
return window?.windowScene?.interfaceOrientation ?? .unknown
}
let _implementation = Implementation(
idiom: .init { .unspecified },
interfaceOrientation: .init { interfaceOrientationGenerator },
interfaceOrientationPublisher: .init { application, notificationCenter in
let condition = interfaceOrientationGenerator
let notification = UIDevice.orientationDidChangeNotification
return Just(condition(application))
.merge(
with: notificationCenter.publisher(for: notification)
.map { _ in
let newOrientation = condition(application)
print(newOrientation.rawValue)
return newOrientation
}
).removeDuplicates()
.eraseToAnyPublisher()
}
)
return .init(_implementation: _implementation)
}
} TestKey.swift import Combine
import Dependencies
import Foundation
import XCTestDynamicOverlay
extension DependencyValues {
public var deviceClient: DeviceClient {
get { self[DeviceClient.self] }
set { self[DeviceClient.self] = newValue }
}
}
extension DeviceClient: TestDependencyKey {
public static let previewValue = Self.noop
public static let testValue = Self.unimplemented
}
extension DeviceClient {
public static var noop: Self {
let _implementation = Implementation(
idiom: .init { .unspecified },
interfaceOrientation: .init { _ in .unknown },
interfaceOrientationPublisher: .init { _, _ in
Just(.unknown).eraseToAnyPublisher()
}
)
return .init(_implementation: _implementation)
}
public static var unimplemented: Self {
let _implementation = Implementation(
idiom: .unimplemented(
#"@Dependency(\.deviceClient.idiom)"#
),
interfaceOrientation: .unimplemented(
#"@Dependency(\.deviceClient.interfaceOrientation)"#
),
interfaceOrientationPublisher: .unimplemented(
#"@Dependency(\.deviceClient.interfaceOrientationPublisher)"#,
placeholder: { _, _ in Just(.unknown).eraseToAnyPublisher() }
)
)
return .init(_implementation: _implementation)
}
} Then in the reducer I can do stuff like this: import ComposableArchitecture
import DeviceClient
import Foundation
import UIKit
import Utilities
public struct SplitScreen: Reducer {
public struct State: Equatable {
public var orientation: UIInterfaceOrientation
public var landscapeLayout: LandscapeLayout
public var centerSpacing: CenterSpacing
public var axisOffset: AxisOffset
public var viewVisibility: ViewVisibility
public init(
orientation: UIInterfaceOrientation = .unknown,
landscapeLayout: LandscapeLayout = .topLeftBottomRight,
centerSpacing: CenterSpacing = [.portrait: 8, .landscape: 8],
axisOffset: AxisOffset = [.portrait: 0.0, .landscape: 0.0],
viewVisibility: ViewVisibility = .both
) {
self.orientation = orientation
self.landscapeLayout = landscapeLayout
self.centerSpacing = centerSpacing
self.axisOffset = axisOffset
self.viewVisibility = viewVisibility
}
}
public enum Action: TCAAction {
public enum ViewAction: Equatable {
case onAppear
}
public enum DelegateAction: Equatable {
case requestToUpdateOrientation
}
public enum InternalAction: Equatable {
case orientationUpdateResult(TaskResult<UIInterfaceOrientation>)
}
case view(ViewAction)
case delegate(DelegateAction)
case _internal(InternalAction)
}
@Dependency(\.deviceClient.interfaceOrientation) var interfaceOrientation
@Dependency(\.deviceClient.interfaceOrientationPublisher) var interfaceOrientationPublisher
public enum InterfaceOrientationCancelID {}
public var body: some ReducerOf<Self> {
Reduce<State, Action> { state, action in
switch action {
case let .view(viewAction):
switch viewAction {
case .onAppear:
state.orientation = interfaceOrientation
return .run { send in
await withTaskCancellation(
id: InterfaceOrientationCancelID.self,
cancelInFlight: true
) { @MainActor in
for await orientation in interfaceOrientationPublisher.values {
await send(._internal(.orientationUpdateResult(
TaskResult { orientation }
)))
}
}
}
}
case let .delegate(delegateAction):
switch delegateAction {
case .requestToUpdateOrientation:
state.orientation = interfaceOrientation
return .none
}
case let ._internal(internalAction):
switch internalAction {
case let .orientationUpdateResult(.success(orientation)):
state.orientation = orientation
return .none
case .orientationUpdateResult(.failure):
return .none
}
}
}
}
public init() {}
}
// MARK: Helpers
extension SplitScreen.State {
public enum Orientation {
case portrait, landscape
}
public enum LandscapeLayout {
case topLeftBottomRight, topRightBottomLeft
}
public enum ViewVisibility {
case topOnly, bottomOnly, both
}
enum ViewPlacement: CaseIterable {
case topHalf, leftHalf, bottomHalf, rightHalf
}
public typealias CenterSpacing = [Orientation: Double]
public typealias AxisOffset = [Orientation: Double]
} |
Beta Was this translation helpful? Give feedback.
Hey @acosmicflamingo! You should probably model this stream as a
DependencyKey
that produces anAsyncStream<UIInterfaceOrientation>
. When testing, your can replace the notification-based sequence with oneAsyncStream
that you control. You can look at the long-livingEffect
case-study and its tests to see how it can be achieved. Being able to update at distance dependencies when testing is one of the key features of theDependencies
module.