@_spi(Internals) import CasePaths import Combine import ConcurrencyExtras import CustomDump @_spi(Beta) import Dependencies import Foundation import IssueReporting @_spi(SharedChangeTracking) import Sharing /// A testable runtime for a reducer. /// /// This object aids in writing expressive and exhaustive tests for features built in the /// Composable Architecture. It allows you to send a sequence of actions to the store, and each step /// of the way you must assert exactly how state changed, and how effect emissions were fed back /// into the system. /// /// See the dedicated article for detailed information on testing. /// /// ## Exhaustive testing /// /// By default, ``TestStore`` requires you to exhaustively prove how your feature evolves from /// sending use actions and receiving actions from effects. There are multiple ways the test store /// forces you to do this: /// /// * After each action is sent you must describe precisely how the state changed from before the /// action was sent to after it was sent. /// /// If even the smallest piece of data differs the test will fail. This guarantees that you are /// proving you know precisely how the state of the system changes. /// /// * Sending an action can sometimes cause an effect to be executed, and if that effect sends an /// action back into the system, you **must** explicitly assert that you expect to receive that /// action from the effect, _and_ you must assert how state changed as a result. /// /// If you try to send another action before you have handled all effect actions, the test will /// fail. This guarantees that you do not accidentally forget about an effect action, and that /// the sequence of steps you are describing will mimic how the application behaves in reality. /// /// * All effects must complete by the time the test case has finished running, and all effect /// actions must be asserted on. /// /// If at the end of the assertion there is still an in-flight effect running or an unreceived /// action, the assertion will fail. This helps exhaustively prove that you know what effects /// are in flight and forces you to prove that effects will not cause any future changes to your /// state. /// /// For example, given a simple counter reducer: /// /// ```swift /// @Reducer /// struct Counter { /// struct State: Equatable { /// var count = 0 /// } /// /// enum Action { /// case decrementButtonTapped /// case incrementButtonTapped /// } /// /// var body: some Reducer { /// Reduce { state, action in /// switch action { /// case .decrementButtonTapped: /// state.count -= 1 /// return .none /// /// case .incrementButtonTapped: /// state.count += 1 /// return .none /// } /// } /// } /// } /// ``` /// /// One can assert against its behavior over time: /// /// ```swift /// @MainActor /// struct CounterTests { /// @Test /// func basics() async { /// let store = TestStore( /// // Given: a counter state of 0 /// initialState: Counter.State(count: 0), /// ) { /// Counter() /// } /// /// // When: the increment button is tapped /// await store.send(.incrementButtonTapped) { /// // Then: the count should be 1 /// $0.count = 1 /// } /// } /// } /// ``` /// /// Note that in the trailing closure of `.send(.incrementButtonTapped)` we are given a single /// mutable value of the state before the action was sent, and it is our job to mutate the value to /// match the state after the action was sent. In this case the `count` field changes to `1`. /// /// If the change made in the closure does not reflect reality, you will get a test failure with a /// nicely formatted failure message letting you know exactly what went wrong: /// /// ```swift /// await store.send(.incrementButtonTapped) { /// $0.count = 42 /// } /// ``` /// /// > ❌ Failure: A state change does not match expectation: … /// > /// > ```diff /// > TestStoreFailureTests.State( /// > - count: 42 /// > + count: 1 /// > ) /// > ``` /// > /// > (Expected: −, Actual: +) /// /// For a more complex example, consider the following bare-bones search feature that uses a clock /// and cancel token to debounce requests: /// /// ```swift /// @Reducer /// struct Search { /// struct State: Equatable { /// var query = "" /// var results: [String] = [] /// } /// /// enum Action { /// case queryChanged(String) /// case searchResponse(Result<[String], any Error>) /// } /// /// @Dependency(\.apiClient) var apiClient /// @Dependency(\.continuousClock) var clock /// private enum CancelID { case search } /// /// var body: some Reducer { /// Reduce { state, action in /// switch action { /// case let .queryChanged(query): /// state.query = query /// return .run { send in /// try await self.clock.sleep(for: 0.5) /// /// await send(.searchResponse(Result { try await self.apiClient.search(query) })) /// } /// .cancellable(id: CancelID.search, cancelInFlight: true) /// /// case let .searchResponse(.success(results)): /// state.results = results /// return .none /// /// case .searchResponse(.failure): /// // Do error handling here. /// return .none /// } /// } /// } /// } /// ``` /// /// It can be fully tested by overriding the `apiClient` and `continuousClock` dependencies with /// values that are fully controlled and deterministic: /// /// ```swift /// // Create a test clock to control the timing of effects /// let clock = TestClock() /// /// let store = TestStore(initialState: Search.State()) { /// Search() /// } withDependencies: { /// // Override the clock dependency with the test clock /// $0.continuousClock = clock /// /// // Simulate a search response with one item /// $0.apiClient.search = { _ in /// ["Composable Architecture"] /// } /// ) /// /// // Change the query /// await store.send(.searchFieldChanged("c") { /// // Assert that state updates accordingly /// $0.query = "c" /// } /// /// // Advance the clock by enough to get past the debounce /// await clock.advance(by: 0.5) /// /// // Assert that the expected response is received /// await store.receive(\.searchResponse.success) { /// $0.results = ["Composable Architecture"] /// } /// ``` /// /// This test is proving that when the search query changes some search responses are delivered and /// state updates accordingly. /// /// If we did not assert that the `searchResponse` action was received, we would get the following /// test failure: /// /// > ❌ Failure: The store received 1 unexpected action after this one: … /// > /// > ``` /// > Unhandled actions: [ /// > [0]: Search.Action.searchResponse /// > ] /// > ``` /// /// This helpfully lets us know that we have no asserted on everything that happened in the feature, /// which could be hiding a bug from us. /// /// Or if we had sent another action before handling the effect's action we would have also gotten /// a test failure: /// /// > ❌ Failure: Must handle 1 received action before sending an action: … /// > /// > ``` /// > Unhandled actions: [ /// > [0]: Search.Action.searchResponse /// > ] /// > ``` /// /// All of these types of failures help you prove that you know exactly how your feature evolves as /// actions are sent into the system. If the library did not produce a test failure in these /// situations it could be hiding subtle bugs in your code. For example, when the user clears the /// search query you probably expect that the results are cleared and no search request is executed /// since there is no query. This can be done like so: /// /// ```swift /// await store.send(.queryChanged("")) { /// $0.query = "" /// $0.results = [] /// } /// /// // No need to perform `store.receive` since we do not expect a search /// // effect to execute. /// ``` /// /// But, if in the future a bug is introduced causing a search request to be executed even when the /// query is empty, you will get a test failure because a new effect is being created that is not /// being asserted on. This is the power of exhaustive testing. /// /// ## Non-exhaustive testing /// /// While exhaustive testing can be powerful, it can also be a nuisance, especially when testing how /// many features integrate together. This is why sometimes you may want to selectively test in a /// non-exhaustive style. /// /// > Tip: The concept of "non-exhaustive test store" was first introduced by /// [Krzysztof Zabłocki][merowing.info] in a [blog post][exhaustive-testing-in-tca] and /// [conference talk][Composable-Architecture-at-Scale], and then later became integrated into the /// core library. /// /// Test stores are exhaustive by default, which means you must assert on every state change, and /// how ever effect feeds data back into the system, and you must make sure that all effects /// complete before the test is finished. To turn off exhaustivity you can set ``exhaustivity`` /// to ``Exhaustivity/off``. When that is done the ``TestStore``'s behavior changes: /// /// * The trailing closures of ``send(_:assert:fileID:file:line:column:)-8f2pl`` and /// ``receive(_:timeout:assert:fileID:file:line:column:)-8zqxk`` no longer need to assert on all /// state changes. They can assert on any subset of changes, and only if they make an incorrect /// mutation will a test failure be reported. /// * The ``send(_:assert:fileID:file:line:column:)-8f2pl`` and /// ``receive(_:timeout:assert:fileID:file:line:column:)-8zqxk`` methods are allowed to be /// called even when actions have been received from effects that have not been asserted on yet. /// Any pending actions will be cleared. /// * Tests are allowed to finish with unasserted, received actions and in-flight effects. No test /// failures will be reported. /// /// Non-exhaustive stores can be configured to report skipped assertions by configuring /// ``Exhaustivity/off(showSkippedAssertions:)``. When set to `true` the test store will have the /// added behavior that any unasserted change causes a grey, informational box to appear next to /// each assertion detailing the changes that were not asserted against. This allows you to see what /// information you are choosing to ignore without causing a test failure. It can be useful in /// tracking down bugs that happen in production but that aren't currently detected in tests. /// /// This style of testing is most useful for testing the integration of multiple features where you /// want to focus on just a certain slice of the behavior. Exhaustive testing can still be important /// to use for leaf node features, where you truly do want to assert on everything happening inside /// the feature. /// /// For example, suppose you have a tab-based application where the 3rd tab is a login screen. The /// user can fill in some data on the screen, then tap the "Submit" button, and then a series of /// events happens to log the user in. Once the user is logged in, the 3rd tab switches from a /// login screen to a profile screen, _and_ the selected tab switches to the first tab, which is an /// activity screen. /// /// When writing tests for the login feature we will want to do that in the exhaustive style so that /// we can prove exactly how the feature would behave in production. But, suppose we wanted to write /// an integration test that proves after the user taps the "Login" button that ultimately the /// selected tab switches to the first tab. /// /// In order to test such a complex flow we must test the integration of multiple features, which /// means dealing with complex, nested state and effects. We can emulate this flow in a test by /// sending actions that mimic the user logging in, and then eventually assert that the selected /// tab switched to activity: /// /// ```swift /// let store = TestStore(initialState: App.State()) { /// App() /// } /// /// // 1️⃣ Emulate user tapping on submit button. /// // (You can use case key path syntax to send actions to deeply nested features.) /// await store.send(\.login.submitButtonTapped) { /// // 2️⃣ Assert how all state changes in the login feature /// $0.login?.isLoading = true /// … /// } /// /// // 3️⃣ Login feature performs API request to login, and /// // sends response back into system. /// await store.receive(\.login.loginResponse.success) { /// // 4️⃣ Assert how all state changes in the login feature /// $0.login?.isLoading = false /// … /// } /// /// // 5️⃣ Login feature sends a delegate action to let parent /// // feature know it has successfully logged in. /// await store.receive(\.login.delegate.didLogin) { /// // 6️⃣ Assert how all of app state changes due to that action. /// $0.authenticatedTab = .loggedIn( /// Profile.State(...) /// ) /// … /// // 7️⃣ *Finally* assert that the selected tab switches to activity. /// $0.selectedTab = .activity /// } /// ``` /// /// Doing this with exhaustive testing is verbose, and there are a few problems with this: /// /// * We need to be intimately knowledgeable in how the login feature works so that we can assert /// on how its state changes and how its effects feed data back into the system. /// * If the login feature were to change its logic we may get test failures here even though the /// logic we are actually trying to test doesn't really care about those changes. /// * This test is very long, and so if there are other similar but slightly different flows we /// want to test we will be tempted to copy-and-paste the whole thing, leading to lots of /// duplicated, fragile tests. /// /// Non-exhaustive testing allows us to test the high-level flow that we are concerned with, that of /// login causing the selected tab to switch to activity, without having to worry about what is /// happening inside the login feature. To do this, we can turn off ``TestStore/exhaustivity`` in /// the test store, and then just assert on what we are interested in: /// /// ```swift /// let store = TestStore(App.State()) { /// App() /// } /// store.exhaustivity = .off // ⬅️ /// /// await store.send(\.login.submitButtonTapped) /// await store.receive(\.login.delegate.didLogin) { /// $0.selectedTab = .activity /// } /// ``` /// /// In particular, we did not assert on how the login's state changed or how the login's effects fed /// data back into the system. We just assert that when the "Submit" button is tapped that /// eventually we get the `didLogin` delegate action and that causes the selected tab to flip to /// activity. Now the login feature is free to make any change it wants to make without affecting /// this integration test. /// /// Using ``Exhaustivity/off`` for ``TestStore/exhaustivity`` causes all un-asserted changes to pass /// without any notification. If you would like to see what test failures are being suppressed /// without actually causing a failure, you can use ``Exhaustivity/off(showSkippedAssertions:)``: /// /// ```swift /// let store = TestStore(initialState: App.State()) { /// App() /// } /// store.exhaustivity = .off(showSkippedAssertions: true) // ⬅️ /// /// await store.send(\.login.submitButtonTapped) /// await store.receive(\.login.delegate.didLogin) { /// $0.selectedTab = .profile /// } /// ``` /// /// When this is run you will get grey, informational boxes on each assertion where some change /// wasn't fully asserted on: /// /// > ◽️ Expected failure: A state change does not match expectation: … /// > /// > ```diff /// >   App.State( /// >   authenticatedTab: .loggedOut( /// > Login.State( /// > - isLoading: false /// > + isLoading: true, /// > … /// > ) /// > ) /// >   ) /// > ``` /// > /// > Skipped receiving .login(.loginResponse(.success)) /// > /// > A state change does not match expectation: … /// > /// > ```diff /// >   App.State( /// > - authenticatedTab: .loggedOut(…) /// > + authenticatedTab: .loggedIn( /// > + Profile.State(…) /// > + ), /// > … /// >   ) /// > ``` /// > /// > (Expected: −, Actual: +) /// /// The test still passes, and none of these notifications are test failures. They just let you know /// what things you are not explicitly asserting against, and can be useful to see when tracking /// down bugs that happen in production but that aren't currently detected in tests. /// /// [merowing.info]: https://www.merowing.info /// [exhaustive-testing-in-tca]: https://www.merowing.info/exhaustive-testing-in-tca/ /// [Composable-Architecture-at-Scale]: https://vimeo.com/751173570 #if swift(<5.10) @MainActor(unsafe) #else @preconcurrency@MainActor #endif public final class TestStore { /// The current dependencies of the test store. /// /// The dependencies define the execution context that your feature runs in. They can be modified /// throughout the test store's lifecycle in order to influence how your feature produces effects. /// /// Typically you will override certain dependencies immediately after constructing the test /// store. For example, if your feature need access to the current date and an API client to do /// its job, you can override those dependencies like so: /// /// ```swift /// let store = TestStore(/* ... */) { /// $0.apiClient = .mock /// $0.date = .constant(Date(timeIntervalSinceReferenceDate: 1234567890)) /// } /// /// // Store assertions here /// ``` /// /// You can also override dependencies in the middle of the test in order to simulate how the /// dependency changes as the user performs action. For example, to test the flow of an API /// request failing at first but then later succeeding, you can do the following: /// /// ```swift /// store.dependencies.apiClient = .failing /// /// store.send(.buttonTapped) { /* ... */ } /// store.receive(\.searchResponse.failure) { /* ... */ } /// /// store.dependencies.apiClient = .mock /// /// store.send(.buttonTapped) { /* ... */ } /// store.receive(\.searchResponse.success) { /* ... */ } /// ``` public var dependencies: DependencyValues { _read { yield self.reducer.dependencies } _modify { yield &self.reducer.dependencies } } /// The current exhaustivity level of the test store. public var exhaustivity: Exhaustivity = .on /// Serializes all async work to the main thread for the lifetime of the test store. public var useMainSerialExecutor: Bool { get { uncheckedUseMainSerialExecutor } set { uncheckedUseMainSerialExecutor = newValue } } private let originalUseMainSerialExecutor = uncheckedUseMainSerialExecutor /// The current state of the test store. /// /// When read from a trailing closure assertion in /// ``send(_:assert:fileID:file:line:column:)-8f2pl`` or /// ``receive(_:timeout:assert:fileID:file:line:column:)-8zqxk``, it will equal the `inout` state /// passed to the /// closure. public var state: State { self.reducer.state } /// The default timeout used in all methods that take an optional timeout. /// /// This is the default timeout used in all methods that take an optional timeout, such as /// ``receive(_:timeout:assert:fileID:file:line:column:)-8zqxk`` and /// ``finish(timeout:fileID:file:line:column:)-klnc``. public var timeout: UInt64 private let fileID: StaticString private let filePath: StaticString private let line: UInt private let column: UInt let reducer: TestReducer private let sharedChangeTracker: SharedChangeTracker private let store: Store.TestAction> /// Returns `true` if the store's feature has been dismissed. public fileprivate(set) var isDismissed = false /// Creates a test store with an initial state and a reducer powering its runtime. /// /// See and the documentation of ``TestStore`` for more information on how to best /// use a test store. /// /// - Parameters: /// - initialState: The state the feature starts in. /// - reducer: The reducer that powers the runtime of the feature. Unlike /// ``Store/init(initialState:reducer:withDependencies:)``, this is _not_ a builder closure /// due to a [Swift bug](https://github.com/apple/swift/issues/72399) that is more likely to /// affect test store initialization. If you must compose multiple reducers in this closure, /// wrap them in ``CombineReducers``. /// - prepareDependencies: A closure that can be used to override dependencies that will be /// accessed during the test. These dependencies will be used when producing the initial /// state. /// - fileID: The fileID. /// - filePath: The filePath. /// - line: The line. /// - column: The column. public init( initialState: @autoclosure () -> State, reducer: () -> some Reducer, withDependencies prepareDependencies: (inout DependencyValues) -> Void = { _ in }, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) { let sharedChangeTracker = SharedChangeTracker() let reducer = Dependencies.withDependencies { prepareDependencies(&$0) sharedChangeTracker.track(&$0) $0.navigationIDPath.append(NavigationID()) } operation: { TestReducer(Reduce(reducer()), initialState: initialState()) } self.fileID = fileID self.filePath = filePath self.line = line self.column = column self.reducer = reducer self.store = Store(initialState: reducer.state) { reducer } self.timeout = 1 * NSEC_PER_SEC self.sharedChangeTracker = sharedChangeTracker self.useMainSerialExecutor = true self.reducer.store = self } /// Suspends until all in-flight effects have finished, or until it times out. /// /// Can be used to assert that all effects have finished. /// /// - Parameters: /// - duration: The amount of time to wait before asserting. /// - fileID: The fileID. /// - filePath: The filePath. /// - line: The line. /// - column: The column. @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) public func finish( timeout duration: Duration, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) async { await self.finish( timeout: duration.nanoseconds, fileID: fileID, file: filePath, line: line, column: column ) } /// Suspends until all in-flight effects have finished, or until it times out. /// /// Can be used to assert that all effects have finished. /// /// > Important: `TestStore.finish()` should only be called once per test store, at the end of the /// > test. Interacting with a finished test store is undefined. /// /// - Parameters: /// - nanoseconds: The amount of time to wait before asserting. /// - fileID: The fileID. /// - filePath: The filePath. /// - line: The line. /// - column: The column. @_disfavoredOverload public func finish( timeout nanoseconds: UInt64? = nil, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) async { self.assertNoReceivedActions(fileID: fileID, filePath: filePath, line: line, column: column) Task.cancel(id: OnFirstAppearID()) let nanoseconds = nanoseconds ?? self.timeout let start = DispatchTime.now().uptimeNanoseconds await Task.megaYield() while !self.reducer.inFlightEffects.isEmpty { guard start.distance(to: DispatchTime.now().uptimeNanoseconds) < nanoseconds else { let timeoutMessage = nanoseconds != self.timeout ? #"try increasing the duration of this assertion's "timeout""# : #"configure this assertion with an explicit "timeout""# let suggestion = """ There are effects in-flight. If the effect that delivers this action uses a \ clock/scheduler (via "receive(on:)", "delay", "debounce", etc.), make sure that you wait \ enough time for it to perform the effect. If you are using a test \ clock/scheduler, advance it so that the effects may complete, or consider using \ an immediate clock/scheduler to immediately perform the effect instead. If you are not yet using a clock/scheduler, or can not use a clock/scheduler, \ \(timeoutMessage). """ reportIssueHelper( """ Expected effects to finish, but there are still effects in-flight\ \(nanoseconds > 0 ? " after \(Double(nanoseconds)/Double(NSEC_PER_SEC)) seconds" : ""). \(suggestion) """, fileID: fileID, filePath: filePath, line: line, column: column ) return } await Task.yield() } self.assertNoSharedChanges(fileID: fileID, filePath: filePath, line: line, column: column) } deinit { uncheckedUseMainSerialExecutor = self.originalUseMainSerialExecutor mainActorNow { self.completed() } } func completed() { self.assertNoReceivedActions( fileID: self.fileID, filePath: self.filePath, line: self.line, column: self.column ) Task.cancel(id: OnFirstAppearID()) for effect in self.reducer.inFlightEffects { reportIssueHelper( """ An effect returned for this action is still running. It must complete before the end of \ the test. To fix, inspect any effects the reducer returns for this action and ensure that all of \ them complete by the end of the test. There are a few reasons why an effect may not have \ completed: If using async/await in your effect, it may need a little bit of time to properly \ finish. To fix you can simply perform "await store.finish()" at the end of your test. If an effect uses a clock (or scheduler, via "receive(on:)", "delay", "debounce", etc.), \ make sure that you wait enough time for it to perform the effect. If you are using a test \ clock/scheduler, advance it so that the effects may complete, or consider using an \ immediate clock/scheduler to immediately perform the effect instead. If you are returning a long-living effect (timers, notifications, subjects, etc.), \ then make sure those effects are torn down by marking the effect ".cancellable" and \ returning a corresponding cancellation effect ("Effect.cancel") from another action, or, \ if your effect is driven by a Combine subject, send it a completion. If you do not wish to assert on these effects, perform "await \ store.skipInFlightEffects()", or consider using a non-exhaustive test store: \ "store.exhaustivity = .off". """, fileID: effect.action.fileID, filePath: effect.action.filePath, line: effect.action.line, column: effect.action.column ) } self.assertNoSharedChanges( fileID: self.fileID, filePath: self.filePath, line: self.line, column: self.column ) } private func assertNoReceivedActions( fileID: StaticString, filePath: StaticString, line: UInt, column: UInt ) { if !self.reducer.receivedActions.isEmpty { let actions = self.reducer.receivedActions .map(\.action) .map { " " + debugCaseOutput($0, abbreviated: true) } .joined(separator: "\n") reportIssueHelper( """ The store received \(self.reducer.receivedActions.count) unexpected \ action\(self.reducer.receivedActions.count == 1 ? "" : "s"). Unhandled actions: \(actions) To fix, explicitly assert against these actions using "store.receive", skip these actions \ by performing "await store.skipReceivedActions()", or consider using a non-exhaustive test \ store: "store.exhaustivity = .off". """, fileID: fileID, filePath: filePath, line: line, column: column ) } } private func assertNoSharedChanges( fileID: StaticString, filePath: StaticString, line: UInt, column: UInt ) { if sharedChangeTracker.hasChanges { try? expectedStateShouldMatch( preamble: "Test store finished before asserting against changes to shared state", postamble: """ Invoke "TestStore.assert" at the end of this test to assert against changes to shared \ state. """, expected: state, actual: state, updateStateToExpectedResult: nil, skipUnnecessaryModifyFailure: true, fileID: fileID, filePath: filePath, line: line, column: column ) } sharedChangeTracker.reset() } /// Overrides the store's dependencies for a given operation. /// /// - Parameters: /// - updateValuesForOperation: A closure for updating the store's dependency values for the /// duration of the operation. /// - operation: The operation. public func withDependencies( _ updateValuesForOperation: (_ dependencies: inout DependencyValues) throws -> Void, operation: () throws -> R ) rethrows -> R { let previous = self.dependencies defer { self.dependencies = previous } try updateValuesForOperation(&self.dependencies) return try operation() } #if compiler(>=6) /// Overrides the store's dependencies for a given operation. /// /// - Parameters: /// - updateValuesForOperation: A closure for updating the store's dependency values for the /// duration of the operation. /// - operation: The operation. public func withDependencies( _ updateValuesForOperation: (_ dependencies: inout DependencyValues) throws -> Void, operation: () async throws -> sending R ) async rethrows -> R { let previous = self.dependencies defer { self.dependencies = previous } try updateValuesForOperation(&self.dependencies) return try await operation() } #else public func withDependencies( _ updateValuesForOperation: (_ dependencies: inout DependencyValues) throws -> Void, operation: () async throws -> R ) async rethrows -> R { let previous = self.dependencies defer { self.dependencies = previous } try updateValuesForOperation(&self.dependencies) return try await operation() } #endif /// Overrides the store's exhaustivity for a given operation. /// /// - Parameters: /// - exhaustivity: The exhaustivity. /// - operation: The operation. public func withExhaustivity( _ exhaustivity: Exhaustivity, operation: () throws -> R ) rethrows -> R { let previous = self.exhaustivity defer { self.exhaustivity = previous } self.exhaustivity = exhaustivity return try operation() } #if compiler(>=6) /// Overrides the store's exhaustivity for a given operation. /// /// - Parameters: /// - exhaustivity: The exhaustivity. /// - operation: The operation. public func withExhaustivity( _ exhaustivity: Exhaustivity, operation: () async throws -> sending R ) async rethrows -> R { let previous = self.exhaustivity defer { self.exhaustivity = previous } self.exhaustivity = exhaustivity return try await operation() } #else public func withExhaustivity( _ exhaustivity: Exhaustivity, operation: () async throws -> R ) async rethrows -> R { let previous = self.exhaustivity defer { self.exhaustivity = previous } self.exhaustivity = exhaustivity return try await operation() } #endif } /// A convenience type alias for referring to a test store of a given reducer's domain. /// /// Instead of specifying two generics: /// /// ```swift /// let testStore: TestStore /// ``` /// /// You can specify a single generic: /// /// ```swift /// let testStore: TestStoreOf /// ``` public typealias TestStoreOf = TestStore where R.State: Equatable extension TestStore { /// Sends an action to the store and asserts when state changes. /// /// To assert on how state changes you can provide a trailing closure, and that closure is handed /// a mutable variable that represents the feature's state _before_ the action was sent. You need /// to mutate that variable so that it is equal to the feature's state _after_ the action is sent: /// /// ```swift /// await store.send(.incrementButtonTapped) { /// $0.count = 1 /// } /// await store.send(.decrementButtonTapped) { /// $0.count = 0 /// } /// ``` /// /// This method suspends in order to allow any effects to start. For example, if you track an /// analytics event in an effect when an action is sent, you can assert on that behavior /// immediately after awaiting `store.send`: /// /// ```swift /// @Test /// func analytics() async { /// let events = LockIsolated<[String]>([]) /// let analytics = AnalyticsClient( /// track: { event in /// events.withValue { $0.append(event) } /// } /// ) /// /// let store = TestStore(initialState: Feature.State()) { /// Feature() /// } withDependencies { /// $0.analytics = analytics /// } /// /// await store.send(.buttonTapped) /// /// events.withValue { XCTAssertEqual($0, ["Button Tapped"]) } /// } /// ``` /// /// This method suspends only for the duration until the effect _starts_ from sending the action. /// It does _not_ suspend for the duration of the effect. /// /// In order to suspend for the duration of the effect you can use its return value, a /// ``TestStoreTask``, which represents the lifecycle of the effect started from sending an /// action. You can use this value to suspend until the effect finishes, or to force the /// cancellation of the effect, which is helpful for effects that are tied to a view's lifecycle /// and not torn down when an action is sent, such as actions sent in SwiftUI's `task` view /// modifier. /// /// For example, if your feature kicks off a long-living effect when the view appears by using /// SwiftUI's `task` view modifier, then you can write a test for such a feature by explicitly /// canceling the effect's task after you make all assertions: /// /// ```swift /// let store = TestStore(/* ... */) /// /// // Emulate the view appearing /// let task = await store.send(.task) /// /// // Assertions /// /// // Emulate the view disappearing /// await task.cancel() /// ``` /// /// - Parameters: /// - action: An action. /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action to /// the store. The mutable state sent to this closure must be modified to match the state of /// the store after processing the given action. Do not provide a closure if no change is /// expected. /// - fileID: The fileID. /// - filePath: The filePath. /// - line: The line. /// - column: The column. /// - Returns: A ``TestStoreTask`` that represents the lifecycle of the effect executed when /// sending the action. @discardableResult public func send( _ action: Action, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) async -> TestStoreTask { await _withIssueContext(fileID: fileID, filePath: filePath, line: line, column: column) { guard !self.isDismissed else { reportIssue( "Can't send action to dismissed test store.", fileID: fileID, filePath: filePath, line: line, column: column ) return TestStoreTask(rawValue: nil, timeout: self.timeout) } if !self.reducer.receivedActions.isEmpty { var actions = "" customDump(self.reducer.receivedActions.map(\.action), to: &actions) reportIssueHelper( """ Must handle \(self.reducer.receivedActions.count) received \ action\(self.reducer.receivedActions.count == 1 ? "" : "s") before sending an action. Unhandled actions: \(actions) """, fileID: fileID, filePath: filePath, line: line, column: column ) } switch self.exhaustivity { case .on: break case .off(showSkippedAssertions: true): await self.skipReceivedActions(strict: false) case .off(showSkippedAssertions: false): self.reducer.receivedActions = [] } let expectedState = self.state let previousState = self.reducer.state let previousStackElementID = self.reducer.dependencies.stackElementID.incrementingCopy() let task = self.store.send( .init( origin: .send(action), fileID: fileID, filePath: filePath, line: line, column: column ) ) if uncheckedUseMainSerialExecutor { await Task.yield() } else { for await _ in self.reducer.effectDidSubscribe.stream { break } } do { let currentState = self.state let currentStackElementID = self.reducer.dependencies.stackElementID self.reducer.state = previousState self.reducer.dependencies.stackElementID = previousStackElementID defer { self.reducer.state = currentState self.reducer.dependencies.stackElementID = currentStackElementID } try self.expectedStateShouldMatch( expected: expectedState, actual: currentState, updateStateToExpectedResult: updateStateToExpectedResult, fileID: fileID, filePath: filePath, line: line, column: column ) } catch { reportIssue( "Threw error: \(error)", fileID: fileID, filePath: filePath, line: line, column: column ) } // NB: Give concurrency runtime more time to kick off effects so users don't need to manually // instrument their effects. await Task.megaYield(count: 20) return .init(rawValue: task.rawValue, timeout: self.timeout) } } /// Assert against the current state of the store. /// /// The trailing closure provided is given a mutable argument that represents the current state, /// and you can provide any mutations you want to the state. If your mutations cause the argument /// to differ from the current state of the test store, a test failure will be triggered. /// /// This tool is most useful in non-exhaustive test stores (see /// ), which allow you to assert on a subset of the things /// happening inside your features. For example, you can send an action in a child feature /// without asserting on how many changes in the system, and then tell the test store to /// ``finish(timeout:fileID:file:line:column:)-klnc`` by executing all of its effects, and finally /// to ``skipReceivedActions(strict:fileID:file:line:column:)`` to receive all actions. After that /// is done you can assert on the final state of the store: /// /// ```swift /// store.exhaustivity = .off /// await store.send(\.child.closeButtonTapped) /// await store.finish() /// await store.skipReceivedActions() /// store.assert { /// $0.child = nil /// } /// ``` /// /// > Note: This helper is only intended to be used with non-exhaustive test stores. It is not /// needed in exhaustive test stores since any assertion you may make inside the trailing closure /// has already been handled by a previous `send` or `receive`. /// /// - Parameters: /// - updateStateToExpectedResult: A closure that asserts against the current state of the test /// store. /// - fileID: The fileID. /// - filePath: The filePath. /// - line: The line. /// - column: The column. public func assert( _ updateStateToExpectedResult: @escaping (_ state: inout State) throws -> Void, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) { let expectedState = self.state let currentState = self.reducer.state do { try self.expectedStateShouldMatch( expected: expectedState, actual: currentState, updateStateToExpectedResult: updateStateToExpectedResult, skipUnnecessaryModifyFailure: true, fileID: fileID, filePath: filePath, line: line, column: column ) } catch { reportIssue( "Threw error: \(error)", fileID: fileID, filePath: filePath, line: line, column: column ) } } private func expectedStateShouldMatch( preamble: String = "", postamble: String = "", expected: State, actual: State, updateStateToExpectedResult: ((inout State) throws -> Void)? = nil, skipUnnecessaryModifyFailure: Bool = false, fileID: StaticString, filePath: StaticString, line: UInt, column: UInt ) throws { try self.sharedChangeTracker.assert { let skipUnnecessaryModifyFailure = skipUnnecessaryModifyFailure || self.sharedChangeTracker.hasChanges == true if self.exhaustivity != .on { self.sharedChangeTracker.reset() } let current = expected var expected = expected let currentStackElementID = self.reducer.dependencies.stackElementID let copiedStackElementID = currentStackElementID.incrementingCopy() self.reducer.dependencies.stackElementID = copiedStackElementID defer { self.reducer.dependencies.stackElementID = currentStackElementID } let updateStateToExpectedResult = updateStateToExpectedResult.map { original in { (state: inout State) in try XCTModifyLocals.$isExhaustive.withValue(self.exhaustivity == .on) { try original(&state) } } } switch self.exhaustivity { case .on: var expectedWhenGivenPreviousState = expected if let updateStateToExpectedResult { try Dependencies.withDependencies { $0 = self.reducer.dependencies } operation: { try self.sharedChangeTracker.assert { try updateStateToExpectedResult(&expectedWhenGivenPreviousState) } } } expected = expectedWhenGivenPreviousState if expectedWhenGivenPreviousState != actual { expectationFailure(expected: expectedWhenGivenPreviousState) } else { tryUnnecessaryModifyFailure() } case .off: var expectedWhenGivenActualState = actual if let updateStateToExpectedResult { try Dependencies.withDependencies { $0 = self.reducer.dependencies } operation: { try self.sharedChangeTracker.assert { try updateStateToExpectedResult(&expectedWhenGivenActualState) } } } expected = expectedWhenGivenActualState if expectedWhenGivenActualState != actual { self.withExhaustivity(.on) { expectationFailure(expected: expectedWhenGivenActualState) } } else if self.exhaustivity == .off(showSkippedAssertions: true) && expectedWhenGivenActualState == actual { var expectedWhenGivenPreviousState = current if let updateStateToExpectedResult { withExpectedIssue(isIntermittent: true) { do { try Dependencies.withDependencies { $0 = self.reducer.dependencies } operation: { try self.sharedChangeTracker.assert { try updateStateToExpectedResult(&expectedWhenGivenPreviousState) } } } catch { reportIssue( """ Skipped assertions. Threw error: \(error) """, fileID: fileID, filePath: filePath, line: line, column: column ) } } } expected = expectedWhenGivenPreviousState if self.withExhaustivity(.on, operation: { expectedWhenGivenPreviousState != actual }) { expectationFailure(expected: expectedWhenGivenPreviousState) } else { tryUnnecessaryModifyFailure() } } else { tryUnnecessaryModifyFailure() } } @MainActor func expectationFailure(expected: State) { let difference = self.withExhaustivity(.on) { diff(expected, actual, format: .proportional) .map { "\($0.indent(by: 4))\n\n(Expected: −, Actual: +)" } ?? """ Expected: \(String(describing: expected).indent(by: 2)) Actual: \(String(describing: actual).indent(by: 2)) """ } let messageHeading = !preamble.isEmpty ? preamble : updateStateToExpectedResult != nil ? "A state change does not match expectation" : "State was not expected to change, but a change occurred" reportIssueHelper( """ \(messageHeading). \(difference)\(postamble.isEmpty ? "" : "\n\n\(postamble)") """, fileID: fileID, filePath: filePath, line: line, column: column ) } @MainActor func tryUnnecessaryModifyFailure() { guard !skipUnnecessaryModifyFailure, expected == current, updateStateToExpectedResult != nil else { return } reportIssueHelper( """ Expected state to change, but no change occurred. The trailing closure made no observable modifications to state. If no change to state is \ expected, omit the trailing closure. """, fileID: fileID, filePath: filePath, line: line, column: column ) } self.sharedChangeTracker.reset() } } } extension TestStore where Action: Equatable { private func _receive( _ expectedAction: Action, assert updateStateToExpectedResult: ((inout State) throws -> Void)? = nil, fileID: StaticString = #fileID, filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) { var expectedActionDump = "" customDump(expectedAction, to: &expectedActionDump, indent: 2) self.receiveAction( matching: { expectedAction == $0 }, failureMessage: """ Expected to receive the following action, but didn't: \(expectedActionDump) """, unexpectedActionDescription: { receivedAction in TaskResultDebugging.$emitRuntimeWarnings.withValue(false) { diff(expectedAction, receivedAction, format: .proportional) .map { "\($0.indent(by: 4))\n\n(Expected: −, Received: +)" } ?? """ Expected: \(String(describing: expectedAction).indent(by: 2)) Received: \(String(describing: receivedAction).indent(by: 2)) """ } }, updateStateToExpectedResult, fileID: fileID, filePath: filePath, line: line, column: column ) } /// Asserts an action was received from an effect and asserts how the state changes. /// /// When an effect is executed in your feature and sends an action back into the system, you can /// use this method to assert that fact, and further assert how state changes after the effect /// action is received: /// /// ```swift /// await store.send(.buttonTapped) /// await store.receive(.response(.success(42)) { /// $0.count = 42 /// } /// ``` /// /// Due to the variability of concurrency in Swift, sometimes a small amount of time needs to pass /// before effects execute and send actions, and that is why this method suspends. The default /// time waited is very small, and typically it is enough so you should be controlling your /// dependencies so that they do not wait for real world time to pass (see /// for more information on how to do that). /// /// To change the amount of time this method waits for an action, pass an explicit `timeout` /// argument, or set the ``timeout`` on the ``TestStore``. /// /// - Parameters: /// - expectedAction: An action expected from an effect. /// - duration: The amount of time to wait for the expected action. /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action /// to the store. The mutable state sent to this closure must be modified to match the state /// of the store after processing the given action. Do not provide a closure if no change /// is expected. /// - fileID: The fileID. /// - filePath: The filePath. /// - line: The line. /// - column: The column. @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) public func receive( _ expectedAction: Action, timeout duration: Duration, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) async { await self.receive( expectedAction, timeout: duration.nanoseconds, assert: updateStateToExpectedResult, fileID: fileID, file: filePath, line: line, column: column ) } /// Asserts an action was received from an effect and asserts how the state changes. /// /// When an effect is executed in your feature and sends an action back into the system, you can /// use this method to assert that fact, and further assert how state changes after the effect /// action is received: /// /// ```swift /// await store.send(.buttonTapped) /// await store.receive(.response(.success(42)) { /// $0.count = 42 /// } /// ``` /// /// Due to the variability of concurrency in Swift, sometimes a small amount of time needs to pass /// before effects execute and send actions, and that is why this method suspends. The default /// time waited is very small, and typically it is enough so you should be controlling your /// dependencies so that they do not wait for real world time to pass (see /// for more information on how to do that). /// /// To change the amount of time this method waits for an action, pass an explicit `timeout` /// argument, or set the ``timeout`` on the ``TestStore``. /// /// - Parameters: /// - expectedAction: An action expected from an effect. /// - nanoseconds: The amount of time to wait for the expected action. /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action to /// the store. The mutable state sent to this closure must be modified to match the state of /// the store after processing the given action. Do not provide a closure if no change is /// expected. /// - fileID: The fileID. /// - filePath: The filePath. /// - line: The line. /// - column: The column. @_disfavoredOverload public func receive( _ expectedAction: Action, timeout nanoseconds: UInt64? = nil, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) async { await _withIssueContext(fileID: fileID, filePath: filePath, line: line, column: column) { guard !self.reducer.inFlightEffects.isEmpty else { _ = { self._receive( expectedAction, assert: updateStateToExpectedResult, fileID: fileID, filePath: filePath, line: line, column: column ) }() return } await self.receiveAction( matching: { expectedAction == $0 }, timeout: nanoseconds, fileID: fileID, filePath: filePath, line: line, column: column ) _ = { self._receive( expectedAction, assert: updateStateToExpectedResult, fileID: fileID, filePath: filePath, line: line, column: column ) }() await Task.megaYield() } } } extension TestStore { private func _receive( _ isMatching: (Action) -> Bool, assert updateStateToExpectedResult: ((inout State) throws -> Void)? = nil, fileID: StaticString = #fileID, filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) { self.receiveAction( matching: isMatching, failureMessage: "Expected to receive an action matching predicate, but didn't get one.", unexpectedActionDescription: { receivedAction in var action = "" customDump(receivedAction, to: &action, indent: 2) return action }, updateStateToExpectedResult, fileID: fileID, filePath: filePath, line: line, column: column ) } private func _receive( _ actionCase: AnyCasePath, assert updateStateToExpectedResult: ((inout State) throws -> Void)? = nil, fileID: StaticString = #fileID, filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) { self.receiveAction( matching: { actionCase.extract(from: $0) != nil }, failureMessage: "Expected to receive an action matching case path, but didn't get one.", unexpectedActionDescription: { receivedAction in var action = "" customDump(receivedAction, to: &action, indent: 2) return action }, updateStateToExpectedResult, fileID: fileID, filePath: filePath, line: line, column: column ) } private func _receive( _ actionCase: AnyCasePath, _ value: Value, assert updateStateToExpectedResult: ((inout State) throws -> Void)? = nil, fileID: StaticString = #fileID, filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) { self.receiveAction( matching: { actionCase.extract(from: $0) == value }, failureMessage: "Expected to receive an action matching case path, but didn't get one.", unexpectedActionDescription: { receivedAction in var action = "" if actionCase.extract(from: receivedAction) != nil, let difference = diff(actionCase.embed(value), receivedAction, format: .proportional) { action.append( """ \(difference.indent(by: 2)) (Expected: −, Actual: +) """ ) } else { customDump(receivedAction, to: &action, indent: 2) } return action }, updateStateToExpectedResult, fileID: fileID, filePath: filePath, line: line, column: column ) } /// Asserts an action was received from an effect that matches a predicate, and asserts how the /// state changes. /// /// This method is similar to ``receive(_:timeout:assert:fileID:file:line:column:)-8zqxk``, except /// it allows you to assert that an action was received that matches a predicate instead of a case /// key path: /// /// ```swift /// await store.send(.buttonTapped) /// await store.receive { /// guard case .response(.success) = $0 else { return false } /// return true /// } assert: { /// store.count = 42 /// } /// ``` /// /// When the store's ``exhaustivity`` is set to anything other than ``Exhaustivity/off``, a grey /// information box will show next to the `store.receive` line in Xcode letting you know what data /// was in the effect that you chose not to assert on. /// /// If you only want to check that a particular action case was received, then you might find the /// ``receive(_:timeout:assert:fileID:file:line:column:)-53wic`` overload of this method more /// useful. /// /// - Parameters: /// - isMatching: A closure that attempts to match an action. If it returns `false`, a test /// failure is reported. /// - duration: The amount of time to wait for the expected action. /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action /// to the store. The mutable state sent to this closure must be modified to match the state /// of the store after processing the given action. Do not provide a closure if no change is /// expected. /// - fileID: The fileID. /// - filePath: The filePath. /// - line: The line. /// - column: The column. @_disfavoredOverload @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) public func receive( _ isMatching: (_ action: Action) -> Bool, timeout duration: Duration, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) async { await self.receive( isMatching, timeout: duration.nanoseconds, assert: updateStateToExpectedResult, fileID: fileID, file: filePath, line: line, column: column ) } /// Asserts an action was received from an effect that matches a predicate, and asserts how the /// state changes. /// /// This method is similar to ``receive(_:timeout:assert:fileID:file:line:column:)-8zqxk``, except /// it allows you to assert that an action was received that matches a predicate instead of a case /// key path: /// /// ```swift /// await store.send(.buttonTapped) /// await store.receive { /// guard case .response(.success) = $0 else { return false } /// return true /// } assert: { /// store.count = 42 /// } /// ``` /// /// When the store's ``exhaustivity`` is set to anything other than ``Exhaustivity/off``, a grey /// information box will show next to the `store.receive` line in Xcode letting you know what data /// was in the effect that you chose not to assert on. /// /// If you only want to check that a particular action case was received, then you might find the /// ``receive(_:timeout:assert:fileID:file:line:column:)-53wic`` overload of this method more /// useful. /// /// - Parameters: /// - isMatching: A closure that attempts to match an action. If it returns `false`, a test /// failure is reported. /// - nanoseconds: The amount of time to wait for the expected action. /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action to /// the store. The mutable state sent to this closure must be modified to match the state of /// the store after processing the given action. Do not provide a closure if no change is /// expected. /// - fileID: The fileID. /// - filePath: The filePath. /// - line: The line. /// - column: The column. @_disfavoredOverload public func receive( _ isMatching: (_ action: Action) -> Bool, timeout nanoseconds: UInt64? = nil, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) async { await _withIssueContext(fileID: fileID, filePath: filePath, line: line, column: column) { guard !self.reducer.inFlightEffects.isEmpty else { _ = { self._receive( isMatching, assert: updateStateToExpectedResult, fileID: fileID, filePath: filePath, line: line, column: column ) }() return } await self.receiveAction( matching: isMatching, timeout: nanoseconds, fileID: fileID, filePath: filePath, line: line, column: column ) _ = { self._receive( isMatching, assert: updateStateToExpectedResult, fileID: fileID, filePath: filePath, line: line, column: column ) }() await Task.megaYield() } } /// Asserts an action was received matching a case path and asserts how the state changes. /// /// This method is similar to ``receive(_:timeout:assert:fileID:file:line:column:)-35638``, except /// it allows you to assert that an action was received that matches a case key path instead of a /// predicate. /// /// It can be useful to assert that a particular action was received without asserting on the data /// inside the action. For example: /// /// ```swift /// await store.receive(/Search.Action.searchResponse) { /// $0.results = [ /// "CasePaths", /// "ComposableArchitecture", /// "IdentifiedCollections", /// "XCTestDynamicOverlay", /// ] /// } /// ``` /// /// When the store's ``exhaustivity`` is set to anything other than ``Exhaustivity/off``, a grey /// information box will show next to the `store.receive` line in Xcode letting you know what data /// was in the effect that you chose not to assert on. /// /// - Parameters: /// - actionCase: A case path identifying the case of an action to enum to receive /// - nanoseconds: The amount of time to wait for the expected action. /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action to /// the store. The mutable state sent to this closure must be modified to match the state of /// the store after processing the given action. Do not provide a closure if no change is /// expected. /// - fileID: The fileID. /// - filePath: The filePath. /// - line: The line. /// - column: The column. @_disfavoredOverload public func receive( _ actionCase: CaseKeyPath, timeout nanoseconds: UInt64? = nil, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) async { await self.receive( AnyCasePath(actionCase), timeout: nanoseconds, assert: updateStateToExpectedResult, fileID: fileID, file: filePath, line: line, column: column ) } /// Asserts an action was received matching a case path with a specific payload, and asserts how /// the state changes. /// /// This method is similar to ``receive(_:timeout:assert:fileID:file:line:column:)-53wic``, except /// it allows you to assert on the value inside the action too. /// /// It can be useful when asserting on delegate actions sent by a child feature: /// /// ```swift /// await store.receive(\.delegate.success, "Hello!") /// ``` /// /// When the store's ``exhaustivity`` is set to anything other than ``Exhaustivity/off``, a grey /// information box will show next to the `store.receive` line in Xcode letting you know what data /// was in the effect that you chose not to assert on. /// /// - Parameters: /// - actionCase: A case path identifying the case of an action to enum to receive /// - value: The value to match in the action. /// - nanoseconds: The amount of time to wait for the expected action. /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action /// to the store. The mutable state sent to this closure must be modified to match the state /// of the store after processing the given action. Do not provide a closure if no change is /// expected. /// - fileID: The fileID. /// - filePath: The filePath. /// - line: The line. /// - column: The column. @_disfavoredOverload public func receive( _ actionCase: CaseKeyPath, _ value: Value, timeout nanoseconds: UInt64? = nil, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) async where Action: CasePathable { let actionCase = AnyCasePath(actionCase) await _withIssueContext(fileID: fileID, filePath: filePath, line: line, column: column) { guard !self.reducer.inFlightEffects.isEmpty else { _ = { self._receive( actionCase, value, assert: updateStateToExpectedResult, fileID: fileID, filePath: filePath, line: line, column: column ) }() return } await self.receiveAction( matching: { actionCase.extract(from: $0) != nil }, timeout: nanoseconds, fileID: fileID, filePath: filePath, line: line, column: column ) _ = { self._receive( actionCase, value, assert: updateStateToExpectedResult, fileID: fileID, filePath: filePath, line: line, column: column ) }() await Task.megaYield() } } @available( iOS, deprecated: 9999, message: "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" ) @available( macOS, deprecated: 9999, message: "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" ) @available( tvOS, deprecated: 9999, message: "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" ) @available( watchOS, deprecated: 9999, message: "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" ) @_disfavoredOverload public func receive( _ actionCase: AnyCasePath, timeout nanoseconds: UInt64? = nil, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) async { await _withIssueContext(fileID: fileID, filePath: filePath, line: line, column: column) { guard !self.reducer.inFlightEffects.isEmpty else { _ = { self._receive( actionCase, assert: updateStateToExpectedResult, fileID: fileID, filePath: filePath, line: line, column: column ) }() return } await self.receiveAction( matching: { actionCase.extract(from: $0) != nil }, timeout: nanoseconds, fileID: fileID, filePath: filePath, line: line, column: column ) _ = { self._receive( actionCase, assert: updateStateToExpectedResult, fileID: fileID, filePath: filePath, line: line, column: column ) }() await Task.megaYield() } } /// Asserts an action was received matching a case path and asserts how the state changes. /// /// This method is similar to ``receive(_:timeout:assert:fileID:file:line:column:)-8zqxk``, except /// it allows you to assert that an action was received that matches a case key path instead of a /// predicate. /// /// It can be useful to assert that a particular action was received without asserting on the data /// inside the action. For example: /// /// ```swift /// await store.receive(\.searchResponse) { /// $0.results = [ /// "CasePaths", /// "ComposableArchitecture", /// "IdentifiedCollections", /// "XCTestDynamicOverlay", /// ] /// } /// ``` /// /// When the store's ``exhaustivity`` is set to anything other than ``Exhaustivity/off``, a grey /// information box will show next to the `store.receive` line in Xcode letting you know what data /// was in the effect that you chose not to assert on. /// /// - Parameters: /// - actionCase: A case path identifying the case of an action to enum to receive /// - duration: The amount of time to wait for the expected action. /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action /// to the store. The mutable state sent to this closure must be modified to match the state /// of the store after processing the given action. Do not provide a closure if no change is /// expected. /// - fileID: The fileID. /// - filePath: The filePath. /// - line: The line. /// - column: The column. @_disfavoredOverload @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) public func receive( _ actionCase: CaseKeyPath, timeout duration: Duration, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) async { await self.receive( AnyCasePath(actionCase), timeout: duration, assert: updateStateToExpectedResult, fileID: fileID, file: filePath, line: line, column: column ) } /// Asserts an action was received matching a case path with a specific payload, and asserts how /// the state changes. /// /// This method is similar to ``receive(_:timeout:assert:fileID:file:line:column:)-53wic``, except /// it allows you to assert on the value inside the action too. /// /// It can be useful when asserting on delegate actions sent by a child feature: /// /// ```swift /// await store.receive(\.delegate.success, "Hello!") /// ``` /// /// When the store's ``exhaustivity`` is set to anything other than ``Exhaustivity/off``, a grey /// information box will show next to the `store.receive` line in Xcode letting you know what data /// was in the effect that you chose not to assert on. /// /// - Parameters: /// - actionCase: A case path identifying the case of an action to enum to receive /// - value: The value to match in the action. /// - duration: The amount of time to wait for the expected action. /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action /// to the store. The mutable state sent to this closure must be modified to match the state /// of the store after processing the given action. Do not provide a closure if no change is /// expected. /// - fileID: The fileID. /// - filePath: The filePath. /// - line: The line. /// - column: The column. @_disfavoredOverload @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) public func receive( _ actionCase: _SendableCaseKeyPath, _ value: Value, timeout duration: Duration, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) async where Action: CasePathable { await self.receive( AnyCasePath( embed: { actionCase($0) }, extract: { action in action[case: actionCase].flatMap { $0 == value ? $0 : nil } } ), timeout: duration, assert: updateStateToExpectedResult, fileID: fileID, file: filePath, line: line, column: column ) } @_disfavoredOverload @available( iOS, introduced: 16, deprecated: 9999, message: "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" ) @available( macOS, introduced: 13, deprecated: 9999, message: "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" ) @available( tvOS, introduced: 16, deprecated: 9999, message: "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" ) @available( watchOS, introduced: 9, deprecated: 9999, message: "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" ) public func receive( _ actionCase: AnyCasePath, timeout duration: Duration, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) async { await _withIssueContext(fileID: fileID, filePath: filePath, line: line, column: column) { guard !self.reducer.inFlightEffects.isEmpty else { _ = { self._receive( actionCase, assert: updateStateToExpectedResult, fileID: fileID, filePath: filePath, line: line, column: column ) }() return } await self.receiveAction( matching: { actionCase.extract(from: $0) != nil }, timeout: duration.nanoseconds, fileID: fileID, filePath: filePath, line: line, column: column ) _ = { self._receive( actionCase, assert: updateStateToExpectedResult, fileID: fileID, filePath: filePath, line: line, column: column ) }() await Task.megaYield() } } private func receiveAction( matching predicate: (Action) -> Bool, failureMessage: @autoclosure () -> String, unexpectedActionDescription: (Action) -> String, _ updateStateToExpectedResult: ((inout State) throws -> Void)?, fileID: StaticString, filePath: StaticString, line: UInt, column: UInt ) { let updateStateToExpectedResult = updateStateToExpectedResult.map { original in { (state: inout State) in try XCTModifyLocals.$isExhaustive.withValue(self.exhaustivity == .on) { try original(&state) } } } guard !self.reducer.receivedActions.isEmpty else { reportIssue( failureMessage(), fileID: fileID, filePath: filePath, line: line, column: column ) return } if self.exhaustivity != .on { guard self.reducer.receivedActions.contains(where: { predicate($0.action) }) else { reportIssue( failureMessage(), fileID: fileID, filePath: filePath, line: line, column: column ) return } var actions: [Action] = [] while let receivedAction = self.reducer.receivedActions.first, !predicate(receivedAction.action) { self.reducer.receivedActions.removeFirst() actions.append(receivedAction.action) self.reducer.state = receivedAction.state } if !actions.isEmpty { var actionsDump = "" customDump(actions, to: &actionsDump) reportIssueHelper( """ \(actions.count) received action\ \(actions.count == 1 ? " was" : "s were") skipped. \(actionsDump) """, fileID: fileID, filePath: filePath, line: line, column: column ) } } let (receivedAction, state) = self.reducer.receivedActions.removeFirst() if !predicate(receivedAction) { let receivedActionLater = self.reducer.receivedActions .contains(where: { action, _ in predicate(receivedAction) }) reportIssueHelper( """ Received unexpected action\(receivedActionLater ? " before this one" : ""): \(unexpectedActionDescription(receivedAction)) """, fileID: fileID, filePath: filePath, line: line, column: column ) } else { let expectedState = self.state do { try self.expectedStateShouldMatch( expected: expectedState, actual: state, updateStateToExpectedResult: updateStateToExpectedResult, fileID: fileID, filePath: filePath, line: line, column: column ) } catch { reportIssue( "Threw error: \(error)", fileID: fileID, filePath: filePath, line: line, column: column ) } } self.reducer.state = state } private func receiveAction( matching predicate: (Action) -> Bool, timeout nanoseconds: UInt64?, fileID: StaticString, filePath: StaticString, line: UInt, column: UInt ) async { let nanoseconds = nanoseconds ?? self.timeout await Task.megaYield() let start = DispatchTime.now().uptimeNanoseconds while !Task.isCancelled { await Task.detached(priority: .background) { await Task.yield() }.value switch self.exhaustivity { case .on: guard self.reducer.receivedActions.isEmpty else { return } case .off: guard !self.reducer.receivedActions.contains(where: { predicate($0.action) }) else { return } } guard start.distance(to: DispatchTime.now().uptimeNanoseconds) < nanoseconds else { let suggestion: String if self.reducer.inFlightEffects.isEmpty { suggestion = """ There are no in-flight effects that could deliver this action. Could the effect you \ expected to deliver this action have been cancelled? """ } else { let timeoutMessage = nanoseconds != self.timeout ? #"try increasing the duration of this assertion's "timeout""# : #"configure this assertion with an explicit "timeout""# suggestion = """ There are effects in-flight. If the effect that delivers this action uses a \ clock/scheduler (via "receive(on:)", "delay", "debounce", etc.), make sure that you \ wait enough time for it to perform the effect. If you are using a test \ clock/scheduler, advance it so that the effects may complete, or consider using \ an immediate clock/scheduler to immediately perform the effect instead. If you are not yet using a clock/scheduler, or can not use a clock/scheduler, \ \(timeoutMessage). """ } reportIssue( """ Expected to receive \(self.exhaustivity == .on ? "an action" : "a matching action"), but \ received none\ \(nanoseconds > 0 ? " after \(Double(nanoseconds)/Double(NSEC_PER_SEC)) seconds" : ""). \(suggestion) """, fileID: fileID, filePath: filePath, line: line, column: column ) return } } } } extension TestStore { /// Sends an action to the store and asserts when state changes. /// /// This method is similar to ``send(_:assert:fileID:file:line:column:)-8f2pl``, except it allows /// you to specify a case key path to an action, which can be useful when testing the integration /// of features and sending deeply nested actions. For example: /// /// ```swift /// await store.send(.destination(.presented(.child(.tap)))) /// ``` /// /// …can be simplified to: /// /// ```swift /// await store.send(\.destination.child.tap) /// ``` /// /// - Parameters: /// - action: A case key path to an action. /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action to /// the store. The mutable state sent to this closure must be modified to match the state of /// the store after processing the given action. Do not provide a closure if no change is /// expected. /// - fileID: The fileID. /// - filePath: The filePath. /// - line: The line. /// - column: The column. /// - Returns: A ``TestStoreTask`` that represents the lifecycle of the effect executed when /// sending the action. @_disfavoredOverload @discardableResult public func send( _ action: CaseKeyPath, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) async -> TestStoreTask { await self.send( action(), assert: updateStateToExpectedResult, fileID: fileID, file: filePath, line: line, column: column ) } /// Sends an action to the store and asserts when state changes. /// /// This method is similar to ``send(_:assert:fileID:file:line:column:)-8f2pl``, except it allows /// you to specify a value for the associated value of the action. /// /// It can be useful when sending nested action. For example: /// /// ```swift /// await store.send(.destination(.presented(.child(.emailChanged("blob@pointfree.co"))))) /// ``` /// /// …can be simplified to: /// /// ```swift /// await store.send(\.destination.child.emailChanged, "blob@pointfree.co") /// ``` /// /// - Parameters: /// - action: A case key path to an action. /// - value: A value to embed in `action`. /// - updateStateToExpectedResult: A closure that asserts state changed by sending the action to /// the store. The mutable state sent to this closure must be modified to match the state of /// the store after processing the given action. Do not provide a closure if no change is /// expected. /// - fileID: The fileID. /// - filePath: The filePath. /// - line: The line. /// - column: The column. /// - Returns: A ``TestStoreTask`` that represents the lifecycle of the effect executed when /// sending the action. @_disfavoredOverload @discardableResult public func send( _ action: CaseKeyPath, _ value: Value, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) async -> TestStoreTask { await self.send( action(value), assert: updateStateToExpectedResult, fileID: fileID, file: filePath, line: line, column: column ) } } extension TestStore { /// Clears the queue of received actions from effects. /// /// Can be handy if you are writing an exhaustive test for a particular part of your feature, but /// you don't want to explicitly deal with all of the received actions: /// /// ```swift /// let store = TestStore(/* ... */) /// /// await store.send(.buttonTapped) { /// // Assert on how state changed /// } /// await store.receive(\.response) { /// // Assert on how state changed /// } /// /// // Make it explicit you do not want to assert on any other received actions. /// await store.skipReceivedActions() /// ``` /// /// - Parameters: /// - strict: When `true` and there are no in-flight actions to cancel, a test failure /// will be reported. /// - fileID: The fileID. /// - filePath: The filePath. /// - line: The line. /// - column: The column. public func skipReceivedActions( strict: Bool = true, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) async { await Task.megaYield() _ = { self._skipReceivedActions( strict: strict, fileID: fileID, file: filePath, line: line, column: column ) }() } private func _skipReceivedActions( strict: Bool = true, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) { if strict && self.reducer.receivedActions.isEmpty { reportIssue( "There were no received actions to skip.", fileID: fileID, filePath: filePath, line: line, column: column ) return } guard !self.reducer.receivedActions.isEmpty else { return } var actions = "" if self.reducer.receivedActions.count == 1 { customDump(self.reducer.receivedActions[0].action, to: &actions) } else { customDump(self.reducer.receivedActions.map { $0.action }, to: &actions) } reportIssueHelper( """ \(self.reducer.receivedActions.count) received action\ \(self.reducer.receivedActions.count == 1 ? " was" : "s were") skipped. \(actions) """, overrideExhaustivity: self.exhaustivity == .on ? .off(showSkippedAssertions: true) : self.exhaustivity, fileID: fileID, filePath: filePath, line: line, column: column ) self.reducer.state = self.reducer.receivedActions.last!.state self.reducer.receivedActions = [] } /// Cancels any currently in-flight effects. /// /// Can be handy if you are writing an exhaustive test for a particular part of your feature, but /// you don't want to explicitly deal with all effects: /// /// ```swift /// let store = TestStore(/* ... */) /// /// await store.send(.buttonTapped) { /// // Assert on how state changed /// } /// await store.receive(\.response) { /// // Assert on how state changed /// } /// /// // Make it explicit you do not want to assert on how any other effects behave. /// await store.skipInFlightEffects() /// ``` /// /// - Parameters: /// - strict: When `true` and there are no in-flight actions to cancel, a test failure /// will be reported. /// - fileID: The fileID. /// - filePath: The filePath. /// - line: The line. /// - column: The column. public func skipInFlightEffects( strict: Bool = true, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) async { await Task.megaYield() _ = { self._skipInFlightEffects( strict: strict, fileID: fileID, filePath: filePath, line: line, column: column ) }() } fileprivate func _skipInFlightEffects( strict: Bool = true, fileID: StaticString = #fileID, filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) { if strict && self.reducer.inFlightEffects.isEmpty { reportIssue( "There were no in-flight effects to skip.", fileID: fileID, filePath: filePath, line: line, column: column ) return } guard !self.reducer.inFlightEffects.isEmpty else { return } var actions = "" if self.reducer.inFlightEffects.count == 1 { customDump(self.reducer.inFlightEffects.first!.action.origin.action, to: &actions) } else { customDump(self.reducer.inFlightEffects.map { $0.action.origin.action }, to: &actions) } reportIssueHelper( """ \(self.reducer.inFlightEffects.count) in-flight effect\ \(self.reducer.inFlightEffects.count == 1 ? " was" : "s were") cancelled, originating from: \(actions) """, overrideExhaustivity: self.exhaustivity == .on ? .off(showSkippedAssertions: true) : self.exhaustivity, fileID: fileID, filePath: filePath, line: line, column: column ) self.reducer.inFlightEffects = [] } private func reportIssueHelper( _ message: String = "", overrideExhaustivity exhaustivity: Exhaustivity? = nil, fileID: StaticString, filePath: StaticString, line: UInt, column: UInt ) { let exhaustivity = exhaustivity ?? self.exhaustivity switch exhaustivity { case .on: reportIssue(message, fileID: fileID, filePath: filePath, line: line, column: column) case .off(let showSkippedAssertions): if showSkippedAssertions { withExpectedIssue { reportIssue( """ Skipped assertions. \(message) """, fileID: fileID, filePath: filePath, line: line, column: column ) } } } } } extension TestStore { /// Returns a binding view store for this store. /// /// Useful for testing view state of a store. /// /// ```swift /// let store = TestStore(LoginFeature.State()) { /// Login.Feature() /// } /// await store.send(.view(.set(\.$email, "blob@pointfree.co"))) { /// $0.email = "blob@pointfree.co" /// } /// XCTAssertTrue( /// LoginView.ViewState(store.bindings(action: \.view)) /// .isLoginButtonDisabled /// ) /// /// await store.send(.view(.set(\.$password, "whats-the-point?"))) { /// $0.password = "blob@pointfree.co" /// $0.isFormValid = true /// } /// XCTAssertFalse( /// LoginView.ViewState(store.bindings(action: \.view)) /// .isLoginButtonDisabled /// ) /// ``` /// /// - Parameter toViewAction: A case path from action to a bindable view action. /// - Returns: A binding view store. public func bindings( action toViewAction: CaseKeyPath ) -> BindingViewStore where State == ViewAction.State, Action: CasePathable { BindingViewStore( store: Store(initialState: self.state) { BindingReducer(action: toViewAction) } .scope(state: \.self, action: toViewAction) ) } @available( iOS, deprecated: 9999, message: "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" ) @available( macOS, deprecated: 9999, message: "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" ) @available( tvOS, deprecated: 9999, message: "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" ) @available( watchOS, deprecated: 9999, message: "Use the version of this operator with case key paths, instead. See the following migration guide for more information: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths" ) public func bindings( action toViewAction: AnyCasePath ) -> BindingViewStore where State == ViewAction.State { BindingViewStore( store: Store(initialState: self.state) { BindingReducer(action: toViewAction.extract(from:)) } ._scope(state: { $0 }, action: toViewAction.embed) ) } } extension TestStore where Action: BindableAction, State == Action.State { /// Returns a binding view store for this store. /// /// Useful for testing view state of a store. /// /// ```swift /// let store = TestStore(LoginFeature.State()) { /// Login.Feature() /// } /// await store.send(.set(\.$email, "blob@pointfree.co")) { /// $0.email = "blob@pointfree.co" /// } /// XCTAssertTrue(LoginView.ViewState(store.bindings).isLoginButtonDisabled) /// /// await store.send(.set(\.$password, "whats-the-point?")) { /// $0.password = "blob@pointfree.co" /// $0.isFormValid = true /// } /// XCTAssertFalse(LoginView.ViewState(store.bindings).isLoginButtonDisabled) /// ``` /// /// - Returns: A binding view store. public var bindings: BindingViewStore { self.bindings(action: AnyCasePath()) } } /// The type returned from ``TestStore/send(_:assert:fileID:file:line:column:)-8f2pl`` that represents the /// lifecycle of the effect started from sending an action. /// /// You can use this value in tests to cancel the effect started from sending an action: /// /// ```swift /// // Simulate the "task" view modifier invoking some async work /// let task = store.send(.task) /// /// // Simulate the view cancelling this work on dismissal /// await task.cancel() /// ``` /// /// You can also explicitly wait for an effect to finish: /// /// ```swift /// store.send(.startTimerButtonTapped) /// /// await mainQueue.advance(by: .seconds(1)) /// await store.receive(\.timerTick) { $0.elapsed = 1 } /// /// // Wait for cleanup effects to finish before completing the test /// await store.send(.stopTimerButtonTapped).finish() /// ``` /// /// See ``TestStore/finish(timeout:fileID:file:line:column:)-klnc`` for the ability to await all /// in-flight effects in the test store. /// /// See ``StoreTask`` for the analog provided to ``Store``. public struct TestStoreTask: Hashable, Sendable { fileprivate let rawValue: Task? fileprivate let timeout: UInt64 @_spi(Canary) public init(rawValue: Task?, timeout: UInt64) { self.rawValue = rawValue self.timeout = timeout } /// Cancels the underlying task and waits for it to finish. /// /// This can be handy when a feature needs to start a long-living effect when the feature appears, /// but cancellation of that effect is handled by the parent when the feature disappears. Such a /// feature is difficult to exhaustively test in isolation because there is no action in its /// domain that cancels the effect: /// /// ```swift /// let store = TestStore(/* ... */) /// /// let onAppearTask = await store.send(.onAppear) /// // Assert what is happening in the feature /// /// await onAppearTask.cancel() // ✅ Cancel the task to simulate the feature disappearing. /// ``` public func cancel() async { self.rawValue?.cancel() await self.rawValue?.cancellableValue } /// Asserts the underlying task finished. /// /// - Parameters: /// - duration: The amount of time to wait before asserting. /// - fileID: The fileID. /// - filePath: The filePath. /// - line: The line. /// - column: The column. @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) public func finish( timeout duration: Duration, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) async { await self.finish( timeout: duration.nanoseconds, fileID: fileID, file: filePath, line: line, column: column ) } /// Asserts the underlying task finished. /// /// - Parameters: /// - nanoseconds: The amount of time to wait before asserting. /// - fileID: The fileID. /// - filePath: The filePath. /// - line: The line. /// - column: The column. @_disfavoredOverload public func finish( timeout nanoseconds: UInt64? = nil, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) async { let nanoseconds = nanoseconds ?? self.timeout await Task.megaYield() do { try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { await self.rawValue?.cancellableValue } group.addTask { try await Task.sleep(nanoseconds: nanoseconds) throw CancellationError() } try await group.next() group.cancelAll() } } catch { let timeoutMessage = nanoseconds != self.timeout ? #"try increasing the duration of this assertion's "timeout""# : #"configure this assertion with an explicit "timeout""# let suggestion = """ If this task delivers its action using a clock/scheduler (via "sleep(for:)", \ "timer(interval:)", etc.), make sure that you wait enough time for it to \ perform its work. If you are using a test clock/scheduler, advance the scheduler so that \ the effects may complete, or consider using an immediate clock/scheduler to immediately \ perform the effect instead. If you are not yet using a clock/scheduler, or cannot use a clock/scheduler, \ \(timeoutMessage). """ reportIssue( """ Expected task to finish, but it is still in-flight\ \(nanoseconds > 0 ? " after \(Double(nanoseconds)/Double(NSEC_PER_SEC)) seconds" : ""). \(suggestion) """, fileID: fileID, filePath: filePath, line: line, column: column ) } } /// A Boolean value that indicates whether the task should stop executing. /// /// After the value of this property becomes `true`, it remains `true` indefinitely. There is /// no way to uncancel a task. public var isCancelled: Bool { self.rawValue?.isCancelled ?? true } } class TestReducer: Reducer { let base: Reduce var dependencies: DependencyValues let effectDidSubscribe = AsyncStream.makeStream(of: Void.self) var inFlightEffects: Set = [] var receivedActions: [(action: Action, state: State)] = [] var state: State weak var store: TestStore? init( _ base: Reduce, initialState: State ) { @Dependency(\.self) var dependencies self.base = base self.dependencies = dependencies self.state = initialState } func reduce(into state: inout State, action: TestAction) -> Effect { var dependencies = self.dependencies let dismiss = dependencies.dismiss.dismiss dependencies.dismiss = DismissEffect { [weak store] in store?.withExhaustivity(.off) { dismiss?() store?._skipInFlightEffects(strict: false) store?.isDismissed = true } } let reducer = self.base.dependency(\.self, dependencies) let effects: Effect switch action.origin { case .send(let action): effects = reducer.reduce(into: &state, action: action) self.state = state case .receive(let action): effects = reducer.reduce(into: &state, action: action) self.receivedActions.append((action, state)) } switch effects.operation { case .none: self.effectDidSubscribe.continuation.yield() return .none case .publisher, .run: let effect = LongLivingEffect(action: action) return .publisher { [effectDidSubscribe, weak self] in _EffectPublisher(effects) .handleEvents( receiveSubscription: { _ in self?.inFlightEffects.insert(effect) Task { await Task.megaYield() effectDidSubscribe.continuation.yield() } }, receiveCompletion: { [weak self] _ in self?.inFlightEffects.remove(effect) }, receiveCancel: { [weak self] in self?.inFlightEffects.remove(effect) } ) .map { .init( origin: .receive($0), fileID: action.fileID, filePath: action.filePath, line: action.line, column: action.column ) } } } } struct LongLivingEffect: Hashable { let id = UUID() let action: TestAction static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id } func hash(into hasher: inout Hasher) { self.id.hash(into: &hasher) } } struct TestAction { let origin: Origin let fileID: StaticString let filePath: StaticString let line: UInt let column: UInt fileprivate var action: Action { self.origin.action } enum Origin { case receive(Action) case send(Action) fileprivate var action: Action { switch self { case .receive(let action), .send(let action): return action } } } } } @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) extension Duration { fileprivate var nanoseconds: UInt64 { UInt64(self.components.seconds) * NSEC_PER_SEC + UInt64(self.components.attoseconds) / 1_000_000_000 } } /// The exhaustivity of assertions made by the test store. public enum Exhaustivity: Equatable, Sendable { /// Exhaustive assertions. /// /// This setting requires you to exhaustively assert on all state changes and all actions received /// from effects. Additionally, all in-flight effects _must_ be received before the test store is /// deallocated. /// /// To manually skip actions or effects, use /// ``TestStore/skipReceivedActions(strict:fileID:file:line:column:)`` or /// ``TestStore/skipInFlightEffects(strict:fileID:file:line:column:)``. /// /// To partially match an action received from an effect, use /// ``TestStore/receive(_:timeout:assert:fileID:file:line:column:)-53wic`` or /// ``TestStore/receive(_:timeout:assert:fileID:file:line:column:)-35638``. case on /// Non-exhaustive assertions. /// /// This settings allows you to assert on any subset of state changes and actions received from /// effects. /// /// When configured to `showSkippedAssertions`, any state not asserted on or received actions /// skipped will be reported in a grey informational box next to the assertion. This is handy for /// when you want non-exhaustivity but you still want to know what all you are missing from your /// assertions. /// /// - Parameter showSkippedAssertions: When `true`, skipped assertions will be reported as /// expected failures. case off(showSkippedAssertions: Bool) /// Non-exhaustive assertions. public static let off = Self.off(showSkippedAssertions: false) } extension TestStore { @available( *, unavailable, message: "Provide a key path to the case you expect to receive (like 'store.receive(\\.tap)'), or conform 'Action' to 'Equatable' to assert against it directly." ) public func receive( _ expectedAction: Action, assert updateStateToExpectedResult: ((_ state: inout State) throws -> Void)? = nil, fileID: StaticString = #fileID, file filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) async { fatalError() } } // TODO: Move to `swift-issue-reporting`? private func _withIssueContext( fileID: StaticString, filePath: StaticString, line: UInt, column: UInt, @_inheritActorContext operation: () async throws -> R ) async rethrows -> R { let result = try await withIssueContext( fileID: fileID, filePath: filePath, line: line, column: column, operation: operation ) await Task.yield() return result }