diff --git a/Package.resolved b/Package.resolved index 38aa930..59bd1e3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "cf9e9b2e390026dca11e218c86984cd1350fe349932bb490e0b0ea47ce66b64a", + "originHash" : "3c4f50eb2486121f00c308626fcdd9c864894f1af2662c1319872a67f76ae93c", "pins" : [ { "identity" : "principle", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/NSFatalError/PrincipleMacros", "state" : { - "revision" : "e4b9e83001aa017f649e45e7657dee769eac7eef", - "version" : "1.0.3" + "revision" : "6381001399379bcbe787f20f13ff5a3b9ff3ef7c", + "version" : "1.0.4" } }, { diff --git a/Package.swift b/Package.swift index 2379fc1..196075e 100644 --- a/Package.swift +++ b/Package.swift @@ -81,11 +81,11 @@ let package = Package( dependencies: [ .package( url: "https://github.com/NSFatalError/Principle", - from: "1.0.0" + from: "1.0.3" ), .package( url: "https://github.com/NSFatalError/PrincipleMacros", - from: "1.0.0" + from: "1.0.4" ), .package( url: "https://github.com/swiftlang/swift-syntax", @@ -118,16 +118,11 @@ let package = Package( name: "Algorithms", package: "swift-algorithms" ) - ], - swiftSettings: [ - .enableExperimentalFeature("LifetimeDependence") ] ) ] + macroTargets( name: "Probing", dependencies: [ - "DeeplyCopyable", - "EquatableObject", .product( name: "PrincipleConcurrency", package: "Principle" diff --git a/README.md b/README.md index 50e9619..a4a80c1 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@

Probing

+

Breakpoints for Swift Testing - precise control over side effects and execution suspension at any point.

@@ -10,3 +11,128 @@

+ +--- + +#### Contents +- [What Problem Probing Solves?](#what-problem-probing-solves) +- [How Probing Works?](#how-probing-works) +- [Documentation & Sample Project](#documentation--sample-project) +- [Installation](#installation) + +## What Problem Probing Solves? + +Testing asynchronous code remains challenging, even with Swift Concurrency and Swift Testing. +Some of the persistent difficulties include: + +- **Hidden states**: When invoking methods on objects, often with complex dependencies between them, it’s not enough +to inspect just the final output of the function. Inspecting the internal state changes during execution, such as loading states in view models, +is equally important but notoriously difficult. +- **Non-determinism**: `Task` instances run concurrently and may complete in different orders each time, leading to unpredictable states. +Even with full code coverage, there’s no guarantee that all execution paths have been reached, and it's' difficult to reason about what remains untested. +- **Limited runtime control**: Once an asynchronous function is running, influencing its behavior becomes nearly impossible. +This limitation pushes developers to rely on ahead-of-time setups, like intricate mocks, which add complexity and reduce clarity of the test. + +Over the years, the Swift community has introduced a number of tools to address these challenges, each with its own strengths: + +- **Quick/Nimble**: Polling with the designated matchers allows checking changes to hidden states, +but it can lead to flaky tests and is generally not concurrency-safe. +- **Combine/RxSwift**: Reactive paradigms are powerful, but they can be difficult to set up and may introduce unnecessary abstraction, +especially now that `AsyncSequence` covers many use cases natively. +- **ComposableArchitecture**: Provides a robust approach for testing UI logic, but it’s tightly coupled +to its own architectural patterns and isn’t suited for other application layers. + +These tools have pushed the ecosystem forward and work well within their intended contexts. +Still, none provide a lightweight, general-purpose way to tackle all of the listed problems that embraces the Swift Concurrency model. +That's why I have designed and developed `Probing`. + +## How Probing Works? + +The `Probing` package consists of two main modules: +- `Probing`, which you add as a dependency to the targets you want to test +- `ProbeTesting`, which you add as a dependency to your test targets + +With `Probing`, you can define **probes** - suspension points typically placed after a state change, +conceptually similar to breakpoints, but accessible and targetable from your tests. +You can also define **effects**, which make `Task` instances controllable and predictable. + +Then, with the help of `ProbeTesting`, you write a sequence of **dispatches** that advance your program to a desired state. +This flattens the execution hierarchy of side effects, allowing you to write tests from the user’s perspective, +as a clear and deterministic flow of events: + +```swift +@Test +func testLoading() async throws { + try await withProbing { + await viewModel.load() + } dispatchedBy: { dispatcher in + #expect(viewModel.isLoading == false) + #expect(viewModel.download == nil) + + try await dispatcher.runUpToProbe() + #expect(viewModel.isLoading == true) + #expect(viewModel.download == nil) + + downloaderMock.shouldFailDownload = false + try await dispatcher.runUntilExitOfBody() + #expect(viewModel.isLoading == false) + #expect(viewModel.download != nil) + + #expect(viewModel.prefetchedData == nil) + try await dispatcher.runUntilEffectCompleted("backgroundFetch") + #expect(viewModel.prefetchedData != nil) + } +} +``` + +`ProbeTesting` also includes robust error handling. It provides recovery suggestions for every error it throws, +guiding you toward a solution and making it easier to get started with the API. + +## Documentation & Sample Project + +The public interface of `Probing` and `ProbeTesting` is relatively small, yet powerful. + +You can download the `ProbingPlayground` sample project from its [GitHub page](https://github.com/NSFatalError/ProbingPlayground). + +## Installation + +To use `Probing`, declare it as a dependency in your `Package.swift` or via Xcode project settings. +Add a dependency on `Probing` in the targets you want to test, and `ProbeTesting` in your test targets: + +```swift +let package = Package( + name: "MyPackage", + dependencies: [ + .package( + url: "https://github.com/NSFatalError/Probing", + from: "1.0.0" + ) + ], + targets: [ + .target( + name: "MyModule", + dependencies: [ + .product(name: "Probing", package: "Probing") + ] + ), + .testTarget( + name: "MyModuleTests", + dependencies: [ + "MyModule", + .product(name: "ProbeTesting", package: "Probing") + ] + ) + ] +) +``` + +Supported platforms: +- macOS 15.0 or later +- iOS 18.0 or later +- tvOS 18.0 or later +- watchOS 11.0 or later +- visionOS 2.0 or later + +Other requirements: +- Swift 6.1 or later +- Xcode 16.3 or later diff --git a/Sources/DeeplyCopyable/Documentation.docc/DeeplyCopyable.md b/Sources/DeeplyCopyable/Documentation.docc/DeeplyCopyable.md new file mode 100644 index 0000000..4c0200c --- /dev/null +++ b/Sources/DeeplyCopyable/Documentation.docc/DeeplyCopyable.md @@ -0,0 +1,51 @@ +# ``DeeplyCopyable-module`` + +Create copies of objects without sharing underlying storage, while remaining otherwise value-equal. + +## Overview + +In Swift, we can recognize three groups of `Copyable` types: +- Value types that don't store any reference types (directly or indirectly): + - Basic types, like `Bool`, `Int`, etc. + - Enums without associated values, or with associated values that don't hold any reference types. + - Structs without stored properties, or with stored properties that don't hold any reference types. +- Value types that store reference types (directly or indirectly), optionally implementing copy-on-write mechanisms: + - Many standard collection types, like `String`, `Array`, `Dictionary`, `Set`, etc. + - Enums with associated values that hold reference types. + - Structs with stored properties that hold reference types. +- Reference types: + - Classes + - Actors + +Instances of these types can be copied either explicitly (by assigning them to another variable) or implicitly (by passing them as arguments to functions). +However, only the first group of types supports copying without sharing any underlying storage - in other words, they can be **deeply copied**. + +Conformance to the ``DeeplyCopyable-protocol`` protocol indicates that a type can create a deep copy of itself, even if it stores reference types or is a reference type. +The easiest way to add this functionality is to apply the ``DeeplyCopyable()`` macro to the type’s declaration: + +```swift +@DeeplyCopyable +final class Person { + let name: String + var age: Int + + init(name: String, age: Int) { + self.name = name + self.age = age + } +} + +let person = Person(name: "Kamil", age: 25) +let deepCopy = person.deepCopy() + +person.age += 1 +print(person.age) // 26 +print(deepCopy.age) // 25 +``` + +## Topics + +### Making Deep Copies + +- ``DeeplyCopyable()`` +- ``DeeplyCopyable-protocol`` diff --git a/Sources/DeeplyCopyable/Protocols/DeeplyCopyable.swift b/Sources/DeeplyCopyable/Protocols/DeeplyCopyable.swift index bcae807..2343b98 100644 --- a/Sources/DeeplyCopyable/Protocols/DeeplyCopyable.swift +++ b/Sources/DeeplyCopyable/Protocols/DeeplyCopyable.swift @@ -6,6 +6,12 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // +/// Defines and implements conformance to the ``DeeplyCopyable-protocol`` protocol for final classes, structs and enums. +/// +/// - Important: If any stored property or associated value of the type is not ``DeeplyCopyable-protocol``, the code generated by the macro +/// will fail to compile. To resolve this, either apply the macro directly to the types used as stored properties or associated values, or manually declare +/// their conformance if they come from a third-party module. +/// @attached( member, names: named(init(deeplyCopying:)) @@ -20,13 +26,29 @@ public macro DeeplyCopyable() = #externalMacro( type: "DeeplyCopyableMacro" ) -public protocol DeeplyCopyable { +/// A type whose instances can be copied without sharing underlying storage, while remaining otherwise value-equal. +/// +/// A deep copy of an instance, created via ``init(deeplyCopying:)``, will ultimately not share any direct on indirect references +/// with the original instance. As a result, mutations to one instance will never affect the other. +/// +/// - Note: For types that use copy-on-write mechanism, underlying storage may be shared until the first mutation. +/// This implementation detail does not affect any of the guarantees provided by `DeeplyCopyable` protocol. +/// +public protocol DeeplyCopyable: Copyable { + /// Creates a new instance that is value-equal to `other`, but shares no underlying storage. + /// + /// - Parameter other: The instance to deep copy. + /// init(deeplyCopying other: Self) } extension DeeplyCopyable { + /// Creates a new instance that is value-equal to this instance, but shares no underlying storage. + /// + /// - Returns: A deep copy of this instance. + /// public func deepCopy() -> Self { .init(deeplyCopying: self) } diff --git a/Sources/DeeplyCopyable/Protocols/DeeplyCopyableByAssignment.swift b/Sources/DeeplyCopyable/Protocols/DeeplyCopyableByAssignment.swift index a3dd5b4..870467d 100644 --- a/Sources/DeeplyCopyable/Protocols/DeeplyCopyableByAssignment.swift +++ b/Sources/DeeplyCopyable/Protocols/DeeplyCopyableByAssignment.swift @@ -8,7 +8,7 @@ import Foundation -public protocol DeeplyCopyableByAssignment: DeeplyCopyable {} +internal protocol DeeplyCopyableByAssignment: DeeplyCopyable {} extension DeeplyCopyableByAssignment { @@ -17,57 +17,57 @@ extension DeeplyCopyableByAssignment { } } -extension Int: DeeplyCopyableByAssignment {} -extension Int8: DeeplyCopyableByAssignment {} -extension Int16: DeeplyCopyableByAssignment {} -extension Int32: DeeplyCopyableByAssignment {} -extension Int64: DeeplyCopyableByAssignment {} -extension Int128: DeeplyCopyableByAssignment {} +extension Int: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension Int8: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension Int16: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension Int32: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension Int64: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension Int128: DeeplyCopyable, DeeplyCopyableByAssignment {} -extension UInt: DeeplyCopyableByAssignment {} -extension UInt8: DeeplyCopyableByAssignment {} -extension UInt16: DeeplyCopyableByAssignment {} -extension UInt32: DeeplyCopyableByAssignment {} -extension UInt64: DeeplyCopyableByAssignment {} -extension UInt128: DeeplyCopyableByAssignment {} +extension UInt: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension UInt8: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension UInt16: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension UInt32: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension UInt64: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension UInt128: DeeplyCopyable, DeeplyCopyableByAssignment {} -extension Double: DeeplyCopyableByAssignment {} -extension Float: DeeplyCopyableByAssignment {} -extension Float16: DeeplyCopyableByAssignment {} -extension Decimal: DeeplyCopyableByAssignment {} +extension Double: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension Float: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension Float16: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension Decimal: DeeplyCopyable, DeeplyCopyableByAssignment {} -extension Bool: DeeplyCopyableByAssignment {} -extension Character: DeeplyCopyableByAssignment {} -extension Duration: DeeplyCopyableByAssignment {} -extension UUID: DeeplyCopyableByAssignment {} +extension Bool: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension Character: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension Duration: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension UUID: DeeplyCopyable, DeeplyCopyableByAssignment {} -extension URL: DeeplyCopyableByAssignment {} -extension URLComponents: DeeplyCopyableByAssignment {} -extension URLQueryItem: DeeplyCopyableByAssignment {} +extension URL: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension URLComponents: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension URLQueryItem: DeeplyCopyable, DeeplyCopyableByAssignment {} -extension Date: DeeplyCopyableByAssignment {} -extension DateComponents: DeeplyCopyableByAssignment {} -extension TimeZone: DeeplyCopyableByAssignment {} -extension Calendar: DeeplyCopyableByAssignment {} +extension Date: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension DateComponents: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension TimeZone: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension Calendar: DeeplyCopyable, DeeplyCopyableByAssignment {} -extension Locale: DeeplyCopyableByAssignment {} -extension PersonNameComponents: DeeplyCopyableByAssignment {} +extension Locale: DeeplyCopyable, DeeplyCopyableByAssignment {} +extension PersonNameComponents: DeeplyCopyable, DeeplyCopyableByAssignment {} -extension Data: DeeplyCopyableByAssignment { +extension Data: DeeplyCopyable, DeeplyCopyableByAssignment { public init(deeplyCopying other: Self) { self = other } } -extension String: DeeplyCopyableByAssignment { +extension String: DeeplyCopyable, DeeplyCopyableByAssignment { public init(deeplyCopying other: Self) { self = other } } -extension Substring: DeeplyCopyableByAssignment { +extension Substring: DeeplyCopyable, DeeplyCopyableByAssignment { public init(deeplyCopying other: Self) { self = other diff --git a/Sources/EquatableObject/Documentation.docc/EquatableObject.md b/Sources/EquatableObject/Documentation.docc/EquatableObject.md new file mode 100644 index 0000000..712c874 --- /dev/null +++ b/Sources/EquatableObject/Documentation.docc/EquatableObject.md @@ -0,0 +1,14 @@ +# ``EquatableObject`` + +Automatically generate property-wise `Equatable` conformance for `final` classes using a macro. + +## Overview + +Swift automatically synthesizes `Equatable` conformance for structs and enums, but not for classes. +The ``EquatableObject()`` macro provides this functionality for `final` classes, allowing you to compare instances based on their stored properties. + +## Topics + +### Adding Equatable Conformance + +- ``EquatableObject()`` diff --git a/Sources/EquatableObject/EquatableObject.swift b/Sources/EquatableObject/EquatableObject.swift index 19eeab2..faa4491 100644 --- a/Sources/EquatableObject/EquatableObject.swift +++ b/Sources/EquatableObject/EquatableObject.swift @@ -6,6 +6,10 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // +/// Defines and implements conformance to the `Equatable` protocol for `final` classes. +/// +/// - Important: If any stored property of the class is not `Equatable`, the code generated by the macro will fail to compile. +/// @attached( member, names: named(==) diff --git a/Sources/ProbeTesting/Dispatcher/AsyncSignal.swift b/Sources/ProbeTesting/Dispatcher/AsyncSignal.swift deleted file mode 100644 index 7dce61d..0000000 --- a/Sources/ProbeTesting/Dispatcher/AsyncSignal.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// AsyncSignal.swift -// Probing -// -// Created by Kamil Strzelecki on 05/05/2025. -// Copyright © 2025 Kamil Strzelecki. All rights reserved. -// - -internal struct AsyncSignal { - - private typealias Underlying = AsyncStream - - private let stream: Underlying - private let continuation: Underlying.Continuation - - init() { - let (stream, continuation) = Underlying.makeStream() - self.stream = stream - self.continuation = continuation - } - - func wait() async { - // swiftlint:disable:next no_empty_block - for await _ in stream {} - } - - func finish() { - continuation.finish() - } -} diff --git a/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift b/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift index 5fd77a9..6119c4d 100644 --- a/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift +++ b/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift @@ -9,6 +9,20 @@ import Probing import Testing +/// Object that controls execution of the `body` during a test. +/// +/// You don't create instances of `ProbingDispatcher` directly. +/// Instead, an instance is provided to you within the `test` closure of ``withProbing(options:sourceLocation:isolation:of:dispatchedBy:)``. +/// +/// `ProbingDispatcher` always performs the minimal necessary work to drive execution toward a desired state. +/// After calling one of its methods, it eagerly suspends the `body` and any effects it created by its invocation at two points: +/// - Explicitly, at declared `#probe()` macros within the `body` and nested effects +/// - Implicitly, immediately after initializing effects with `#Effect` macros, preventing them from starting until required +/// +/// This ensures that no part of the tested code runs concurrently with the expectations defined in the `test`. +/// +/// - SeeAlso: For details on how probe and effect identifiers are constructed, see the `Probing` documentation. +/// public struct ProbingDispatcher: ~Escapable, Sendable { private let coordinator: ProbingCoordinator @@ -23,62 +37,21 @@ public struct ProbingDispatcher: ~Escapable, Sendable { extension ProbingDispatcher { - public typealias Injection = @Sendable () async throws -> sending R - - private func withIssueRecording( + private func withIssueRecording( at sourceLocation: SourceLocation, isolation: isolated (any Actor)?, - perform dispatch: @escaping (() -> Void) async throws -> Void, - after injection: @escaping Injection - ) async throws -> sending R { - // https://github.com/swiftlang/swift/issues/77301 - // When `isolation` is nil `injection` must be `@Sendable` - - let signal = AsyncSignal() - var result: R? - - let injectionTask = Task { + perform dispatch: () async throws -> Void + ) async throws { + do { _ = isolation - await signal.wait() - await Task.yield() - - guard !Task.isCancelled else { - return - } - - result = try await injection() - } - - let dispatchTask = Task { - do { - _ = isolation - try await dispatch { - signal.finish() - } - } catch { - injectionTask.cancel() - signal.finish() - throw RecordedError( - underlying: error, - sourceLocation: sourceLocation - ) - } - } - - defer { - injectionTask.cancel() - dispatchTask.cancel() + try await dispatch() + } catch let error as any RecordableProbingError { + throw RecordedError( + underlying: error, + sourceLocation: sourceLocation + ) } - - try await injectionTask.value - try await dispatchTask.value try Task.checkCancellation() - - guard let result else { - preconditionFailure("Injection task did not produce any result.") - } - - return result } private func withIssueRecording( @@ -87,7 +60,7 @@ extension ProbingDispatcher { ) rethrows -> R { do { return try block() - } catch { + } catch let error as any RecordableProbingError { throw RecordedError( underlying: error, sourceLocation: sourceLocation @@ -98,117 +71,314 @@ extension ProbingDispatcher { extension ProbingDispatcher { - // swiftlint:disable no_empty_block - - public func runUpToProbe( + /// Resumes execution of `body`, performing the minimal necessary work to install the specified probe, and suspends `body` again before returning. + /// + /// - Parameter id: Identifier of the probe, which is guaranteed to be installed when this function returns. + /// + /// If any effect along the `id.effect.path` has not yet been created, this function resumes its closest ancestor until the required effect is initialized, + /// suspending that ancestor at the next available probe. Once the parent effect (`id.effect.path.last`) is created , it is resumed and suspended at the first probe matching `id.name`. + /// + /// - Throws: If the probe is unreachable, fails to install, or if API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions. + /// + /// ```swift + /// try await withProbing { + /// await #probe("1") // id: "1" + /// print("1") + /// #Effect("first") { // <- SUSPENDED + /// print("Not called until dispatch is given.") + /// } + /// #Effect("second") { + /// await #probe("1") // id: "second.1" + /// print("second.1") + /// await #probe("2") // id: "second.2" <- SUSPENDED + /// print("Not called until dispatch is given.") + /// } + /// await #probe("2") // id: "2" <- SUSPENDED + /// print("Not called until dispatch is given.") + /// } dispatchedBy: { dispatcher in + /// try await dispatcher.runUpToProbe("second.2") + /// // Always prints: + /// // 1 + /// // second.1 + /// } + /// ``` + /// + /// - Tip: Conceptually, this algorithm resembles [breadth-first search](https://en.wikipedia.org/wiki/Breadth-first_search), + /// where effects form the nodes and probes are the leaves of the execution tree. + /// + public func runUpToProbe( _ id: ProbeIdentifier, sourceLocation: SourceLocation = #_sourceLocation, - isolation: isolated (any Actor)? = #isolation, - @_inheritActorContext @_implicitSelfCapture after injection: @escaping Injection = {} - ) async throws -> sending R { + isolation: isolated (any Actor)? = #isolation + ) async throws { try await withIssueRecording( at: sourceLocation, isolation: isolation, - perform: { [coordinator] startOperation in + perform: { try await coordinator.runUntilProbeInstalled( withID: id, - isolation: isolation, - after: startOperation + isolation: isolation ) - }, - after: injection + } ) } - public func runUpToProbe( + /// Resumes execution of `body`, performing the minimal necessary work to install a default probe in the specified effect, and suspends `body` again before returning. + /// + /// - Parameter effectID: Identifier of the effect in which a default probe is guaranteed to be installed when this function returns. + /// + /// - Attention: A **default probe** is a probe created via `#probe()` macro without specifying a name, or via `#probe(.default)`. + /// + /// - Note: This function is equivalent to calling ``runUpToProbe(_:sourceLocation:isolation:)`` with `ProbeIdentifier(effect: effectID, name: .default)` + /// + /// If any effect along the `effectID.path` has not yet been created, this function resumes its closest ancestor until the required effect is initialized, + /// suspending that ancestor at the next available probe. Once the parent effect (`effectID.path.last`) is created , it is resumed and suspended at the first probe with `.default` name. + /// + /// - Throws: If the probe is unreachable, fails to install, or if API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions. + /// + /// ```swift + /// try await withProbing { + /// await #probe() // id: "probe" + /// print("probe") + /// #Effect("first") { // <- SUSPENDED + /// print("Not called until dispatch is given.") + /// } + /// #Effect("second") { + /// await #probe("1") // id: "second.1" + /// print("second.1") + /// await #probe() // id: "second.probe" <- SUSPENDED + /// print("Not called until dispatch is given.") + /// } + /// await #probe() // id: "probe" <- SUSPENDED + /// print("Not called until dispatch is given.") + /// } dispatchedBy: { dispatcher in + /// try await dispatcher.runUpToProbe(inEffect: "second") + /// // Always prints: + /// // probe + /// // second.1 + /// } + /// ``` + /// + /// - Tip: Conceptually, this algorithm resembles [breadth-first search](https://en.wikipedia.org/wiki/Breadth-first_search), + /// where effects form the nodes and probes are the leaves of the execution tree. + /// + public func runUpToProbe( inEffect effectID: EffectIdentifier, sourceLocation: SourceLocation = #_sourceLocation, - isolation: isolated (any Actor)? = #isolation, - @_inheritActorContext @_implicitSelfCapture after injection: @escaping Injection = {} - ) async throws -> sending R { + isolation: isolated (any Actor)? = #isolation + ) async throws { try await runUpToProbe( .init(effect: effectID, name: .default), sourceLocation: sourceLocation, - isolation: isolation, - after: injection + isolation: isolation ) } - public func runUpToProbe( + /// Resumes execution of `body`, performing the minimal necessary work to install a default probe that is not nested in any effect, and suspends `body` again before returning. + /// + /// - Attention: A **default probe** is a probe created via `#probe()` macro without specifying a name, or via `#probe(.default)`. + /// + /// - Note: This function is equivalent to calling ``runUpToProbe(_:sourceLocation:isolation:)`` with `ProbeIdentifier(effect: .root, name: .default)` + /// + /// - Throws: If the probe is unreachable, fails to install, or if API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions. + /// + /// ```swift + /// try await withProbing { + /// await #probe("1") // id: "1" + /// print("1") + /// #Effect("first") { // <- SUSPENDED + /// print("Not called until dispatch is given.") + /// } + /// #Effect("second") { // <- SUSPENDED + /// await #probe() // id: "second.probe" + /// print("Not called until dispatch is given.") + /// await #probe() // id: "second.probe" + /// print("Not called until dispatch is given.") + /// } + /// await #probe() // id: "probe" <- SUSPENDED + /// print("Not called until dispatch is given.") + /// } dispatchedBy: { dispatcher in + /// try await dispatcher.runUpToProbe() + /// // Always prints: + /// // 1 + /// } + /// ``` + /// + public func runUpToProbe( sourceLocation: SourceLocation = #_sourceLocation, - isolation: isolated (any Actor)? = #isolation, - @_inheritActorContext @_implicitSelfCapture after injection: @escaping Injection = {} - ) async throws -> sending R { + isolation: isolated (any Actor)? = #isolation + ) async throws { try await runUpToProbe( inEffect: .root, sourceLocation: sourceLocation, - isolation: isolation, - after: injection + isolation: isolation ) } - - // swiftlint:enable no_empty_block } extension ProbingDispatcher { - // swiftlint:disable no_empty_block - - public func runUntilEffectCompleted( + /// Resumes execution of `body`, performing the minimal necessary work to complete the specified effect, and suspends `body` again before returning. + /// + /// - Parameters: + /// - id: Identifier of the effect, which is guaranteed to be completed when this function returns. + /// - includeDescendants: If `true`, all descendants of the specified effect will also be completed. + /// Defaults to `false`, meaning they will remain suspended in their current state. + /// + /// If any effect along the `id.path` has not yet been created, this function resumes its closest ancestor until the required effect is initialized, + /// suspending that ancestor at the next available probe. Once the specified effect (`id.path.last`) is created , it is resumed and run until completion. + /// + /// - Throws: If the effect is unreachable, fails to be created, or if API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions. + /// + /// ```swift + /// try await withProbing { + /// await #probe("1") // id: "1" + /// print("1") + /// #Effect("first") { // <- SUSPENDED + /// print("Not called until dispatch is given.") + /// } + /// #Effect("second") { + /// await #probe("1") // id: "second.1" + /// print("second.1") + /// await #probe("2") // id: "second.2" + /// print("second.2") // <- COMPLETED + /// } + /// await #probe("2") // id: "2" <- SUSPENDED + /// print("Not called until dispatch is given.") + /// } dispatchedBy: { dispatcher in + /// try await dispatcher.runUntilEffectCompleted("second.2") + /// // Always prints: + /// // 1 + /// // second.1 + /// // second.2 + /// } + /// ``` + /// + /// - Tip: Conceptually, this algorithm resembles [breadth-first search](https://en.wikipedia.org/wiki/Breadth-first_search), + /// where effects form the nodes of the execution tree. + /// + public func runUntilEffectCompleted( _ id: EffectIdentifier, includingDescendants includeDescendants: Bool = false, sourceLocation: SourceLocation = #_sourceLocation, - isolation: isolated (any Actor)? = #isolation, - @_inheritActorContext @_implicitSelfCapture after injection: @escaping Injection = {} - ) async throws -> sending R { + isolation: isolated (any Actor)? = #isolation + ) async throws { try await withIssueRecording( at: sourceLocation, isolation: isolation, - perform: { [coordinator] startOperation in + perform: { try await coordinator.runUntilEffectCompleted( withID: id, includingDescendants: includeDescendants, - isolation: isolation, - after: startOperation + isolation: isolation ) - }, - after: injection + } ) } - public func runUntilEverythingCompleted( + /// Resumes execution of `body`, completing all remaining work within it, as well as all effects and their descendants, before returning. + /// + /// - Note: This function is equivalent to calling ``runUntilEffectCompleted(_:includingDescendants:sourceLocation:isolation:)`` + /// with `EffectIdentifier.root` and `includeDescendants` set to `true`. + /// + /// - Throws: If API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions. + /// + /// ```swift + /// try await withProbing { + /// await #probe("1") // id: "1" + /// print("1") + /// #Effect("first") { + /// print("first") // <- COMPLETED + /// } + /// #Effect("second") { + /// await #probe("1") // id: "second.1" + /// print("second.1") + /// await #probe("2") // id: "second.2" + /// print("second.2") // <- COMPLETED + /// } + /// await #probe("2") // id: "2" + /// print("2") // <- COMPLETED + /// } dispatchedBy: { dispatcher in + /// try await dispatcher.runUntilEverythingCompleted() + /// // Always prints: + /// // 1 + /// // 2 + /// // first + /// // second.1 + /// // second.2 + /// // Note: Exact order of prints may vary, as effects may execute concurrently. + /// } + /// ``` + /// + public func runUntilEverythingCompleted( sourceLocation: SourceLocation = #_sourceLocation, - isolation: isolated (any Actor)? = #isolation, - @_inheritActorContext @_implicitSelfCapture after injection: @escaping Injection = {} - ) async throws -> sending R { + isolation: isolated (any Actor)? = #isolation + ) async throws { try await runUntilEffectCompleted( .root, includingDescendants: true, sourceLocation: sourceLocation, - isolation: isolation, - after: injection + isolation: isolation ) } - public func runUntilExitOfBody( + /// Resumes execution of `body`, completing all remaining work within it, while leaving effects suspended in their current state, before returning. + /// + /// - Note: This function is equivalent to calling ``runUntilEffectCompleted(_:includingDescendants:sourceLocation:isolation:)`` + /// with `EffectIdentifier.root` and `includeDescendants` set to `false`. + /// + /// - Throws: If API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions. + /// + /// ```swift + /// try await withProbing { + /// await #probe("1") // id: "1" + /// print("1") + /// #Effect("first") { // <- SUSPENDED + /// print("Not called until dispatch is given.") + /// } + /// #Effect("second") { // <- SUSPENDED + /// await #probe("1") // id: "second.1" + /// print("Not called until dispatch is given.") + /// await #probe("2") // id: "second.2" + /// print("Not called until dispatch is given.") + /// } + /// await #probe("2") // id: "2" + /// print("2") // <- COMPLETED + /// } dispatchedBy: { dispatcher in + /// try await dispatcher.runUntilExitOfBody() + /// // Always prints: + /// // 1 + /// // 2 + /// } + /// ``` + /// + public func runUntilExitOfBody( sourceLocation: SourceLocation = #_sourceLocation, - isolation: isolated (any Actor)? = #isolation, - @_inheritActorContext @_implicitSelfCapture after injection: @escaping Injection = {} - ) async throws -> sending R { + isolation: isolated (any Actor)? = #isolation + ) async throws { try await runUntilEffectCompleted( .root, includingDescendants: false, sourceLocation: sourceLocation, - isolation: isolation, - after: injection + isolation: isolation ) } - - // swiftlint:enable no_empty_block } extension ProbingDispatcher { + /// Retrieves the return value of the specified effect, ensuring it has completed successfully. + /// + /// - Parameters: + /// - id: Identifier of the effect, which is expected to have completed successfully. + /// - successType: The expected type of the value returned by the effect. + /// + /// - Returns: The return value of the effect, if it completed successfully. + /// + /// - Important: This method does not resume the execution of `body`. You must ensure the effect has finished running through prior dispatches. + /// + /// - Throws: If the effect has not been completed successfully yet, was cancelled, or if API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions. + /// public func getValue( fromEffect id: EffectIdentifier, as successType: Success.Type = Success.self, @@ -223,6 +393,18 @@ extension ProbingDispatcher { } } + /// Retrieves the return value of the specified effect, ensuring it has been cancelled. + /// + /// - Parameters: + /// - id: Identifier of the effect, which is expected to have been cancelled. + /// - successType: The expected type of the value returned by the effect. + /// + /// - Returns: The return value of the effect, if it was cancelled. + /// + /// - Important: This method does not resume the execution of `body`. You must ensure the effect has finished running through prior dispatches. + /// + /// - Throws: If the effect has not been cancelled yet, was completed successfully, or if API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions. + /// public func getCancelledValue( fromEffect id: EffectIdentifier, as successType: Success.Type = Success.self, diff --git a/Sources/ProbeTesting/Dispatcher/RecordedError.swift b/Sources/ProbeTesting/Dispatcher/RecordedError.swift index 33636ee..939d730 100644 --- a/Sources/ProbeTesting/Dispatcher/RecordedError.swift +++ b/Sources/ProbeTesting/Dispatcher/RecordedError.swift @@ -11,19 +11,16 @@ import Testing internal struct RecordedError: Error { - let underlying: any Error + let underlying: any RecordableProbingError let sourceLocation: SourceLocation init( - underlying: some Error, + underlying: some RecordableProbingError, sourceLocation: SourceLocation ) { self.underlying = underlying self.sourceLocation = sourceLocation - - if underlying is any RecordableProbingError { - Issue.record(self, sourceLocation: sourceLocation) - } + Issue.record(self, sourceLocation: sourceLocation) } } diff --git a/Sources/ProbeTesting/Documentation.docc/Examples.md b/Sources/ProbeTesting/Documentation.docc/Examples.md new file mode 100644 index 0000000..f18d4b5 --- /dev/null +++ b/Sources/ProbeTesting/Documentation.docc/Examples.md @@ -0,0 +1,211 @@ +# Examples + +Explore simplified examples from the `ProbingPlayground` sample project to see `ProbeTesting` integration in action. + +## Download the Sample Project + +You can download the `ProbingPlayground` sample project from its [GitHub page](https://github.com/NSFatalError/ProbingPlayground). + +## Running Through Probes in Async Functions + +To verify that an object has changed state while an `async` function is running, +you can suspend the execution of `body` at declared probes using the ``ProbingDispatcher/runUpToProbe(sourceLocation:isolation:)`` +method. You don't need to declare a probe at the end of the `body`, since the +``ProbingDispatcher/runUntilExitOfBody(sourceLocation:isolation:)`` method suspends execution there for you: + +```swift +@Test +func testUploadingImage() async throws { + try await withProbing { + await viewModel.uploadImage(ImageMock()) + } dispatchedBy: { dispatcher in + #expect(viewModel.uploadState == nil) + + try await dispatcher.runUpToProbe() + #expect(uploader.uploadImageCallsCount == 0) + #expect(viewModel.uploadState == .uploading) + + try await dispatcher.runUpToProbe() + #expect(uploader.uploadImageCallsCount == 1) + #expect(viewModel.uploadState == .success) + + try await dispatcher.runUntilExitOfBody() + #expect(viewModel.uploadState == nil) + } +} +``` + +This lets you reliably verify the `uploadState`, reproducing the exact sequence of events users experience in your app. + +## Interacting with Mocks During Tests + +When testing asynchronous functions or side effects, you often have to set up mocks far in advance. +This can be problematic, as it decouples the setup from the actual invocation, obscuring its purpose. + +With `ProbeTesting`, you can control your objects directly in the `test` closure, +immediately before running a dispatch that triggers their usage. For example, you can yield elements to an `AsyncSequence` +or finish it at precise moments: + +```swift +@Test +func testUpdatingLocation() async throws { + try await withProbing { + await viewModel.beginUpdatingLocation() + } dispatchedBy: { dispatcher in + #expect(viewModel.locationState == nil) + + locationProvider.continuation.yield(.init(location: .sanFrancisco)) + try await dispatcher.runUpToProbe() + #expect(viewModel.locationState == .near) + + locationProvider.continuation.yield(.init(location: .init(latitude: 0, longitude: 0))) + try await dispatcher.runUpToProbe() + #expect(viewModel.locationState == .far) + + locationProvider.continuation.yield(.init(location: .sanFrancisco)) + try await dispatcher.runUpToProbe() + #expect(viewModel.locationState == .near) + + locationProvider.continuation.yield(.init(location: nil, authorizationDenied: true)) + try await dispatcher.runUpToProbe() + #expect(viewModel.locationState == .error) + + locationProvider.continuation.yield(.init(location: .sanFrancisco)) + try await dispatcher.runUpToProbe() + #expect(viewModel.locationState == .near) + + locationProvider.continuation.finish(throwing: ErrorMock()) + try await dispatcher.runUntilExitOfBody() + #expect(viewModel.locationState == .error) + } +} +``` + +This allows you to reliably verify the `locationState` before entering the loop, after each iteration, +or after an error is thrown. + +## Controlling Execution of Effects + +To control the execution of effects, you have several options, starting with the +``ProbingDispatcher/runUntilEffectCompleted(_:includingDescendants:sourceLocation:isolation:)`` method. + +You can also retrieve the value returned by an effect using ``ProbingDispatcher/getValue(fromEffect:as:sourceLocation:)``, +or check if it was cancelled using ``ProbingDispatcher/getCancelledValue(fromEffect:as:sourceLocation:)``. + +None of these methods require you to store references to effects. `ProbeTesting` can identify and access them during testing: + +```swift +@Test +func testDownloadingImage() async throws { + try await withProbing { + await viewModel.downloadImage() + } dispatchedBy: { dispatcher in + await #expect(viewModel.downloadState == nil) + + try await dispatcher.runUntilExitOfBody() + #expect(viewModel.downloadState?.isDownloading == true) + + try await dispatcher.runUntilEffectCompleted("low") + #expect(viewModel.downloadState?.quality == .low) + + try await dispatcher.runUntilEffectCompleted("high") + #expect(viewModel.downloadState?.quality == .high) + } +} +``` + +This enables you to reliably verify the `downloadState` before downloads start, +as well as verify its progression as effects complete. + +## Recreating Runtime Scenarios + +Even with full test coverage reported by Xcode, you can’t be certain that all execution paths have been exercised. +Each await introduces a suspension point where another side effect - isolated to the same actor - might interleave, +altering the sequence of events. + +By flattening the execution tree into a series of dispatches, `ProbeTesting` allows you to recreate specific runtime scenarios +that users might experience. Previously, this level of control and expressivity was nearly impossible to achieve in tests: + +```swift +@Test +func testDownloadingImageWhenLowQualityDownloadFailsFirst() async throws { + try await withProbing { + viewModel.downloadImage() + } dispatchedBy: { dispatcher in + #expect(viewModel.downloadState == nil) + + try await dispatcher.runUntilExitOfBody() + #expect(viewModel.downloadState?.isDownloading == true) + + downloader.shouldFailDownload = true + try await dispatcher.runUntilEffectCompleted("low") + #expect(viewModel.downloadState?.isDownloading == true) + + downloader.shouldFailDownload = false + try await dispatcher.runUntilEffectCompleted("high") + #expect(viewModel.downloadState?.quality == .high) + } +} + +@Test +func testDownloadingImageWhenHighQualityDownloadFailsFirst() async throws { + // ... +} + +@Test +func testDownloadingImageWhenLowQualityDownloadFailsAfterHighQualityDownloadSucceeds() async throws { + try await withProbing { + viewModel.downloadImage() + } dispatchedBy: { dispatcher in + #expect(viewModel.downloadState == nil) + + try await dispatcher.runUntilExitOfBody() + #expect(viewModel.downloadState?.isDownloading == true) + + try await dispatcher.runUntilEffectCompleted("high") + #expect(viewModel.downloadState?.quality == .high) + + downloader.shouldFailDownload = true + try await dispatcher.runUntilEffectCompleted("low") + try dispatcher.getCancelledValue(fromEffect: "low", as: Void.self) + #expect(viewModel.downloadState?.quality == .high) + } +} + +@Test +func testDownloadingImageWhenHighQualityDownloadFailsAfterLowQualityDownloadSucceeds() async throws { + // ... +} + +@Test +func testDownloadingImageRepeatedly() async throws { + try await withProbing { + viewModel.downloadImage() + viewModel.downloadImage() + } dispatchedBy: { dispatcher in + #expect(viewModel.downloadState == nil) + + try await dispatcher.runUntilExitOfBody() + #expect(viewModel.downloadState?.isDownloading == true) + + try await dispatcher.runUntilEffectCompleted("low0") + try dispatcher.getCancelledValue(fromEffect: "low0", as: Void.self) + #expect(viewModel.downloadState?.isDownloading == true) + + try await dispatcher.runUntilEffectCompleted("high0") + try dispatcher.getCancelledValue(fromEffect: "high0", as: Void.self) + #expect(viewModel.downloadState?.isDownloading == true) + + try await dispatcher.runUntilEffectCompleted("low1") + #expect(viewModel.downloadState?.quality == .low) + + try await dispatcher.runUntilEffectCompleted("high1") + #expect(viewModel.downloadState?.quality == .high) + } +} + +// ... +``` + +It’s up to you to decide how much granularity your tests require, yet with `ProbeTesting` +you have full control over execution flow. diff --git a/Sources/ProbeTesting/Documentation.docc/ProbeTesting.md b/Sources/ProbeTesting/Documentation.docc/ProbeTesting.md new file mode 100644 index 0000000..fd2fcef --- /dev/null +++ b/Sources/ProbeTesting/Documentation.docc/ProbeTesting.md @@ -0,0 +1,53 @@ +# ``ProbeTesting`` + +Control the execution of asynchronous code during tests using a sequence of dispatches. + +## Overview + +To use `ProbeTesting`, you first need to prepare your codebase using the `Probing` library. + +- SeeAlso: Refer to the `Probing` documentation for details on how to make your code controllable during tests. + +`ProbeTesting` gives you precise control over the execution of asynchronous code, +making it fully predictable and testable. It also lets you reason about the flow +from the user’s perspective by reducing it to a clear, step-by-step sequence of dispatches. + +To use these features, wrap your test code with the +``withProbing(options:sourceLocation:isolation:of:dispatchedBy:)`` function: + +```swift +@Test +func testLoading() async throws { + try await withProbing { + await viewModel.load() + } dispatchedBy: { dispatcher in + #expect(viewModel.isLoading == false) + #expect(viewModel.download == nil) + + try await dispatcher.runUpToProbe() + #expect(viewModel.isLoading == true) + #expect(viewModel.download == nil) + + downloaderMock.shouldFailDownload = false + try await dispatcher.runUntilExitOfBody() + #expect(viewModel.isLoading == false) + #expect(viewModel.download != nil) + + #expect(viewModel.prefetchedData == nil) + try await dispatcher.runUntilEffectCompleted("backgroundFetch") + #expect(viewModel.prefetchedData != nil) + } +} +``` + +## Topics + +### Getting Started + +- + +### Enabling Probing In Tests + +- ``withProbing(options:sourceLocation:isolation:of:dispatchedBy:)`` +- ``ProbingDispatcher`` +- ``ProbingOptions`` diff --git a/Sources/ProbeTesting/Documentation.docc/ProbingDispatcher.md b/Sources/ProbeTesting/Documentation.docc/ProbingDispatcher.md new file mode 100644 index 0000000..32dc74a --- /dev/null +++ b/Sources/ProbeTesting/Documentation.docc/ProbingDispatcher.md @@ -0,0 +1,23 @@ +# ``ProbeTesting/ProbingDispatcher`` + +## Topics + +### Advancing Execution Through Probes + +- ``ProbingDispatcher/runUpToProbe(sourceLocation:isolation:)`` +- ``ProbingDispatcher/runUpToProbe(inEffect:sourceLocation:isolation:)`` +- ``ProbingDispatcher/runUpToProbe(_:sourceLocation:isolation:)`` + +### Awaiting Probed Body Completion + +- ``ProbingDispatcher/runUntilExitOfBody(sourceLocation:isolation:)`` + +### Awaiting Effects Completion + +- ``ProbingDispatcher/runUntilEffectCompleted(_:includingDescendants:sourceLocation:isolation:)`` +- ``ProbingDispatcher/runUntilEverythingCompleted(sourceLocation:isolation:)`` + +### Retrieving Effects Return Values + +- ``ProbingDispatcher/getValue(fromEffect:as:sourceLocation:)`` +- ``ProbingDispatcher/getCancelledValue(fromEffect:as:sourceLocation:)`` diff --git a/Sources/ProbeTesting/Documentation.docc/ProbingOptions.md b/Sources/ProbeTesting/Documentation.docc/ProbingOptions.md new file mode 100644 index 0000000..53006b1 --- /dev/null +++ b/Sources/ProbeTesting/Documentation.docc/ProbingOptions.md @@ -0,0 +1,13 @@ +# ``ProbeTesting/ProbingOptions`` + +## Topics + +### Available Options + +- ``ignoreProbingInTasks`` +- ``attemptProbingInTasks`` + +### OptionSet Implementations + +- ``rawValue`` +- ``init(rawValue:)`` diff --git a/Sources/ProbeTesting/WithProbing/ProbingOptions.swift b/Sources/ProbeTesting/WithProbing/ProbingOptions.swift index 4e5832a..451642f 100644 --- a/Sources/ProbeTesting/WithProbing/ProbingOptions.swift +++ b/Sources/ProbeTesting/WithProbing/ProbingOptions.swift @@ -8,6 +8,10 @@ import Probing +/// Options that change the testability of probes and effects created from `Task` APIs. +/// +/// - SeeAlso: Refer to `Probing` documentation to learn more on interactions with Swift Concurrency. +/// public struct ProbingOptions: OptionSet, Sendable { public let rawValue: Int @@ -30,6 +34,28 @@ extension ProbingOptions { extension ProbingOptions { + /// Treats probes and effects created from `Task` APIs as if they were invoked + /// from the top effect on the execution stack. + /// + /// - Warning: Using this option may lead to reporting of API misuses. + /// + /// When using this option, you must ensure that no part of your code executes concurrently with the `test` closure + /// of the ``withProbing(options:sourceLocation:isolation:of:dispatchedBy:)`` function. + /// + /// In the simplest case, this means you would need to serialize calls to `Task` APIs and `await` each of them sequentially. + /// + /// - SeeAlso: Refer to `Probing` documentation to learn more on interactions with Swift Concurrency. + /// public static let attemptProbingInTasks = Self(.attemptProbingInTasks) + + /// Ignores probes and effects created from `Task` APIs. + /// + /// - Note: This is the default option and it's recommended in most cases, as it avoids reporting API misuses. + /// + /// When this option is enabled, probes created within `Task` APIs resume immediately, + /// and effects run as standard Swift `Task` instances, without any custom scheduling. + /// + /// - SeeAlso: Refer to `Probing` documentation to learn more on interactions with Swift Concurrency. + /// public static let ignoreProbingInTasks = Self(.ignoreProbingInTasks) } diff --git a/Sources/ProbeTesting/WithProbing/ProbingTerminatedError.swift b/Sources/ProbeTesting/WithProbing/ProbingTerminatedError.swift new file mode 100644 index 0000000..3e68594 --- /dev/null +++ b/Sources/ProbeTesting/WithProbing/ProbingTerminatedError.swift @@ -0,0 +1,17 @@ +// +// ProbingTerminatedError.swift +// Probing +// +// Created by Kamil Strzelecki on 11/05/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +internal struct ProbingTerminatedError: Error, CustomStringConvertible { + + var description: String { + """ + Probing was terminated because one of the dispatches couldn't be fulfilled. \ + See the issue reported in the `test` closure for more details. + """ + } +} diff --git a/Sources/ProbeTesting/WithProbing/WithProbing.swift b/Sources/ProbeTesting/WithProbing/WithProbing.swift index eb37043..e2761ef 100644 --- a/Sources/ProbeTesting/WithProbing/WithProbing.swift +++ b/Sources/ProbeTesting/WithProbing/WithProbing.swift @@ -9,6 +9,63 @@ import Probing import Testing +/// Enables control over probes and effects, making asynchronous code testable. +/// +/// - Parameters: +/// - options: Controls testability of probes and effects created from `Task` APIs. Defaults to ``ProbingOptions/ignoreProbingInTasks``. +/// - body: A closure containing the code under test. Probes and effects invoked from this closure become controllable in `test`. +/// - test: A closure used to control the execution of `body` via ``ProbingDispatcher``, verify expectations, and interact with the system under test. +/// +/// - Throws: Rethrows any error thrown by either the `body` or `test` closure. +/// +/// - Returns: The result returned by the `body` closure. +/// +/// This function always begins by invoking `test`, suspending execution of `body` until the first dispatch is given. +/// Before returning, it awaits the completion of both `body` and `test`. +/// +/// ```swift +/// try await withProbing { +/// print("Body") +/// } dispatchedBy: { dispatcher in +/// // Noting is printed yet at this point +/// print("Test") +/// } +/// // Always prints: +/// // Test +/// // Body +/// ``` +/// +/// - Note: This does **not** guarantee that all effects created by the `body` invocation are completed when `withProbing` returns. +/// To ensure this, call the ``ProbingDispatcher/runUntilEverythingCompleted(sourceLocation:isolation:)`` method +/// on the dispatcher at the appropriate point within `test`. +/// +/// `ProbeTesting` guarantees that as long as your code uses `#Effect` macros instead of the `Task` APIs, +/// no part of code initiated by the `body` invocation will run concurrently with `test`. This is enforced by the ``ProbingDispatcher``, +/// which lets you deterministically advance execution to the desired state. This model allows you to reliably check expectations +/// and interact with the system under test. For example: +/// +/// ```swift +/// try await withProbing { +/// await viewModel.load() +/// } dispatchedBy: { dispatcher in +/// #expect(viewModel.isLoading == false) +/// #expect(viewModel.download == nil) +/// +/// try await dispatcher.runUpToProbe() +/// #expect(viewModel.isLoading == true) +/// #expect(viewModel.download == nil) +/// +/// downloaderMock.shouldFailDownload = false +/// try await dispatcher.runUntilExitOfBody() +/// #expect(viewModel.isLoading == false) +/// #expect(viewModel.download != nil) +/// +/// #expect(viewModel.prefetchedData == nil) +/// try await dispatcher.runUntilEffectCompleted("backgroundFetch") +/// #expect(viewModel.prefetchedData != nil) +/// } +/// ``` +/// public func withProbing( options: ProbingOptions = .ignoreProbingInTasks, sourceLocation: SourceLocation = #_sourceLocation, @@ -53,14 +110,19 @@ public func withProbing( do { try await testTask.value - try await bodyTask.value } catch { if testTask.isCancelled { try await bodyTask.value + } else { + try? await bodyTask.value + } + if error is RecordedError { + throw ProbingTerminatedError() } throw error } + try await bodyTask.value guard let result else { preconditionFailure("Body task did not produce any result.") } @@ -89,7 +151,7 @@ private func makeTestTask( do { try coordinator.didCompleteTest() - } catch { + } catch let error as any RecordableProbingError { throw RecordedError( underlying: error, sourceLocation: sourceLocation @@ -99,7 +161,7 @@ private func makeTestTask( } private func runRootEffect( - using body: @escaping () async throws -> sending R, + using body: () async throws -> sending R, testTask: Task, coordinator: ProbingCoordinator, isolation: isolated (any Actor)? diff --git a/Sources/Probing/Documentation.docc/Concurrency.md b/Sources/Probing/Documentation.docc/Concurrency.md new file mode 100644 index 0000000..d9d3ba7 --- /dev/null +++ b/Sources/Probing/Documentation.docc/Concurrency.md @@ -0,0 +1,124 @@ +# Interactions with Swift Concurrency + +Learn how `Probing` interacts with Swift’s `Task` APIs and what limitations currently exist. + +## Installing Probes and Creating Effects from Tasks + +`Task.init`, `TaskGroup.addTask`, and `async let` declarations all start executing concurrent work immediately, +while unblocking the caller. `Probing` has no control over asynchronous tasks created through these APIs, +nor any visibility into their potential influence on your application’s state. + +Because of this, `ProbeTesting` cannot guarantee deterministic execution when such APIs are used. +By default, it treats probes and effects created from these APIs as if they were run outside the test scope. +This means: +- Probes resume immediately, without suspension. +- Effects behave like regular Swift `Task` instances and are not subjected to any scheduling. + +Nesting probes or effects within tasks does not make them testable again. For example: + +```swift +func example() async { + await #probe() + + #Effect("first") { + await #probe() + // All probes and effects are testable up to this point. + // State changes until here are predictable and observable. + + Task { + // Probes or effects nested here are not testable (by default). + // State changes inside a task cannot be reliably observed. + await #probe() + + #Effect("second") { + await #probe() + // ... + } + } + + // All probes and effects are testable from this point again. + // State changes here are predictable, unless they race + // with mutations from the task above. + await #probe() + // ... + } +} +``` + +- Important: Wherever applicable, use effect macros instead of `Task` APIs to ensure full test support. + +That said, calling `Probing` APIs from within `Task` APIs is completely safe — and vice versa. +Using `Task` APIs only affects testing support, as it does not alter the runtime behavior of your app. + +It is fully supported and recommended to use `Task` APIs in non-testable contexts, even if those tasks invoke `Probing` APIs. +For example, you can continue using `Task.init` in a SwiftUI `View`: + +```swift +@Observable +final class MyViewModel { + private(set) var isDownloading = false + + func download() async { + isDownloading = true + await #probe() + // ... + } +} + +struct MyView: View { + @State private var viewModel = MyViewModel() + + var body: some View { + Button("Download") { + Task { + await viewModel.download() + } + } + // ... + } +} +``` + +- SeeAlso: Refer to the `ProbeTesting.ProbingOptions.attemptProbingFromTasks` documentation if you need to control +the execution of probes and effects created from `Task` APIs. As the name of the option suggests, this is not always possible +and is generally discouraged. + +## Parallel Processing + +While `Probing` offers macro equivalents to `Task.init`, it currently lacks counterparts for `TaskGroup.addTask` +functions. This is intentional, as APIs like `withTaskGroup` are meant to introduce parallelism to your code. +Since one of `Probing`’s goals is to help you reason about execution flow as a sequence of events, +flattening parallel hierarchies provides limited value in such cases. + +That said, you can still use `Probing` before parallel processing begins, after it ends, and while collecting the results +from the group’s child tasks. For example: + +```swift +// ... +await #probe() + +await withTaskGroup(of: Void.self) { group in + let count = 100 + progress = 0.0 + + for _ in 0 ..< count { + group.addTask { ... } + } + + for await task in group { + progress += 1 / Double(count) + await #probe() + } +} + +await #probe() +// ... +``` + +To support `Probing` with `async let` declarations, you can leverage the ``Effect/value-677pr`` property of effects. +It allows them to execute concurrently while remaining controllable from tests: + +```swift +let effect = #Effect("itJustWorks") { ... } +async let value = effect.value +``` diff --git a/Sources/Probing/Documentation.docc/EffectIdentifier.md b/Sources/Probing/Documentation.docc/EffectIdentifier.md new file mode 100644 index 0000000..5fb5069 --- /dev/null +++ b/Sources/Probing/Documentation.docc/EffectIdentifier.md @@ -0,0 +1,26 @@ +# ``Probing/EffectIdentifier`` + +## Topics + +### Creating Identifiers for Lookup During Tests + +- ``init(path:)`` +- ``init(arrayLiteral:)`` +- ``init(rawValue:)`` +- ``init(stringInterpolation:)`` + +### Accessing Raw Value + +- ``rawValue`` + +### Accessing Components + +- ``path`` + +### Debugging Effects + +- ``current`` + +### Understanding Effects Hierarchy + +- ``root`` diff --git a/Sources/Probing/Documentation.docc/EffectName.md b/Sources/Probing/Documentation.docc/EffectName.md new file mode 100644 index 0000000..0ade355 --- /dev/null +++ b/Sources/Probing/Documentation.docc/EffectName.md @@ -0,0 +1,17 @@ +# ``Probing/EffectName`` + +## Topics + +### Creating Names + +- ``init(rawValue:)`` +- ``init(stringInterpolation:)`` + +### Accessing Raw Value + +- ``rawValue`` + +### Uniquing Names + +- ``enumerated(_:)`` +- ``withIndex(_:)`` diff --git a/Sources/Probing/Documentation.docc/Examples.md b/Sources/Probing/Documentation.docc/Examples.md new file mode 100644 index 0000000..207922c --- /dev/null +++ b/Sources/Probing/Documentation.docc/Examples.md @@ -0,0 +1,121 @@ +# Examples + +Explore simplified examples from the `ProbingPlayground` sample project to see `Probing` integration in action. + +## Download the Sample Project + +You can download the `ProbingPlayground` sample project from its [GitHub page](https://github.com/NSFatalError/ProbingPlayground). + +## Probing in Async Functions + +To verify that an object has changed state while an `async` function is running, +you can define probes after the state change but before the next `await` statement: + +```swift +func uploadImage(_ item: ImageItem) async { + do { + uploadState = .uploading + await #probe() // ADDED + let image = try await item.loadImage() + let processedImage = try await processor.processImage(image) + try await uploader.uploadImage(processedImage) + uploadState = .success + } catch { + uploadState = .error + } + + await #probe() // ADDED + try? await Task.sleep(for: .seconds(3)) + uploadState = nil +} +``` + +This lets you reliably verify the `uploadState` before the image is processed and uploaded, +and before the presentation timer fires, reproducing the exact sequence of events users experience in your app. + +Similarly, you can install probes after each iteration of an `AsyncSequence` loop: + +```swift +func updateLocation() async { + locationState = .unknown + await #probe() // ADDED + + do { + for try await update in locationProvider.getUpdates() { + try Task.checkCancellation() + + if update.authorizationDenied { + locationState = .error + } else if let isNear = update.location?.isNearSanFrancisco() { + locationState = isNear ? .near : .far + } else { + locationState = .unknown + } + await #probe() // ADDED + } + } catch { + locationState = .error + } +} +``` + +This allows you to reliably verify the `locationState` before entering the loop, +after each iteration, or after an error is thrown. + +## Probing with Tasks + +To control the execution of tasks, you can replace them with equivalent effect macros: + +```swift +private var downloadImageEffects = [ImageQuality: any Effect]() // CHANGED + +func downloadImage() { + downloadImageEffects.values.forEach { $0.cancel() } + downloadImageEffects.removeAll() + downloadState = .downloading + + downloadImage(withQuality: .low) + downloadImage(withQuality: .high) +} + +private func downloadImage(withQuality quality: ImageQuality) { + downloadImageEffects[quality] = #Effect("\(quality)") { // CHANGED + defer { + downloadImageEffects[quality] = nil + } + + do { + let image = try await downloader.downloadImage(withQuality: quality) + try Task.checkCancellation() + imageDownloadSucceeded(with: image, quality: quality) + } catch is CancellationError { + return + } catch { + imageDownloadFailed() + } + } +} +``` + +This enables you to reliably verify the `downloadState` before the downloads start. +You can also precisely recreate various runtime scenarios, putting your objects into different states +that users might experience, such as: +- low quality download succeeded, while high quality download is pending +- high quality download succeeded, while low quality download is pending +- low quality download succeeded, then high quality download succeeded +- high quality download succeeded, then low quality download succeeded +- low quality download failed, then high quality download succeeded +- high quality download failed, then low quality download succeeded +- low quality download succeeded, then high quality download failed +- high quality download succeeded, then low quality download failed +- low quality download failed, then high quality download failed +- high quality download failed, then low quality download failed +- user requested redownload before either download completed +- user requested redownload before high quality download completed +- user requested redownload before low quality download completed +- etc. + +The return values and cancellation states of the effects are also available during testing, +without needing to reference them directly, as they are uniquely identified and can be retrieved. + +- SeeAlso: Refer to the `ProbeTesting` documentation for details on accessing probes and controlling effects during tests. diff --git a/Sources/Probing/Documentation.docc/ProbeIdentifier.md b/Sources/Probing/Documentation.docc/ProbeIdentifier.md new file mode 100644 index 0000000..abf9f88 --- /dev/null +++ b/Sources/Probing/Documentation.docc/ProbeIdentifier.md @@ -0,0 +1,18 @@ +# ``Probing/ProbeIdentifier`` + +## Topics + +### Creating Identifiers for Lookup During Tests + +- ``init(effect:name:)`` +- ``init(rawValue:)`` +- ``init(stringInterpolation:)`` + +### Accessing Raw Value + +- ``rawValue`` + +### Accessing Components + +- ``name`` +- ``effect`` diff --git a/Sources/Probing/Documentation.docc/ProbeName.md b/Sources/Probing/Documentation.docc/ProbeName.md new file mode 100644 index 0000000..d7679e7 --- /dev/null +++ b/Sources/Probing/Documentation.docc/ProbeName.md @@ -0,0 +1,16 @@ +# ``Probing/ProbeName`` + +## Topics + +### Creating Names + +- ``init(rawValue:)`` +- ``init(stringInterpolation:)`` + +### Accessing Raw Value + +- ``rawValue`` + +### Getting Default Name + +- ``default`` diff --git a/Sources/Probing/Documentation.docc/Probing.md b/Sources/Probing/Documentation.docc/Probing.md new file mode 100644 index 0000000..78999d9 --- /dev/null +++ b/Sources/Probing/Documentation.docc/Probing.md @@ -0,0 +1,98 @@ +# ``Probing`` + +Define suspension points accessible from tests with probes, and make side effects controllable. + +## Overview + +`Probing` lets you define suspension points for your test expectations +using the ``probe(_:preprocessorFlag:)`` macro. These are typically placed after a state change +and before `await` statements, for example: + +```swift +func load() async { + isLoading = true + + // Add a probe macro after `isLoading` is set to `true`, + // but before next `await`, to verify the state change in your tests. + await #probe() + + await downloadAndProcess() + isLoading = false +} +``` + +If your code uses `Task` instances to create side effects, you can replace them with +the ``Effect(_:preprocessorFlag:priority:operation:)`` macro or one of its variants to make them +controllable during tests. For example: + +```swift +func load() { + isLoading = true + + #Effect("download") { + // Replace `Task` instances with effect macros, + // so they will become initially suspended during tests, + // making their execution controllable and predictable. + + await downloadAndProcess() + isLoading = false + } +} +``` + +`Task` and effects created by `Probing` both conform to the ``Effect`` protocol and have the same public interface, +so replacing one with the other should be seamless. In fact, in release builds (by default), effect macros will return +standard Swift `Task` instances, so that your production code will not be affected by integration with `Probing`. + +- SeeAlso: Refer to the `ProbeTesting` documentation for details on accessing probes and controlling effects during tests. + +## Integration + +To enable testing with `ProbeTesting`, you only need to adopt `Probing` APIs in the bodies of the functions +whose execution you want to control. Neither probes nor effects change the runtime characteristics of your code. +By default, probes are completely stripped from the application binary in release builds. +Effects, on the other hand, are replaced with regular Swift `Task` instances. + +```swift +func callWithTask() { + Task { + // Not controllable with `ProbeTesting`, but runs normally. + await callWithProbing() + } +} + +func callWithProbing() await { + // Controllable with `ProbeTesting` when invoked from the `body` + // of the `withProbing` function, either directly or from an effect. + await #probe() + #Effect("test") { ... } +} +``` + +- SeeAlso: Refer to the article to learn more on how `Probing` interacts with Swift Concurrency. + +## Topics + +### Getting Started + +- +- + +### Installing Probes + +- ``probe(_:preprocessorFlag:)`` +- ``ProbeName`` +- ``ProbeIdentifier`` + +### Creating Effects + +- ``Effect(_:preprocessorFlag:priority:operation:)`` +- ``Effect(_:preprocessorFlag:executorPreference:priority:operation:)`` +- ``ConcurrentEffect(_:preprocessorFlag:priority:operation:)`` +- ``EffectName`` +- ``EffectIdentifier`` + +### Using Effects + +- ``Effect`` +- ``AnyEffect`` diff --git a/Sources/Probing/Effects/AnyEffect.swift b/Sources/Probing/Effects/AnyEffect.swift index 7cc0275..79d4a5c 100644 --- a/Sources/Probing/Effects/AnyEffect.swift +++ b/Sources/Probing/Effects/AnyEffect.swift @@ -6,6 +6,8 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // +/// Type-erased wrapper for ``Effect``, providing conformance to `Equatable` and `Hashable`. +/// public struct AnyEffect: Effect, Hashable { public let task: Task @@ -17,7 +19,11 @@ public struct AnyEffect: Effect, Hashable { extension Effect { - public func erasedToAnyEffect() -> AnyEffect { + /// Wraps this effect with a type eraser, making it `Equatable` and `Hashable`. + /// + /// - Returns: An instance of ``AnyEffect`` wrapping this effect. + /// + public func eraseToAnyEffect() -> AnyEffect { AnyEffect(self) } } diff --git a/Sources/Probing/Effects/Effect.swift b/Sources/Probing/Effects/Effect.swift index bcfe96d..cb3d634 100644 --- a/Sources/Probing/Effects/Effect.swift +++ b/Sources/Probing/Effects/Effect.swift @@ -6,6 +6,32 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // +/// Creates a `Task`-like effect that can be controlled from your tests. +/// +/// - Parameters: +/// - name: The name of the effect. It must be unique within the scope of its parent while the effect is still running. +/// - preprocessorFlag: A preprocessor flag that determines whether the generated code is included in the compiled binary. +/// Defaults to `DEBUG`. +/// - priority: The priority of the underlying task. +/// - operation: The asynchronous operation to perform. +/// +/// - Returns: An instance of type conforming to the ``Effect`` protocol. +/// +/// When run in the `body` of `ProbeTesting.withProbing` function, the effect is suspended immediately after initialization, +/// instead of starting execution as `Task` would. Later, it can be resumed and suspended at suspension points declared +/// using the ``probe(_:preprocessorFlag:)`` macro within the `operation`. +/// +/// Each effect must be uniquely identified within the scope of its parent by its `name` at every point in its execution. +/// Failure to do so will result in an error during testing. Once an effect completes, its identifier can be reused. +/// +/// If your code is compiled with the given `preprocessorFlag`, the effect becomes accessible and controllable from your tests +/// only when created within the `body` of `ProbeTesting.withProbing` function. Outside of that scope, this call initializes +/// a regular Swift `Task` that is not subject to any additional scheduling. +/// +/// - Attention: Unlike `Task`, the ``Effect`` protocol does not support throwing errors. +/// Any error handling should be performed inside the operation executed by the effect itself. +/// This design choice helps prevent errors from being unintentionally left unhandled. +/// @discardableResult @freestanding(expression) public macro Effect( @@ -18,6 +44,33 @@ public macro Effect( type: "EffectMacro" ) +/// Creates a `Task`-like effect that runs on the specified executor and can be controlled from your tests. +/// +/// - Parameters: +/// - name: The name of the effect. It must be unique within the scope of its parent while the effect is still running. +/// - preprocessorFlag: A preprocessor flag that determines whether the generated code is included in the compiled binary. +/// Defaults to `DEBUG`. +/// - taskExecutor: The preferred task executor for the underlying task, and any child tasks created by it. +/// - priority: The priority of the underlying task. +/// - operation: The asynchronous operation to perform. +/// +/// - Returns: An instance of type conforming to the ``Effect`` protocol. +/// +/// When run in the `body` of `ProbeTesting.withProbing` function, the effect is suspended immediately after initialization, +/// instead of starting execution as `Task` would. Later, it can be resumed and suspended at suspension points declared +/// using the ``probe(_:preprocessorFlag:)`` macro within the `operation`. +/// +/// Each effect must be uniquely identified within the scope of its parent by its `name` at every point in its execution. +/// Failure to do so will result in an error during testing. Once an effect completes, its identifier can be reused. +/// +/// If your code is compiled with the given `preprocessorFlag`, the effect becomes accessible and controllable from your tests +/// only when created within the `body` of `ProbeTesting.withProbing` function. Outside of that scope, this call initializes +/// a regular Swift `Task` that is not subject to any additional scheduling. +/// +/// - Attention: Unlike `Task`, the ``Effect`` protocol does not support throwing errors. +/// Any error handling should be performed inside the operation executed by the effect itself. +/// This design choice helps prevent errors from being unintentionally left unhandled. +/// @discardableResult @freestanding(expression) public macro Effect( @@ -31,6 +84,35 @@ public macro Effect( type: "EffectMacro" ) +/// Creates a `Task`-like effect that runs on the `globalConcurrentExecutor` and can be controlled from your tests. +/// +/// - Parameters: +/// - name: The name of the effect. It must be unique within the scope of its parent while the effect is still running. +/// - preprocessorFlag: A preprocessor flag that determines whether the generated code is included in the compiled binary. +/// Defaults to `DEBUG`. +/// - priority: The priority of the underlying task. +/// - operation: The asynchronous operation to perform. +/// +/// - Returns: An instance of type conforming to the ``Effect`` protocol. +/// +/// - Note: This macro is equivalent to calling ``Effect(_:preprocessorFlag:executorPreference:priority:operation:)`` with `globalConcurrentExecutor`. +/// If you were using `Task.detached`, this may be a viable alternative. +/// +/// When run in the `body` of `ProbeTesting.withProbing` function, the effect is suspended immediately after initialization, +/// instead of starting execution as `Task` would. Later, it can be resumed and suspended at suspension points declared +/// using the ``probe(_:preprocessorFlag:)`` macro within the `operation`. +/// +/// Each effect must be uniquely identified within the scope of its parent by its `name` at every point in its execution. +/// Failure to do so will result in an error during testing. Once an effect completes, its identifier can be reused. +/// +/// If your code is compiled with the given `preprocessorFlag`, the effect becomes accessible and controllable from your tests +/// only when created within the `body` of `ProbeTesting.withProbing` function. Outside of that scope, this call initializes +/// a regular Swift `Task` that is not subject to any additional scheduling. +/// +/// - Attention: Unlike `Task`, the ``Effect`` protocol does not support throwing errors. +/// Any error handling should be performed inside the operation executed by the effect itself. +/// This design choice helps prevent errors from being unintentionally left unhandled. +/// @discardableResult @freestanding(expression) public macro ConcurrentEffect( @@ -43,14 +125,37 @@ public macro ConcurrentEffect( type: "EffectMacro" ) +/// An interface shared by `Task` and types used by `Probing` to represent units of asynchronous work. +/// +/// You do not need to conform your own types to this protocol. +/// Types conforming to `Effect` are returned from effect macros such as ``Effect(_:preprocessorFlag:priority:operation:)``. +/// +/// - Attention: Unlike `Task`, the `Effect` protocol does not support throwing errors. +/// Any error handling should be performed inside the operation executed by the effect itself. +/// This design choice helps prevent errors from being unintentionally left unhandled. +/// public protocol Effect: Sendable { + /// The type of value returned by the effect. + /// associatedtype Success: Sendable + /// The underlying `Task` that performs the effect’s work. + /// var task: Task { get } + + /// The result from an effect, after it completes. + /// var value: Success { get async } + + /// Indicates whether the effect has been cancelled. + /// var isCancelled: Bool { get } + /// Cancels the effect. + /// + /// If the effect was cancelled after completing successfully, `Probing` considers it finished, not cancelled. + /// func cancel() } diff --git a/Sources/Probing/Effects/EffectIdentifier.swift b/Sources/Probing/Effects/EffectIdentifier.swift index e1b6f5f..6717d05 100644 --- a/Sources/Probing/Effects/EffectIdentifier.swift +++ b/Sources/Probing/Effects/EffectIdentifier.swift @@ -6,13 +6,99 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // +import Synchronization + +/// A unique identifier of an effect at every point in its execution. +/// +/// An `EffectIdentifier` consists of the ``EffectName`` components passed to the ``Effect(_:preprocessorFlag:priority:operation:)`` +/// macro (and its variants). These names form the identifier's ``path``, which reflects the stack of nested effects during a particular invocation. +/// +/// The identifier is represented as a string formed by concatenating these components with `"."` separators: +/// ```swift +/// path.map(\.rawValue).joined(separator: ".") +/// // "effect1.effect2.(...).effectN" +/// ``` +/// +/// If the ``path`` is empty, the identifier is also empty, and the effect is known as the ``root``. +/// The root effect is the one that executes the `body` of the `ProbeTesting.withProbing` function, and it is created implicitly. +/// All effects created within `body` are considered descendants of the root effect. +/// ```swift +/// withProbing { +/// // #Effect(.root) { +/// try await body() +/// // } +/// } dispatchedBy: { ... } +/// ``` +/// +/// ### Example +/// +/// ```swift +/// func createInnerEffect() { +/// #Effect("inner") {} +/// } +/// +/// func createOuterEffect() { +/// #Effect("outer") { +/// createInnerEffect() +/// } +/// } +/// +/// createInnerEffect() +/// // Identifier: "inner" +/// +/// createOuterEffect() +/// // Identifiers: "outer", "outer.inner" +/// ``` +/// public struct EffectIdentifier { + /// The identifier of the effect executing the `body` of the `ProbeTesting.withProbing` function. + /// + /// This effect is created implicitly during testing, and all effects created within the `body` are considered its descendants. + /// + /// Its ``rawValue`` is `""`, and its ``path`` is `[]`. + /// public static let root = Self(path: []) + /// A string formed by concatenating the ``path`` elements with `"."` separators. + /// + /// The `rawValue` has the form: + /// ```swift + /// path.map(\.rawValue).joined(separator: ".") + /// // "effect1.effect2.(...).effectN" + /// ``` + /// public let rawValue: String + + /// The stack of effect names that created a particular effect. + /// + /// The `path` reflects the nesting hierarchy of effects during a particular invocation. For example: + /// ```swift + /// func createInnerEffect() { + /// #Effect("inner") {} + /// } + /// + /// func createOuterEffect() { + /// #Effect("outer") { + /// createInnerEffect() + /// } + /// } + /// + /// createInnerEffect() + /// // Path: ["inner"] + /// + /// createOuterEffect() + /// // Paths: ["outer"], ["outer", "inner"] + /// public let path: [EffectName] + /// Creates an effect identifier for lookup during tests. + /// + /// - Parameter path: The stack of effect names that created a particular effect. + /// + /// Effect identifiers are determined dynamically during test execution. + /// Use this initializer to construct expected effect identifiers, which can be passed to the `ProbeTesting.ProbingDispatcher` to control the execution flow. + /// public init(path: [EffectName]) { self.rawValue = ProbingIdentifiers.join(path) self.path = path @@ -22,26 +108,50 @@ public struct EffectIdentifier { extension EffectIdentifier { @TaskLocal - private static var _current = EffectIdentifier.root + private static var _currentNodes = [Node]() + /// The identifier of the effect from which this property was accessed. + /// + /// - Important: This property only works if the effect was created by invocation of the `body` of the `ProbeTesting.withProbing` function. + /// Otherwise, it returns the ``root`` identifier. + /// public static var current: EffectIdentifier { - _current + _currentNodes.last?.id ?? .root } - static func appending( - _ childName: EffectName, - operation: (EffectIdentifier) throws -> R - ) rethrows -> R { - let childID = current.appending(childName) - return try $_current.withValue(childID) { - try operation(childID) + static func current(appending childName: EffectName) -> EffectIdentifier { + guard let parentNode = _currentNodes.last else { + preconditionFailure("Root identifier has not been set.") } - } - func appending(_ childName: EffectName) -> EffectIdentifier { - let childPath = path + CollectionOfOne(childName) + let childName = parentNode.enumerateIfNeeded(childName) + let childPath = current.path + CollectionOfOne(childName) return EffectIdentifier(path: childPath) } + + static func withChild( + _ childID: EffectIdentifier, + operation: () throws -> R + ) rethrows -> R { + let childNode = Node(id: childID) + let childrenNodes = _currentNodes + CollectionOfOne(childNode) + return try $_currentNodes.withValue( + childrenNodes, + operation: operation + ) + } + + static func withRoot( + isolation: isolated (any Actor)?, + operation: () async throws -> R + ) async rethrows -> R { + let rootNode = [Node(id: .root)] + return try await $_currentNodes.withValue( + rootNode, + operation: operation, + isolation: isolation + ) + } } extension EffectIdentifier: ProbingIdentifierProtocol { @@ -54,6 +164,13 @@ extension EffectIdentifier: ProbingIdentifierProtocol { } } + /// Creates an effect identifier for lookup during tests. + /// + /// - Parameter rawValue: A string in the format described by ``rawValue``. + /// + /// Effect identifiers are determined dynamically during test execution. + /// Use this initializer to construct expected effect identifiers, which can be passed to the `ProbeTesting.ProbingDispatcher` to control the execution flow. + /// public init(rawValue: String) { let path = ProbingIdentifiers.split(rawValue).map(EffectName.init) self.init(path: path) @@ -62,7 +179,45 @@ extension EffectIdentifier: ProbingIdentifierProtocol { extension EffectIdentifier: ExpressibleByArrayLiteral { + /// Creates an effect identifier for lookup during tests. + /// + /// - Parameter elements: The stack of effect names that created a particular effect. + /// + /// Effect identifiers are determined dynamically during test execution. + /// Use this initializer to construct expected effect identifiers, which can be passed to the `ProbeTesting.ProbingDispatcher` to control the execution flow. + /// public init(arrayLiteral elements: EffectName...) { self.init(path: elements) } } + +extension EffectIdentifier { + + private final class Node: Sendable { + + let id: EffectIdentifier + private let childrenIndices = Mutex([EffectName: UInt]()) + + init(id: EffectIdentifier) { + self.id = id + } + + func enumerateIfNeeded(_ childName: EffectName) -> EffectName { + guard childName.isEnumerated else { + return childName + } + + let index = childrenIndices.withLock { childrenIndices in + let index = if let existing = childrenIndices[childName] { + existing + 1 + } else { + UInt.zero + } + childrenIndices[childName] = index + return index + } + + return childName.withIndex(index) + } + } +} diff --git a/Sources/Probing/Effects/EffectName.swift b/Sources/Probing/Effects/EffectName.swift index 1f21e19..0ee882c 100644 --- a/Sources/Probing/Effects/EffectName.swift +++ b/Sources/Probing/Effects/EffectName.swift @@ -6,12 +6,58 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // +/// The name of an effect, forming the elements of ``EffectIdentifier/path`` of an ``EffectIdentifier``. +/// +/// An `EffectName` must be unique within the scope of its parent effect while the effect is still running. +/// Once the effect has completed, the same name may be reused by another effect. +/// +/// It’s recommended to assign distinct names to effects with different purposes. +/// If you need to invoke the same function repeatedly and cannot guarantee that the effect created by its previous invocation has completed, +/// use ``enumerated(_:)`` to automatically append an index to the name, making it unique. +/// public struct EffectName: ProbingIdentifierProtocol { + /// A non-empty string that does not contain any `.` characters. + /// public let rawValue: String + private(set) var isEnumerated = false + + /// Creates a new effect name. + /// + /// - Parameter rawValue: A non-empty string that must not contain any `.` characters. + /// public init(rawValue: String) { ProbingNames.preconditionValid(rawValue) self.rawValue = rawValue } } + +extension EffectName { + + /// Marks the given effect name as eligible for automatic indexing during tests, starting from zero. + /// + /// - Parameter name: The effect name to append an index to during tests. + /// + /// - Returns: A new effect name that will be automatically indexed during tests. + /// + /// The resulting ``rawValue`` during tests will be formatted as `name0`, `name1`, etc. + /// + public static func enumerated(_ name: EffectName) -> EffectName { + var enumerated = name + enumerated.isEnumerated = true + return enumerated + } + + /// Appends the given index to the ``rawValue``. + /// + /// - Parameter index: The index to append to the ``rawValue``. + /// + /// - Returns: A new effect name with the index appended. + /// + /// The resulting ``rawValue`` will be formatted as `name0`, `name1`, etc. + /// + public func withIndex(_ index: UInt) -> EffectName { + .init(rawValue: "\(rawValue)\(index)") + } +} diff --git a/Sources/Probing/Effects/TestableEffect.swift b/Sources/Probing/Effects/TestableEffect.swift index 96100cc..2c09a90 100644 --- a/Sources/Probing/Effects/TestableEffect.swift +++ b/Sources/Probing/Effects/TestableEffect.swift @@ -8,6 +8,7 @@ import PrincipleConcurrency +@_documentation(visibility: private) public struct TestableEffect: Effect, Hashable { public let task: Task @@ -35,7 +36,7 @@ public struct TestableEffect: Effect, Hashable { } let name = name() - let id = EffectIdentifier.current.appending(name) + let id = EffectIdentifier.current(appending: name) let isolation = extractIsolation(operation) var transfer = SingleUseTransfer(operation) let location = ProbingLocation( @@ -53,7 +54,7 @@ public struct TestableEffect: Effect, Hashable { ) } - let task = EffectIdentifier.appending(name) { id in + let task = EffectIdentifier.withChild(id) { var transfer = transfer.take() return Task(priority: priority) { @@ -101,7 +102,7 @@ public struct TestableEffect: Effect, Hashable { } let name = name() - let id = EffectIdentifier.current.appending(name) + let id = EffectIdentifier.current(appending: name) var transfer = SingleUseTransfer(operation) let location = ProbingLocation( fileID: fileID, @@ -119,7 +120,7 @@ public struct TestableEffect: Effect, Hashable { ) } - let task = EffectIdentifier.appending(name) { [taskExecutor] id in + let task = EffectIdentifier.withChild(id) { [taskExecutor] in var transfer = transfer.take() return Task(executorPreference: taskExecutor, priority: priority) { diff --git a/Sources/Probing/Errors/ProbingErrors.swift b/Sources/Probing/Errors/ProbingErrors.swift index 523a656..e56dcd9 100644 --- a/Sources/Probing/Errors/ProbingErrors.swift +++ b/Sources/Probing/Errors/ProbingErrors.swift @@ -55,6 +55,7 @@ extension ProbingErrors { Recovery suggestions: - If effects have distinct purposes assign unique identifiers to each of them. + - Use EffectName.enumerated(_:) to automatically append an index to the name, making it unique. - Ensure preexisting effect and its children have completed before a new one is created. """ } diff --git a/Sources/Probing/Errors/ProbingInterruptedError.swift b/Sources/Probing/Errors/ProbingInterruptedError.swift deleted file mode 100644 index c8d2307..0000000 --- a/Sources/Probing/Errors/ProbingInterruptedError.swift +++ /dev/null @@ -1,9 +0,0 @@ -// -// ProbingInterruptedError.swift -// Probing -// -// Created by Kamil Strzelecki on 05/05/2025. -// Copyright © 2025 Kamil Strzelecki. All rights reserved. -// - -internal struct ProbingInterruptedError: Error {} diff --git a/Sources/Probing/Identifiers/ProbingIdentifierProtocol.swift b/Sources/Probing/Identifiers/ProbingIdentifierProtocol.swift index 66def53..2b1fca5 100644 --- a/Sources/Probing/Identifiers/ProbingIdentifierProtocol.swift +++ b/Sources/Probing/Identifiers/ProbingIdentifierProtocol.swift @@ -6,6 +6,7 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // +@_documentation(visibility: private) public protocol ProbingIdentifierProtocol: RawRepresentable, Hashable, @@ -31,3 +32,14 @@ extension ProbingIdentifierProtocol { self.init(rawValue: .init(stringInterpolation: stringInterpolation)) } } + +extension ProbingIdentifierProtocol { + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue == rhs.rawValue + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(rawValue) + } +} diff --git a/Sources/Probing/Imports.swift b/Sources/Probing/Imports.swift deleted file mode 100644 index 07093c1..0000000 --- a/Sources/Probing/Imports.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// Imports.swift -// Probing -// -// Created by Kamil Strzelecki on 29/03/2025. -// Copyright © 2025 Kamil Strzelecki. All rights reserved. -// - -@_documentation(visibility: private) @_exported import DeeplyCopyable -@_documentation(visibility: private) @_exported import EquatableObject diff --git a/Sources/Probing/Probes/Probe.swift b/Sources/Probing/Probes/Probe.swift index 6508537..f386b26 100644 --- a/Sources/Probing/Probes/Probe.swift +++ b/Sources/Probing/Probes/Probe.swift @@ -6,6 +6,24 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // +/// Defines a possible suspension point accessible from your tests. +/// +/// - Parameters: +/// - name: The name of the probe. Does not need to be unique. Defaults to ``ProbeName/default``. +/// - preprocessorFlag: A preprocessor flag that determines whether the generated code is included in the compiled binary. +/// Defaults to `DEBUG`. +/// +/// Think of a probe as a conditional “breakpoint” for tests, with two key differences: +/// - It suspends only the current effect/task, not the entire program. +/// - It enables you to run test expectations at that point in execution, rather than debugging. +/// +/// When a probe is installed, it is uniquely identified at that point in execution by a ``ProbeIdentifier``. +/// Subsequent probes with the same `name` in the same effect will be assigned the same identifier. +/// +/// If your code is compiled with the given `preprocessorFlag`, the probe becomes accessible and controllable from your tests +/// only when run within the `body` of `ProbeTesting.withProbing` function. Outside of that scope, this call does nothing +/// and resumes execution immediately. +/// @freestanding(expression) public macro probe( _ name: @autoclosure () -> ProbeName = .default, diff --git a/Sources/Probing/Probes/ProbeIdentifier.swift b/Sources/Probing/Probes/ProbeIdentifier.swift index cb8a717..7c1ea43 100644 --- a/Sources/Probing/Probes/ProbeIdentifier.swift +++ b/Sources/Probing/Probes/ProbeIdentifier.swift @@ -6,12 +6,60 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // +/// A unique identifier of a probe at a specific point in execution. +/// +/// A `ProbeIdentifier` consists of the ``effect`` identifier in which the probe was installed during a particular test run, +/// and the probe ``name`` passed to the ``probe(_:preprocessorFlag:)`` macro. +/// +/// It is represented as a string formed by concatenating these components with `"."` separators: +/// ```swift +/// "\(effect).\(name)" +/// // "effect1.effect2.(...).effectN.probe" +/// ``` +/// +/// If the probe is installed directly in the `body` of the `ProbeTesting.withProbing` function, without any intermediate effects, +/// the identifier contains only the ``name``, since the ``effect``.``EffectIdentifier/path`` is empty: +/// ```swift +/// "\(name)" +/// // "probe" +/// ``` +/// public struct ProbeIdentifier { + /// A string formed by concatenating the ``effect`` and ``name`` components with `"."` separators. + /// + /// If the probe is installed within an effect, the `rawValue` has the form: + /// ```swift + /// "\(effect).\(name)" + /// // "effect1.effect2.(...).effectN.probe" + /// ``` + /// + /// If the probe is installed directly in the `body` of the `ProbeTesting.withProbing` function, without any intermediate effects, + /// the `rawValue` contains only the ``name``, since the ``effect``.``EffectIdentifier/path`` is empty: + /// ```swift + /// "\(name)" + /// // "probe" + /// ``` + /// public let rawValue: String + + /// The identifier of the effect in which the probe was installed during a particular test run. + /// public let effect: EffectIdentifier + + /// The name of the probe, as passed to the ``probe(_:preprocessorFlag:)`` macro. + /// public let name: ProbeName + /// Creates a probe identifier for lookup during tests. + /// + /// - Parameters: + /// - effect: The identifier of the effect in which the probe was installed. + /// - name: The name of the probe. + /// + /// Probe identifiers are determined dynamically during test execution. + /// Use this initializer to construct expected probe identifiers, which can be passed to the `ProbeTesting.ProbingDispatcher` to control the execution flow. + /// public init( effect: EffectIdentifier, name: ProbeName @@ -27,6 +75,13 @@ public struct ProbeIdentifier { extension ProbeIdentifier: ProbingIdentifierProtocol { + /// Creates a probe identifier for lookup during tests. + /// + /// - Parameter rawValue: A non-empty string in the format described by ``rawValue``. + /// + /// Probe identifiers are determined dynamically during test execution. + /// Use this initializer to construct expected probe identifiers, which can be passed to the `ProbeTesting.ProbingDispatcher` to control the execution flow. + /// public init(rawValue: String) { let components = ProbingIdentifiers.split(rawValue) let path = components.dropLast().map(EffectName.init) diff --git a/Sources/Probing/Probes/ProbeName.swift b/Sources/Probing/Probes/ProbeName.swift index e8b9502..d208a76 100644 --- a/Sources/Probing/Probes/ProbeName.swift +++ b/Sources/Probing/Probes/ProbeName.swift @@ -6,14 +6,31 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // +/// The name of a probe, forming the last component of a ``ProbeIdentifier``. +/// +/// A `ProbeName` doesn't need to be unique, as it is impossible to install two probes concurrently from the same effect. +/// public struct ProbeName: ProbingIdentifierProtocol { - public static let `default`: Self = "probe" - + /// A non-empty string that does not contain any `.` characters. + /// public let rawValue: String + /// Creates a new probe name. + /// + /// - Parameter rawValue: A non-empty string that must not contain any `.` characters. + /// public init(rawValue: String) { ProbingNames.preconditionValid(rawValue) self.rawValue = rawValue } } + +extension ProbeName { + + /// The default probe name, used by ``probe(_:preprocessorFlag:)`` when no `name` argument is provided. + /// + /// Its ``rawValue`` is `"probe"`. + /// + public static let `default`: Self = "probe" +} diff --git a/Sources/Probing/TestingSupport/EffectDispatch.swift b/Sources/Probing/TestingSupport/EffectDispatch.swift index 81b4019..cac7dd3 100644 --- a/Sources/Probing/TestingSupport/EffectDispatch.swift +++ b/Sources/Probing/TestingSupport/EffectDispatch.swift @@ -23,14 +23,6 @@ internal indirect enum EffectDispatch: Sendable { preconditionFailure("Child dispatches cannot be nested.") } - precondition( - childID.path.count <= id.path.count, - """ - Dispatch for effect with identifier \"\(id)\" was relayed \ - to unrelated descendant with identifier \"\(childID)\". - """ - ) - if childID == id { self = .suspendWhenPossible return dispatch diff --git a/Sources/Probing/TestingSupport/EffectState.swift b/Sources/Probing/TestingSupport/EffectState.swift index f25050a..f7301c4 100644 --- a/Sources/Probing/TestingSupport/EffectState.swift +++ b/Sources/Probing/TestingSupport/EffectState.swift @@ -166,9 +166,9 @@ extension EffectState { func runUntilEffectCompleted( withID effectID: EffectIdentifier, includingDescendants includeDescendants: Bool - ) { + ) throws { preconditionRoot() - runUntilEffectCompleted( + try runUntilEffectCompleted( withID: effectID, path: effectID.path[...], includingDescendants: includeDescendants @@ -179,10 +179,10 @@ extension EffectState { withID effectID: EffectIdentifier, path: ArraySlice, includingDescendants includeDescendants: Bool - ) { + ) throws { if let descendantName = path.first { if let descendant = children[descendantName] { - descendant.runUntilEffectCompleted( + try descendant.runUntilEffectCompleted( withID: effectID, path: path.dropFirst(), includingDescendants: includeDescendants @@ -204,7 +204,7 @@ extension EffectState { if includeDescendants { for child in children.values { - child.runUntilEffectCompleted( + try child.runUntilEffectCompleted( withID: effectID, path: path, includingDescendants: true @@ -213,11 +213,7 @@ extension EffectState { } } - do { - try resumeIfNeeded() - } catch { - preconditionFailure("Effect would never complete: \(error)") - } + try resumeIfNeeded() } } diff --git a/Sources/Probing/TestingSupport/ProbingCoordinator.swift b/Sources/Probing/TestingSupport/ProbingCoordinator.swift index a8a7f8c..a4bfb54 100644 --- a/Sources/Probing/TestingSupport/ProbingCoordinator.swift +++ b/Sources/Probing/TestingSupport/ProbingCoordinator.swift @@ -10,8 +10,8 @@ import Synchronization package final class ProbingCoordinator: Sendable { - private let state: Mutex let options: _ProbingOptions + private let state: Mutex package init( options: _ProbingOptions, @@ -20,14 +20,16 @@ package final class ProbingCoordinator: Sendable { column: Int ) { let initialState = ProbingState( + options: options, rootEffectLocation: ProbingLocation( fileID: fileID, line: line, column: column ) ) - self.state = .init(initialState) + self.options = options + self.state = .init(initialState) } deinit { @@ -51,7 +53,7 @@ extension ProbingCoordinator { @TaskLocal private static var _current: ProbingCoordinator? - internal static var current: ProbingCoordinator? { + static var current: ProbingCoordinator? { _current } @@ -59,11 +61,14 @@ extension ProbingCoordinator { isolation: isolated (any Actor)?, operation: () async throws -> R ) async rethrows -> R { - try await ProbingCoordinator.$_current.withValue( - self, - operation: operation, - isolation: isolation - ) + nonisolated(unsafe) let operation = operation + return try await EffectIdentifier.withRoot(isolation: isolation) { + try await ProbingCoordinator.$_current.withValue( + self, + operation: operation, + isolation: isolation + ) + } } } @@ -72,8 +77,7 @@ extension ProbingCoordinator { private func pauseTest( isolation: isolated (any Actor)?, phasePrecondition precondition: TestPhase.Precondition, - awaiting dispatches: (ProbingState) throws -> Void, - after injection: () -> Void + awaiting dispatches: (ProbingState) throws -> Void ) async throws { try await withCheckedThrowingContinuation(isolation: isolation) { continuation in state.resumeTestIfPossible { state in @@ -81,7 +85,6 @@ extension ProbingCoordinator { state.pauseTest(using: continuation) try dispatches(state) } - injection() } } @@ -92,8 +95,7 @@ extension ProbingCoordinator { try await pauseTest( isolation: isolation, phasePrecondition: .init(\.isScheduled), - awaiting: { _ in }, // swiftlint:disable:this no_empty_block - after: {} // swiftlint:disable:this no_empty_block + awaiting: { _ in } // swiftlint:disable:this no_empty_block ) } catch { preconditionFailure(""" @@ -107,9 +109,7 @@ extension ProbingCoordinator { guard !state.testPhase.isFailed else { return } - state.preconditionTestPhase { testPhase in - testPhase.isRunning || testPhase.isPaused - } + state.preconditionTestPhase(\.isRunning) try state.passTest() } } @@ -119,35 +119,31 @@ extension ProbingCoordinator { package func runUntilProbeInstalled( withID id: ProbeIdentifier, - isolation: isolated (any Actor)?, - after injection: () -> Void + isolation: isolated (any Actor)? ) async throws { try await pauseTest( isolation: isolation, phasePrecondition: .init(\.isRunning), awaiting: { state in try state.rootEffect.runUntilProbeInstalled(withID: id) - }, - after: injection + } ) } package func runUntilEffectCompleted( withID id: EffectIdentifier, includingDescendants includeDescendants: Bool, - isolation: isolated (any Actor)?, - after injection: () -> Void + isolation: isolated (any Actor)? ) async throws { try await pauseTest( isolation: isolation, phasePrecondition: .init(\.isRunning), awaiting: { state in - state.rootEffect.runUntilEffectCompleted( + try state.rootEffect.runUntilEffectCompleted( withID: id, includingDescendants: includeDescendants ) - }, - after: injection + } ) } @@ -178,16 +174,6 @@ extension ProbingCoordinator { extension ProbingCoordinator { - private func shouldProbeCurrentTask(state: ProbingState) -> Bool { - guard options.contains(.ignoreProbingInTasks) else { - return true - } - guard let taskID = Task.id else { - return false - } - return state.taskIDs.contains(taskID) - } - func installProbe( withName name: ProbeName, at location: ProbingLocation, @@ -207,6 +193,7 @@ extension ProbingCoordinator { ) guard state.isTracking, + state.shouldProbeCurrentTask(), let childEffect = state.childEffect(withID: id.effect) else { continuation.resume() @@ -230,11 +217,6 @@ extension ProbingCoordinator { ) } - guard shouldProbeCurrentTask(state: state) else { - continuation.resume() - return - } - state.preconditionTestPhase(\.isPaused) childEffect.probe(using: continuation) } @@ -262,15 +244,15 @@ extension ProbingCoordinator { return } - guard !state.testPhase.isRunning else { - throw ProbingErrors.EffectAPIMisuse(backtrace: backtrace) - } - - guard shouldProbeCurrentTask(state: state) else { + guard state.shouldProbeCurrentTask() else { shouldProbe = false return } + guard !state.testPhase.isRunning else { + throw ProbingErrors.EffectAPIMisuse(backtrace: backtrace) + } + state.preconditionTestPhase(\.isPaused) try state.rootEffect.createChild(withBacktrace: backtrace) } @@ -288,9 +270,7 @@ extension ProbingCoordinator { ) async { await withCheckedContinuation(isolation: isolation) { underlying in state.resumeTestIfPossible { state in - if options.contains(.ignoreProbingInTasks) { - state.registerCurrentTask() - } + state.registerCurrentTaskIfNeeded() guard state.isTracking, let childEffect = state.childEffect(withID: id) @@ -349,9 +329,7 @@ extension ProbingCoordinator { testPhasePrecondition precondition: TestPhase.Precondition ) { state.resumeTestIfPossible { state in - if options.contains(.ignoreProbingInTasks) { - state.unregisterCurrentTask() - } + state.unregisterCurrentTaskIfNeeded() guard state.isTracking, let childEffect = state.childEffect(withID: id) diff --git a/Sources/Probing/TestingSupport/ProbingState.swift b/Sources/Probing/TestingSupport/ProbingState.swift index e258851..9deb3a1 100644 --- a/Sources/Probing/TestingSupport/ProbingState.swift +++ b/Sources/Probing/TestingSupport/ProbingState.swift @@ -10,7 +10,9 @@ import Synchronization internal struct ProbingState { + let options: _ProbingOptions let rootEffect: EffectState + private(set) var testPhase = TestPhase.scheduled private(set) var taskIDs = Set() private(set) var errors = [any Error]() @@ -19,7 +21,11 @@ internal struct ProbingState { !testPhase.isCompleted && errors.isEmpty } - init(rootEffectLocation: ProbingLocation) { + init( + options: _ProbingOptions, + rootEffectLocation: ProbingLocation + ) { + self.options = options self.rootEffect = .root(location: rootEffectLocation) } } @@ -55,31 +61,27 @@ extension ProbingState { extension ProbingState { mutating func passTest() throws { - switch testPhase { - case let .paused(continuation): - let error = ProbingInterruptedError() - continuation.resume(throwing: error) - failTest(with: error) - throw error - - default: - if let firstError = errors.first { - failTest(with: firstError) - throw firstError - } + if let firstError = errors.first { + failTest(with: firstError) + throw firstError + } + do { + try unblockRootEffect() testPhase = .passed - completeTest() + } catch { + testPhase = .failed(error) + throw error } } private mutating func failTest(with error: any Error) { testPhase = .failed(error) - completeTest() + try? unblockRootEffect() } - private func completeTest() { - rootEffect.runUntilEffectCompleted( + private func unblockRootEffect() throws { + try rootEffect.runUntilEffectCompleted( withID: .root, includingDescendants: true ) @@ -88,21 +90,35 @@ extension ProbingState { extension ProbingState { - mutating func registerCurrentTask() { - guard let taskID = Task.id else { + mutating func registerCurrentTaskIfNeeded() { + guard options.contains(.ignoreProbingInTasks), + let taskID = Task.id + else { return } let result = taskIDs.insert(taskID) precondition(result.inserted, "Task was already registered.") } - mutating func unregisterCurrentTask() { - guard let taskID = Task.id else { + mutating func unregisterCurrentTaskIfNeeded() { + guard options.contains(.ignoreProbingInTasks), + let taskID = Task.id + else { return } let result = taskIDs.remove(taskID) precondition(result != nil, "Task was never registered.") } + + func shouldProbeCurrentTask() -> Bool { + guard options.contains(.ignoreProbingInTasks) else { + return true + } + guard let taskID = Task.id else { + return false + } + return taskIDs.contains(taskID) + } } extension ProbingState { diff --git a/Sources/Probing/TestingSupport/_ProbingOptions.swift b/Sources/Probing/TestingSupport/_ProbingOptions.swift index 674d906..57ab7d3 100644 --- a/Sources/Probing/TestingSupport/_ProbingOptions.swift +++ b/Sources/Probing/TestingSupport/_ProbingOptions.swift @@ -24,6 +24,6 @@ extension _ProbingOptions { extension _ProbingOptions { static var current: Self { - ProbingCoordinator.current?.options ?? .attemptProbingInTasks + ProbingCoordinator.current?.options ?? .ignoreProbingInTasks } } diff --git a/Tests/ProbeTestingTests/Helpers/Issue+RecordedError.swift b/Tests/ProbeTestingTests/Helpers/Issue+RecordedError.swift index 3071f95..6e646ba 100644 --- a/Tests/ProbeTestingTests/Helpers/Issue+RecordedError.swift +++ b/Tests/ProbeTestingTests/Helpers/Issue+RecordedError.swift @@ -14,7 +14,9 @@ extension Issue { func didRecordError( _: Underlying.Type ) -> Bool { - let recordedError = error as? RecordedError - return recordedError?.underlying is Underlying + guard let recordedError = error as? RecordedError else { + return error is ProbingTerminatedError + } + return recordedError.underlying is Underlying } } diff --git a/Tests/ProbeTestingTests/Suites/APIMisuseTests.swift b/Tests/ProbeTestingTests/Suites/APIMisuseTests.swift index fbfe10f..82d531d 100644 --- a/Tests/ProbeTestingTests/Suites/APIMisuseTests.swift +++ b/Tests/ProbeTestingTests/Suites/APIMisuseTests.swift @@ -91,29 +91,6 @@ extension APIMisuseTests { } } -extension APIMisuseTests { - - @Test - func testProbingWhileTesting() async throws { - try await withProbing { - // Void - } dispatchedBy: { _ in - await #probe() - } - } - - @Test - func testProbingWhileInjecting() async throws { - try await withProbing { - // Void - } dispatchedBy: { dispatcher in - try await dispatcher.runUntilEverythingCompleted { - await #probe() - } - } - } -} - extension APIMisuseTests { private struct NonSendableInteractor { diff --git a/Tests/ProbeTestingTests/Suites/InjectionTests.swift b/Tests/ProbeTestingTests/Suites/AsyncSequenceTests.swift similarity index 64% rename from Tests/ProbeTestingTests/Suites/InjectionTests.swift rename to Tests/ProbeTestingTests/Suites/AsyncSequenceTests.swift index fd294f7..51e5890 100644 --- a/Tests/ProbeTestingTests/Suites/InjectionTests.swift +++ b/Tests/ProbeTestingTests/Suites/AsyncSequenceTests.swift @@ -1,5 +1,5 @@ // -// InjectionTests.swift +// AsyncSequenceTests.swift // Probing // // Created by Kamil Strzelecki on 26/04/2025. @@ -10,7 +10,7 @@ @testable import Probing import Testing -internal struct InjectionTests { +internal struct AsyncSequenceTests { private let model: IsolatedModel private let interactor: IsolatedInteractor @@ -25,21 +25,18 @@ internal struct InjectionTests { try await withProbing { await interactor.callWithAsyncStream() } dispatchedBy: { dispatcher in - let first = try await dispatcher.runUpToProbe { - await interactor.yield() - } + let first = await interactor.yield() + try await dispatcher.runUpToProbe() #expect(first == 0) await #expect(model.value == 1) - let second = try await dispatcher.runUpToProbe { - await interactor.yield() - } + let second = await interactor.yield() + try await dispatcher.runUpToProbe() #expect(second == 1) await #expect(model.value == 2) - let final = try await dispatcher.runUpToProbe("1") { - await interactor.finish() - } + let final = await interactor.finish() + try await dispatcher.runUpToProbe("1") #expect(final == 2) await #expect(model.value == 4) @@ -56,21 +53,18 @@ internal struct InjectionTests { try await dispatcher.runUpToProbe("2") await #expect(model.value == 2) - let first = try await dispatcher.runUpToProbe(inEffect: "stream") { - await interactor.yield() - } + let first = await interactor.yield() + try await dispatcher.runUpToProbe(inEffect: "stream") #expect(first == 2) await #expect(model.value == 3) - let second = try await dispatcher.runUpToProbe(inEffect: "stream") { - await interactor.yield() - } + let second = await interactor.yield() + try await dispatcher.runUpToProbe(inEffect: "stream") #expect(second == 3) await #expect(model.value == 4) - let final = try await dispatcher.runUpToProbe("stream.1") { - await interactor.finish() - } + let final = await interactor.finish() + try await dispatcher.runUpToProbe("stream.1") #expect(final == 4) await #expect(model.value == 6) @@ -83,16 +77,15 @@ internal struct InjectionTests { } } -extension InjectionTests { +extension AsyncSequenceTests { @Test func testRunningUpToProbe() async throws { try await withProbing { await interactor.callWithAsyncStream() } dispatchedBy: { dispatcher in - let result = try await dispatcher.runUpToProbe { - await interactor.finish() - } + let result = await interactor.finish() + try await dispatcher.runUpToProbe() #expect(result == 0) await #expect(model.value == 1) } @@ -103,9 +96,8 @@ extension InjectionTests { try await withProbing { await interactor.callWithAsyncStream() } dispatchedBy: { dispatcher in - let result = try await dispatcher.runUpToProbe("1") { - await interactor.finish() - } + let result = await interactor.finish() + try await dispatcher.runUpToProbe("1") #expect(result == 0) await #expect(model.value == 2) } @@ -116,25 +108,23 @@ extension InjectionTests { try await withProbing { await interactor.callWithAsyncStreamInEffect() } dispatchedBy: { dispatcher in - let result = try await dispatcher.runUpToProbe(inEffect: "stream") { - await interactor.finish() - } - #expect(result <= 1) + let result = await interactor.finish() + try await dispatcher.runUpToProbe(inEffect: "stream") + #expect(result == 0) await #expect(model.value == 2) } } } -extension InjectionTests { +extension AsyncSequenceTests { @Test func testRunningUntilExitOfBody() async throws { try await withProbing { await interactor.callWithAsyncStream() } dispatchedBy: { dispatcher in - let result = try await dispatcher.runUntilExitOfBody { - await interactor.finish() - } + let result = await interactor.finish() + try await dispatcher.runUntilExitOfBody() #expect(result == 0) await #expect(model.value == 3) } @@ -145,10 +135,9 @@ extension InjectionTests { try await withProbing { await interactor.callWithAsyncStreamInEffect() } dispatchedBy: { dispatcher in - let result = try await dispatcher.runUntilEverythingCompleted { - await interactor.finish() - } - #expect(result <= 3) + let result = await interactor.finish() + try await dispatcher.runUntilEverythingCompleted() + #expect(result == 0) await #expect(model.value == 6) } } @@ -158,47 +147,15 @@ extension InjectionTests { try await withProbing { await interactor.callWithAsyncStreamInEffect() } dispatchedBy: { dispatcher in - let result = try await dispatcher.runUntilEffectCompleted("stream") { - await interactor.finish() - } - #expect(result <= 1) + let result = await interactor.finish() + try await dispatcher.runUntilEffectCompleted("stream") + #expect(result == 0) await #expect(model.value == 4) } } } -extension InjectionTests { - - @Test - func testThrowing() async { - await #expect(throws: ErrorMock.self) { - try await withProbing { - await interactor.callWithAsyncStream() - } dispatchedBy: { dispatcher in - try await dispatcher.runUpToProbe("unknown") { - throw ErrorMock() - } - Issue.record() - } - } - } - - @CustomActor - @Test - func testIsolation() async throws { - try await withProbing { - await interactor.callWithAsyncStream() - } dispatchedBy: { dispatcher in - try await dispatcher.runUpToProbe { - #expect(#isolation === CustomActor.shared) - CustomActor.shared.assertIsolated() - _ = await interactor.finish() - } - } - } -} - -extension InjectionTests { +extension AsyncSequenceTests { private struct ErrorMock: Error {} diff --git a/Tests/ProbeTestingTests/Suites/EffectTests.swift b/Tests/ProbeTestingTests/Suites/EffectTests.swift index 3f3b09f..1515edd 100644 --- a/Tests/ProbeTestingTests/Suites/EffectTests.swift +++ b/Tests/ProbeTestingTests/Suites/EffectTests.swift @@ -6,8 +6,6 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -// swiftlint:disable file_length - @testable import ProbeTesting @testable import Probing import Algorithms @@ -15,8 +13,8 @@ import Testing internal class EffectTests { - private let model: IsolatedModel - private let interactor: IsolatedInteractor + let model: IsolatedModel + let interactor: IsolatedInteractor init() async { self.model = .init() @@ -179,575 +177,10 @@ extension EffectTests { } } -extension EffectTests { - - final class Independent: EffectTests { - - @Test - func testRunningThroughProbes() async throws { - try await withProbing { - await interactor.callWithIndependentEffects() - } dispatchedBy: { dispatcher in - await #expect(model.values.isEmpty) - try await dispatcher.runUpToProbe("1") - await #expect( - model.values == [ - .root: 1 - ] - ) - - try await dispatcher.runUpToProbe("2") - await #expect( - model.values == [ - .root: 2 - ] - ) - - try await dispatcher.runUntilExitOfBody() - await #expect( - model.values == [ - .root: 3 - ] - ) - - try await dispatcher.runUpToProbe("1.1") - await #expect( - model.values == [ - .root: 3, - "1": 1 - ] - ) - - try await dispatcher.runUpToProbe("1.2") - await #expect( - model.values == [ - .root: 3, - "1": 2 - ] - ) - - try await dispatcher.runUntilEffectCompleted("1") - await #expect( - model.values == [ - .root: 3, - "1": 3 - ] - ) - - try await dispatcher.runUpToProbe("2.1") - await #expect( - model.values == [ - .root: 3, - "1": 3, - "2": 1 - ] - ) - - try await dispatcher.runUpToProbe("2.2") - await #expect( - model.values == [ - .root: 3, - "1": 3, - "2": 2 - ] - ) - - try await dispatcher.runUntilEffectCompleted("2") - await #expect( - model.values == [ - .root: 3, - "1": 3, - "2": 3 - ] - ) - } - } - - @Test( - arguments: 1 ..< 3, - 1 ..< 3 - ) - func testRunningToProbe( - inEffect effect: Int, - withNumber number: Int - ) async throws { - try await withProbing { - await interactor.callWithIndependentEffects() - } dispatchedBy: { dispatcher in - await #expect(model.values.isEmpty) - try await dispatcher.runUpToProbe("\(effect).\(number)") - await #expect( - model.values == [ - .root: effect, - "\(effect)": number - ] - ) - - try await dispatcher.runUntilEffectCompleted("\(effect)") - await #expect( - model.values == [ - .root: effect, - "\(effect)": 3 - ] - ) - } - await #expect(model.values[.root, default: 0] == 3) - } - } -} - -extension EffectTests.Independent { - - @Test - func testRunningWithoutDispatches() async throws { - try await withProbing { - await interactor.callWithIndependentEffects() - } dispatchedBy: { _ in - await #expect(model.values.isEmpty) - } - await #expect(model.values[.root] == 3) - } - - @Test - func testRunningUntilExitOfBody() async throws { - try await withProbing { - await interactor.callWithIndependentEffects() - } dispatchedBy: { dispatcher in - await #expect(model.values.isEmpty) - try await dispatcher.runUntilExitOfBody() - await #expect(model.values == [.root: 3]) - } - } - - @Test - func testRunningUntilEverythingCompleted() async throws { - try await withProbing { - await interactor.callWithIndependentEffects() - } dispatchedBy: { dispatcher in - await #expect(model.values.isEmpty) - try await dispatcher.runUntilEverythingCompleted() - await #expect( - model.values == [ - .root: 3, - "1": 3, - "2": 3 - ] - ) - } - } - - @Test - func testGettingMissingEffectValue() async throws { - try await withKnownIssue { - try await withProbing { - await interactor.callWithIndependentEffects() - } dispatchedBy: { dispatcher in - do { - await #expect(model.values.isEmpty) - try dispatcher.getValue(fromEffect: "test", as: Void.self) - } catch { - await #expect(model.values.isEmpty) - throw error - } - } - } matching: { issue in - issue.didRecordError(ProbingErrors.EffectNotFound.self) - } - await #expect(model.values[.root] == 3) - } - - @Test - func testGettingMissingEffectCancelledValue() async throws { - try await withKnownIssue { - try await withProbing { - await interactor.callWithIndependentEffects() - } dispatchedBy: { dispatcher in - do { - await #expect(model.values.isEmpty) - try dispatcher.getCancelledValue(fromEffect: "test", as: Void.self) - } catch { - await #expect(model.values.isEmpty) - throw error - } - } - } matching: { issue in - issue.didRecordError(ProbingErrors.EffectNotFound.self) - } - await #expect(model.values[.root] == 3) - } - - @Test(arguments: ProbingOptions.all) - func testRunningUpToMissingProbe(options: ProbingOptions) async throws { - try await withKnownIssue { - try await withProbing(options: options) { - await interactor.callWithIndependentEffects() - } dispatchedBy: { dispatcher in - do { - await #expect(model.values.isEmpty) - try await dispatcher.runUpToProbe("test") - } catch { - await #expect(model.values[.root] == 3) - throw error - } - } - } matching: { issue in - issue.didRecordError(ProbingErrors.ProbeNotInstalled.self) - } - } - - @Test(arguments: ProbingOptions.all) - func testRunningUpToMissingProbeInEffect(options: ProbingOptions) async throws { - try await withKnownIssue { - try await withProbing(options: options) { - await interactor.callWithIndependentEffects() - } dispatchedBy: { dispatcher in - do { - await #expect(model.values.isEmpty) - try await dispatcher.runUpToProbe(inEffect: "test") - } catch { - await #expect(model.values[.root] == 3) - throw error - } - } - } matching: { issue in - issue.didRecordError(ProbingErrors.ChildEffectNotCreated.self) - } - } - - @Test( - arguments: [true, false], - ProbingOptions.all - ) - func testRunningUntilMissingEffectCompleted( - includingDescendants: Bool, - options: ProbingOptions - ) async throws { - try await withKnownIssue { - try await withProbing(options: options) { - await interactor.callWithIndependentEffects() - } dispatchedBy: { dispatcher in - do { - await #expect(model.values.isEmpty) - try await dispatcher.runUntilEffectCompleted( - "test", - includingDescendants: includingDescendants - ) - } catch { - await #expect(model.values[.root] == 3) - throw error - } - } - } matching: { issue in - issue.didRecordError(ProbingErrors.ChildEffectNotCreated.self) - } - } -} - -extension EffectTests { - - final class Nested: EffectTests { - - @Test - func testRunningThroughProbes() async throws { - try await withProbing { - await interactor.callWithNestedEffects() - } dispatchedBy: { dispatcher in - await #expect(model.values.isEmpty) - try await dispatcher.runUpToProbe("1") - await #expect( - model.values == [ - .root: 1 - ] - ) - - try await dispatcher.runUpToProbe("2") - await #expect( - model.values == [ - .root: 2 - ] - ) - - try await dispatcher.runUntilExitOfBody() - await #expect( - model.values == [ - .root: 3 - ] - ) - - try await dispatcher.runUpToProbe("1.1") - await #expect( - model.values == [ - .root: 3, - "1": 1 - ] - ) - - try await dispatcher.runUpToProbe("1.2") - await #expect( - model.values == [ - .root: 3, - "1": 2 - ] - ) - - try await dispatcher.runUntilEffectCompleted("1") - await #expect( - model.values == [ - .root: 3, - "1": 3 - ] - ) - - try await dispatcher.runUpToProbe("1.1.1") - await #expect( - model.values == [ - .root: 3, - "1": 3, - "1.1": 1 - ] - ) - - try await dispatcher.runUpToProbe("1.1.2") - await #expect( - model.values == [ - .root: 3, - "1": 3, - "1.1": 2 - ] - ) - - try await dispatcher.runUntilEffectCompleted("1.1") - await #expect( - model.values == [ - .root: 3, - "1": 3, - "1.1": 3 - ] - ) - } - } - - @Test( - arguments: Array(product(1 ..< 3, 1 ..< 3)), - 1 ..< 3 - ) - func testRunningToProbe( - inEffect effect: (parent: Int, child: Int), - withNumber number: Int - ) async throws { - try await withProbing { - await interactor.callWithNestedEffects() - } dispatchedBy: { dispatcher in - await #expect(model.values.isEmpty) - try await dispatcher.runUpToProbe("\(effect.parent).\(effect.child).\(number)") - await #expect( - model.values == [ - .root: effect.parent, - "\(effect.parent)": effect.child, - "\(effect.parent).\(effect.child)": number - ] - ) - - try await dispatcher.runUntilEffectCompleted("\(effect.parent).\(effect.child)") - await #expect( - model.values == [ - .root: effect.parent, - "\(effect.parent)": effect.child, - "\(effect.parent).\(effect.child)": 3 - ] - ) - - try await dispatcher.runUntilEffectCompleted("\(effect.parent)") - await #expect( - model.values == [ - .root: effect.parent, - "\(effect.parent)": 3, - "\(effect.parent).\(effect.child)": 3 - ] - ) - - try await dispatcher.runUntilEffectCompleted( - "\(effect.parent)", - includingDescendants: true - ) - await #expect( - model.values == [ - .root: effect.parent, - "\(effect.parent)": 3, - "\(effect.parent).1": 3, - "\(effect.parent).2": 3 - ] - ) - } - await #expect(model.values[.root, default: 0] == 3) - } - } -} - -extension EffectTests.Nested { - - @Test - func testRunningWithoutDispatches() async throws { - try await withProbing { - await interactor.callWithNestedEffects() - } dispatchedBy: { _ in - await #expect(model.values.isEmpty) - } - await #expect(model.values[.root] == 3) - } - - @Test - func testRunningUntilExitOfBody() async throws { - try await withProbing { - await interactor.callWithNestedEffects() - } dispatchedBy: { dispatcher in - await #expect(model.values.isEmpty) - try await dispatcher.runUntilExitOfBody() - await #expect(model.values == [.root: 3]) - } - } - - @Test - func testRunningUntilEverythingCompleted() async throws { - try await withProbing { - await interactor.callWithNestedEffects() - } dispatchedBy: { dispatcher in - await #expect(model.values.isEmpty) - try await dispatcher.runUntilEverythingCompleted() - await #expect( - model.values == [ - .root: 3, - "1": 3, - "1.1": 3, - "1.2": 3, - "2": 3, - "2.1": 3, - "2.2": 3 - ] - ) - } - } - - @Test - func testGettingMissingEffectValue() async throws { - try await withKnownIssue { - try await withProbing { - await interactor.callWithNestedEffects() - } dispatchedBy: { dispatcher in - do { - await #expect(model.values.isEmpty) - try dispatcher.getValue(fromEffect: "1.2.test", as: Void.self) - } catch { - await #expect(model.values.isEmpty) - throw error - } - } - } matching: { issue in - issue.didRecordError(ProbingErrors.EffectNotFound.self) - } - await #expect(model.values[.root] == 3) - } - - @Test - func testGettingMissingEffectCancelledValue() async throws { - try await withKnownIssue { - try await withProbing { - await interactor.callWithNestedEffects() - } dispatchedBy: { dispatcher in - do { - await #expect(model.values.isEmpty) - try dispatcher.getCancelledValue(fromEffect: "1.2.test", as: Void.self) - } catch { - await #expect(model.values.isEmpty) - throw error - } - } - } matching: { issue in - issue.didRecordError(ProbingErrors.EffectNotFound.self) - } - await #expect(model.values[.root] == 3) - } - - @Test(arguments: ProbingOptions.all) - func testRunningUpToMissingProbe(options: ProbingOptions) async throws { - try await withKnownIssue { - try await withProbing(options: options) { - await interactor.callWithNestedEffects() - } dispatchedBy: { dispatcher in - do { - await #expect(model.values.isEmpty) - try await dispatcher.runUpToProbe("1.2.test") - } catch { - await #expect(model.values[.root, default: 0] >= 1) - await #expect(model.values["1", default: 0] >= 2) - await #expect(model.values["1.2", default: 0] == 3) - throw error - } - } - } matching: { issue in - issue.didRecordError(ProbingErrors.ProbeNotInstalled.self) - } - await #expect(model.values[.root] == 3) - } - - @Test(arguments: ProbingOptions.all) - func testRunningUpToMissingProbeInEffect(options: ProbingOptions) async throws { - try await withKnownIssue { - try await withProbing(options: options) { - await interactor.callWithNestedEffects() - } dispatchedBy: { dispatcher in - do { - await #expect(model.values.isEmpty) - try await dispatcher.runUpToProbe(inEffect: "1.2.test") - } catch { - await #expect(model.values[.root, default: 0] >= 1) - await #expect(model.values["1", default: 0] >= 2) - await #expect(model.values["1.2", default: 0] == 3) - throw error - } - } - } matching: { issue in - issue.didRecordError(ProbingErrors.ChildEffectNotCreated.self) - } - await #expect(model.values[.root] == 3) - } - - @Test( - arguments: [true, false], - ProbingOptions.all - ) - func testRunningUntilMissingEffectCompleted( - includingDescendants: Bool, - options: ProbingOptions - ) async throws { - try await withKnownIssue { - try await withProbing(options: options) { - await interactor.callWithNestedEffects() - } dispatchedBy: { dispatcher in - do { - await #expect(model.values.isEmpty) - try await dispatcher.runUntilEffectCompleted( - "1.2.test", - includingDescendants: includingDescendants - ) - } catch { - await #expect(model.values[.root, default: 0] >= 1) - await #expect(model.values["1", default: 0] >= 2) - await #expect(model.values["1.2", default: 0] == 3) - throw error - } - } - } matching: { issue in - issue.didRecordError(ProbingErrors.ChildEffectNotCreated.self) - } - await #expect(model.values[.root] == 3) - } -} - extension EffectTests { @MainActor - private final class IsolatedModel { + final class IsolatedModel { private(set) var values = [EffectIdentifier: Int]() @@ -757,16 +190,16 @@ extension EffectTests { } @MainActor - private final class IsolatedInteractor { + final class IsolatedInteractor { - private let model: IsolatedModel + let model: IsolatedModel init(model: IsolatedModel) { self.model = model } @discardableResult - private func makeEffect(_ name: EffectName) -> any Effect { + func makeEffect(_ name: EffectName) -> any Effect { #Effect(name) { self.model.tick() await #probe("1") @@ -791,32 +224,6 @@ extension EffectTests { effect.cancel() } - func callWithIndependentEffects() async { - model.tick() - makeEffect("1") - await #probe("1") - model.tick() - makeEffect("2") - await #probe("2") - model.tick() - } - - func callWithNestedEffects() async { - model.tick() - #Effect("1") { - await self.callWithIndependentEffects() - } - - await #probe("1") - model.tick() - #ConcurrentEffect("2") { - await self.callWithIndependentEffects() - } - - await #probe("2") - model.tick() - } - func callWithAmbiguousEffects() async { #Effect("ambiguous") { #Effect("nested") { -1 } diff --git a/Tests/ProbeTestingTests/Suites/IndependentEffectsTests.swift b/Tests/ProbeTestingTests/Suites/IndependentEffectsTests.swift new file mode 100644 index 0000000..443639f --- /dev/null +++ b/Tests/ProbeTestingTests/Suites/IndependentEffectsTests.swift @@ -0,0 +1,305 @@ +// +// IndependentEffectsTests.swift +// Probing +// +// Created by Kamil Strzelecki on 26/04/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +@testable import ProbeTesting +@testable import Probing +import Algorithms +import Testing + +internal final class IndependentEffectsTests: EffectTests { + + @Test + func testRunningThroughProbes() async throws { + try await withProbing { + await interactor.callWithIndependentEffects() + } dispatchedBy: { dispatcher in + await #expect(model.values.isEmpty) + try await dispatcher.runUpToProbe("1") + await #expect( + model.values == [ + .root: 1 + ] + ) + + try await dispatcher.runUpToProbe("2") + await #expect( + model.values == [ + .root: 2 + ] + ) + + try await dispatcher.runUntilExitOfBody() + await #expect( + model.values == [ + .root: 3 + ] + ) + + try await dispatcher.runUpToProbe("1.1") + await #expect( + model.values == [ + .root: 3, + "1": 1 + ] + ) + + try await dispatcher.runUpToProbe("1.2") + await #expect( + model.values == [ + .root: 3, + "1": 2 + ] + ) + + try await dispatcher.runUntilEffectCompleted("1") + await #expect( + model.values == [ + .root: 3, + "1": 3 + ] + ) + + try await dispatcher.runUpToProbe("2.1") + await #expect( + model.values == [ + .root: 3, + "1": 3, + "2": 1 + ] + ) + + try await dispatcher.runUpToProbe("2.2") + await #expect( + model.values == [ + .root: 3, + "1": 3, + "2": 2 + ] + ) + + try await dispatcher.runUntilEffectCompleted("2") + await #expect( + model.values == [ + .root: 3, + "1": 3, + "2": 3 + ] + ) + } + } + + @Test( + arguments: 1 ..< 3, + 1 ..< 3 + ) + func testRunningToProbe( + inEffect effect: Int, + withNumber number: Int + ) async throws { + try await withProbing { + await interactor.callWithIndependentEffects() + } dispatchedBy: { dispatcher in + await #expect(model.values.isEmpty) + try await dispatcher.runUpToProbe("\(effect).\(number)") + await #expect( + model.values == [ + .root: effect, + "\(effect)": number + ] + ) + + try await dispatcher.runUntilEffectCompleted("\(effect)") + await #expect( + model.values == [ + .root: effect, + "\(effect)": 3 + ] + ) + } + await #expect(model.values[.root, default: 0] == 3) + } + + @Test + func testNameEnumeration() async throws { + try await withProbing { + await interactor.callWithIndependentEnumeratedEffects() + } dispatchedBy: { dispatcher in + try await dispatcher.runUntilExitOfBody() + try await dispatcher.runUntilEffectCompleted("effect0") + await #expect(model.values == ["effect0": 3]) + + try await dispatcher.runUntilEffectCompleted("effect1") + await #expect(model.values == ["effect0": 3, "effect1": 3]) + } + } +} + +extension IndependentEffectsTests { + + @Test + func testRunningWithoutDispatches() async throws { + try await withProbing { + await interactor.callWithIndependentEffects() + } dispatchedBy: { _ in + await #expect(model.values.isEmpty) + } + await #expect(model.values[.root] == 3) + } + + @Test + func testRunningUntilExitOfBody() async throws { + try await withProbing { + await interactor.callWithIndependentEffects() + } dispatchedBy: { dispatcher in + await #expect(model.values.isEmpty) + try await dispatcher.runUntilExitOfBody() + await #expect(model.values == [.root: 3]) + } + } + + @Test + func testRunningUntilEverythingCompleted() async throws { + try await withProbing { + await interactor.callWithIndependentEffects() + } dispatchedBy: { dispatcher in + await #expect(model.values.isEmpty) + try await dispatcher.runUntilEverythingCompleted() + await #expect( + model.values == [ + .root: 3, + "1": 3, + "2": 3 + ] + ) + } + } + + @Test + func testGettingMissingEffectValue() async throws { + try await withKnownIssue { + try await withProbing { + await interactor.callWithIndependentEffects() + } dispatchedBy: { dispatcher in + do { + await #expect(model.values.isEmpty) + try dispatcher.getValue(fromEffect: "test", as: Void.self) + } catch { + await #expect(model.values.isEmpty) + throw error + } + } + } matching: { issue in + issue.didRecordError(ProbingErrors.EffectNotFound.self) + } + await #expect(model.values[.root] == 3) + } + + @Test + func testGettingMissingEffectCancelledValue() async throws { + try await withKnownIssue { + try await withProbing { + await interactor.callWithIndependentEffects() + } dispatchedBy: { dispatcher in + do { + await #expect(model.values.isEmpty) + try dispatcher.getCancelledValue(fromEffect: "test", as: Void.self) + } catch { + await #expect(model.values.isEmpty) + throw error + } + } + } matching: { issue in + issue.didRecordError(ProbingErrors.EffectNotFound.self) + } + await #expect(model.values[.root] == 3) + } + + @Test(arguments: ProbingOptions.all) + func testRunningUpToMissingProbe(options: ProbingOptions) async throws { + try await withKnownIssue { + try await withProbing(options: options) { + await interactor.callWithIndependentEffects() + } dispatchedBy: { dispatcher in + do { + await #expect(model.values.isEmpty) + try await dispatcher.runUpToProbe("test") + } catch { + await #expect(model.values[.root] == 3) + throw error + } + } + } matching: { issue in + issue.didRecordError(ProbingErrors.ProbeNotInstalled.self) + } + } + + @Test(arguments: ProbingOptions.all) + func testRunningUpToMissingProbeInEffect(options: ProbingOptions) async throws { + try await withKnownIssue { + try await withProbing(options: options) { + await interactor.callWithIndependentEffects() + } dispatchedBy: { dispatcher in + do { + await #expect(model.values.isEmpty) + try await dispatcher.runUpToProbe(inEffect: "test") + } catch { + await #expect(model.values[.root] == 3) + throw error + } + } + } matching: { issue in + issue.didRecordError(ProbingErrors.ChildEffectNotCreated.self) + } + } + + @Test( + arguments: [true, false], + ProbingOptions.all + ) + func testRunningUntilMissingEffectCompleted( + includingDescendants: Bool, + options: ProbingOptions + ) async throws { + try await withKnownIssue { + try await withProbing(options: options) { + await interactor.callWithIndependentEffects() + } dispatchedBy: { dispatcher in + do { + await #expect(model.values.isEmpty) + try await dispatcher.runUntilEffectCompleted( + "test", + includingDescendants: includingDescendants + ) + } catch { + await #expect(model.values[.root] == 3) + throw error + } + } + } matching: { issue in + issue.didRecordError(ProbingErrors.ChildEffectNotCreated.self) + } + } +} + +extension EffectTests.IsolatedInteractor { + + func callWithIndependentEffects() async { + model.tick() + makeEffect("1") + await #probe("1") + model.tick() + makeEffect("2") + await #probe("2") + model.tick() + } + + func callWithIndependentEnumeratedEffects() { + makeEffect(.enumerated("effect")) + makeEffect(.enumerated("effect")) + } +} diff --git a/Tests/ProbeTestingTests/Suites/NestedEffectsTests.swift b/Tests/ProbeTestingTests/Suites/NestedEffectsTests.swift new file mode 100644 index 0000000..052e2b2 --- /dev/null +++ b/Tests/ProbeTestingTests/Suites/NestedEffectsTests.swift @@ -0,0 +1,376 @@ +// +// NestedEffectsTests.swift +// Probing +// +// Created by Kamil Strzelecki on 26/04/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +@testable import ProbeTesting +@testable import Probing +import Algorithms +import Testing + +internal final class NestedEffectsTests: EffectTests { + + @Test + func testRunningThroughProbes() async throws { + try await withProbing { + await interactor.callWithNestedEffects() + } dispatchedBy: { dispatcher in + await #expect(model.values.isEmpty) + try await dispatcher.runUpToProbe("1") + await #expect( + model.values == [ + .root: 1 + ] + ) + + try await dispatcher.runUpToProbe("2") + await #expect( + model.values == [ + .root: 2 + ] + ) + + try await dispatcher.runUntilExitOfBody() + await #expect( + model.values == [ + .root: 3 + ] + ) + + try await dispatcher.runUpToProbe("1.1") + await #expect( + model.values == [ + .root: 3, + "1": 1 + ] + ) + + try await dispatcher.runUpToProbe("1.2") + await #expect( + model.values == [ + .root: 3, + "1": 2 + ] + ) + + try await dispatcher.runUntilEffectCompleted("1") + await #expect( + model.values == [ + .root: 3, + "1": 3 + ] + ) + + try await dispatcher.runUpToProbe("1.1.1") + await #expect( + model.values == [ + .root: 3, + "1": 3, + "1.1": 1 + ] + ) + + try await dispatcher.runUpToProbe("1.1.2") + await #expect( + model.values == [ + .root: 3, + "1": 3, + "1.1": 2 + ] + ) + + try await dispatcher.runUntilEffectCompleted("1.1") + await #expect( + model.values == [ + .root: 3, + "1": 3, + "1.1": 3 + ] + ) + } + } + + @Test( + arguments: Array(product(1 ..< 3, 1 ..< 3)), + 1 ..< 3 + ) + func testRunningToProbe( + inEffect effect: (parent: Int, child: Int), + withNumber number: Int + ) async throws { + try await withProbing { + await interactor.callWithNestedEffects() + } dispatchedBy: { dispatcher in + await #expect(model.values.isEmpty) + try await dispatcher.runUpToProbe("\(effect.parent).\(effect.child).\(number)") + await #expect( + model.values == [ + .root: effect.parent, + "\(effect.parent)": effect.child, + "\(effect.parent).\(effect.child)": number + ] + ) + + try await dispatcher.runUntilEffectCompleted("\(effect.parent).\(effect.child)") + await #expect( + model.values == [ + .root: effect.parent, + "\(effect.parent)": effect.child, + "\(effect.parent).\(effect.child)": 3 + ] + ) + + try await dispatcher.runUntilEffectCompleted("\(effect.parent)") + await #expect( + model.values == [ + .root: effect.parent, + "\(effect.parent)": 3, + "\(effect.parent).\(effect.child)": 3 + ] + ) + + try await dispatcher.runUntilEffectCompleted( + "\(effect.parent)", + includingDescendants: true + ) + await #expect( + model.values == [ + .root: effect.parent, + "\(effect.parent)": 3, + "\(effect.parent).1": 3, + "\(effect.parent).2": 3 + ] + ) + } + await #expect(model.values[.root, default: 0] == 3) + } + + @Test + func testNameEnumeration() async throws { + try await withProbing { + await interactor.callWithNestedEnumeratedEffects() + } dispatchedBy: { dispatcher in + try await dispatcher.runUntilExitOfBody() + try await dispatcher.runUntilEffectCompleted("name0.effect0") + await #expect(model.values == ["name0.effect0": 3]) + + try await dispatcher.runUntilEffectCompleted("name1.effect1") + await #expect( + model.values == [ + "name0.effect0": 3, + "name1.effect1": 3 + ] + ) + + try await dispatcher.runUntilEffectCompleted("name0", includingDescendants: true) + await #expect( + model.values == [ + "name0.effect0": 3, + "name0.effect1": 3, + "name1.effect1": 3 + ] + ) + + try await dispatcher.runUntilEffectCompleted("name1", includingDescendants: true) + await #expect( + model.values == [ + "name0.effect0": 3, + "name0.effect1": 3, + "name1.effect0": 3, + "name1.effect1": 3 + ] + ) + } + } +} + +extension NestedEffectsTests { + + @Test + func testRunningWithoutDispatches() async throws { + try await withProbing { + await interactor.callWithNestedEffects() + } dispatchedBy: { _ in + await #expect(model.values.isEmpty) + } + await #expect(model.values[.root] == 3) + } + + @Test + func testRunningUntilExitOfBody() async throws { + try await withProbing { + await interactor.callWithNestedEffects() + } dispatchedBy: { dispatcher in + await #expect(model.values.isEmpty) + try await dispatcher.runUntilExitOfBody() + await #expect(model.values == [.root: 3]) + } + } + + @Test + func testRunningUntilEverythingCompleted() async throws { + try await withProbing { + await interactor.callWithNestedEffects() + } dispatchedBy: { dispatcher in + await #expect(model.values.isEmpty) + try await dispatcher.runUntilEverythingCompleted() + await #expect( + model.values == [ + .root: 3, + "1": 3, + "1.1": 3, + "1.2": 3, + "2": 3, + "2.1": 3, + "2.2": 3 + ] + ) + } + } + + @Test + func testGettingMissingEffectValue() async throws { + try await withKnownIssue { + try await withProbing { + await interactor.callWithNestedEffects() + } dispatchedBy: { dispatcher in + do { + await #expect(model.values.isEmpty) + try dispatcher.getValue(fromEffect: "1.2.test", as: Void.self) + } catch { + await #expect(model.values.isEmpty) + throw error + } + } + } matching: { issue in + issue.didRecordError(ProbingErrors.EffectNotFound.self) + } + await #expect(model.values[.root] == 3) + } + + @Test + func testGettingMissingEffectCancelledValue() async throws { + try await withKnownIssue { + try await withProbing { + await interactor.callWithNestedEffects() + } dispatchedBy: { dispatcher in + do { + await #expect(model.values.isEmpty) + try dispatcher.getCancelledValue(fromEffect: "1.2.test", as: Void.self) + } catch { + await #expect(model.values.isEmpty) + throw error + } + } + } matching: { issue in + issue.didRecordError(ProbingErrors.EffectNotFound.self) + } + await #expect(model.values[.root] == 3) + } + + @Test(arguments: ProbingOptions.all) + func testRunningUpToMissingProbe(options: ProbingOptions) async throws { + try await withKnownIssue { + try await withProbing(options: options) { + await interactor.callWithNestedEffects() + } dispatchedBy: { dispatcher in + do { + await #expect(model.values.isEmpty) + try await dispatcher.runUpToProbe("1.2.test") + } catch { + await #expect(model.values[.root, default: 0] >= 1) + await #expect(model.values["1", default: 0] >= 2) + await #expect(model.values["1.2", default: 0] == 3) + throw error + } + } + } matching: { issue in + issue.didRecordError(ProbingErrors.ProbeNotInstalled.self) + } + await #expect(model.values[.root] == 3) + } + + @Test(arguments: ProbingOptions.all) + func testRunningUpToMissingProbeInEffect(options: ProbingOptions) async throws { + try await withKnownIssue { + try await withProbing(options: options) { + await interactor.callWithNestedEffects() + } dispatchedBy: { dispatcher in + do { + await #expect(model.values.isEmpty) + try await dispatcher.runUpToProbe(inEffect: "1.2.test") + } catch { + await #expect(model.values[.root, default: 0] >= 1) + await #expect(model.values["1", default: 0] >= 2) + await #expect(model.values["1.2", default: 0] == 3) + throw error + } + } + } matching: { issue in + issue.didRecordError(ProbingErrors.ChildEffectNotCreated.self) + } + await #expect(model.values[.root] == 3) + } + + @Test( + arguments: [true, false], + ProbingOptions.all + ) + func testRunningUntilMissingEffectCompleted( + includingDescendants: Bool, + options: ProbingOptions + ) async throws { + try await withKnownIssue { + try await withProbing(options: options) { + await interactor.callWithNestedEffects() + } dispatchedBy: { dispatcher in + do { + await #expect(model.values.isEmpty) + try await dispatcher.runUntilEffectCompleted( + "1.2.test", + includingDescendants: includingDescendants + ) + } catch { + await #expect(model.values[.root, default: 0] >= 1) + await #expect(model.values["1", default: 0] >= 2) + await #expect(model.values["1.2", default: 0] == 3) + throw error + } + } + } matching: { issue in + issue.didRecordError(ProbingErrors.ChildEffectNotCreated.self) + } + await #expect(model.values[.root] == 3) + } +} + +extension EffectTests.IsolatedInteractor { + + func callWithNestedEffects() async { + model.tick() + #Effect("1") { + await self.callWithIndependentEffects() + } + + await #probe("1") + model.tick() + #ConcurrentEffect("2") { + await self.callWithIndependentEffects() + } + + await #probe("2") + model.tick() + } + + func callWithNestedEnumeratedEffects() { + #Effect(.enumerated("name")) { + self.callWithIndependentEnumeratedEffects() + } + #ConcurrentEffect(.enumerated("name")) { + await self.callWithIndependentEnumeratedEffects() + } + } +} diff --git a/Tests/ProbeTestingTests/Suites/ProbeTests.swift b/Tests/ProbeTestingTests/Suites/ProbeTests.swift index 3c2e7a1..9fdec25 100644 --- a/Tests/ProbeTestingTests/Suites/ProbeTests.swift +++ b/Tests/ProbeTestingTests/Suites/ProbeTests.swift @@ -219,6 +219,21 @@ extension ProbeTests { } } } + + @Test + func testThrowingLateInTest() async { + await #expect(throws: ErrorMock.self) { + try await confirmation { confirmation in + try await withProbing { + await interactor.callWithDefaultProbes() + confirmation() + } dispatchedBy: { dispatcher in + try await dispatcher.runUpToProbe() + throw ErrorMock() + } + } + } + } } extension ProbeTests { diff --git a/Tests/ProbeTestingTests/Suites/TaskGroupTests.swift b/Tests/ProbeTestingTests/Suites/TaskGroupTests.swift new file mode 100644 index 0000000..9bb8ffe --- /dev/null +++ b/Tests/ProbeTestingTests/Suites/TaskGroupTests.swift @@ -0,0 +1,37 @@ +// +// TaskGroupTests.swift +// Probing +// +// Created by Kamil Strzelecki on 12/05/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +@testable import ProbeTesting +@testable import Probing +import Testing + +internal struct TaskGroupTests { + + private let childrenCount = 100 + + @Test + func testCollectingChildResults() async throws { + try await withProbing { + await withTaskGroup(of: Void.self) { group in + for _ in 0 ..< childrenCount { + group.addTask { + await #probe() + } + } + for await _ in group { + await #probe() + } + } + } dispatchedBy: { dispatcher in + for _ in 0 ..< childrenCount { + try await dispatcher.runUpToProbe() + } + try await dispatcher.runUntilExitOfBody() + } + } +} diff --git a/Tests/ProbeTestingTests/Suites/WithProbingTests.swift b/Tests/ProbeTestingTests/Suites/WithProbingTests.swift index a31365c..312364d 100644 --- a/Tests/ProbeTestingTests/Suites/WithProbingTests.swift +++ b/Tests/ProbeTestingTests/Suites/WithProbingTests.swift @@ -186,7 +186,7 @@ extension WithProbingTests { } @Test - func testThrowingWhileTesting() async { + func testThrowingEarlyInTest() async { await #expect(throws: ErrorMock.self) { try await confirmation { confirmation in try await withProbing { @@ -197,9 +197,15 @@ extension WithProbingTests { } } } -} -extension WithProbingTests { + @Test + func testProbingInTest() async throws { + try await withProbing { + // Void + } dispatchedBy: { _ in + await #probe() + } + } @CustomActor @Test @@ -214,7 +220,7 @@ extension WithProbingTests { @CustomActor @Test - func testIsolationWhileTesting() async throws { + func testIsolationInTest() async throws { try await withProbing { // Void } dispatchedBy: { _ in diff --git a/Tests/ProbingTests/EffectNameTests.swift b/Tests/ProbingTests/EffectNameTests.swift new file mode 100644 index 0000000..ec09939 --- /dev/null +++ b/Tests/ProbingTests/EffectNameTests.swift @@ -0,0 +1,38 @@ +// +// EffectNameTests.swift +// Probing +// +// Created by Kamil Strzelecki on 10/05/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +@testable import Probing +import Testing + +internal struct EffectNameTests { + + @Test + func testEquality() { + let name: EffectName = "effect" + let enumeratedName = EffectName.enumerated(name) + + #expect(name == enumeratedName) + #expect(name.hashValue == enumeratedName.hashValue) + } + + @Test + func testIndexing() { + let name: EffectName = "effect" + let enumeratedName = EffectName.enumerated(name) + + let index: UInt = 123 + let indexedName = name.withIndex(index) + let enumeratedIndexedName = enumeratedName.withIndex(index) + let literalName: EffectName = "effect123" + + #expect(indexedName == literalName) + #expect(indexedName == enumeratedIndexedName) + #expect(indexedName.hashValue == literalName.hashValue) + #expect(indexedName.hashValue == enumeratedIndexedName.hashValue) + } +} diff --git a/Tests/ProbingTests/EffectTests.swift b/Tests/ProbingTests/EffectTests.swift index 23fac90..21506cf 100644 --- a/Tests/ProbingTests/EffectTests.swift +++ b/Tests/ProbingTests/EffectTests.swift @@ -22,7 +22,7 @@ internal struct EffectTests { } #expect(effect is TestableEffect) - #expect(effect.erasedToAnyEffect().task == effect.task) + #expect(effect.eraseToAnyEffect().task == effect.task) await effect.value } } @@ -40,7 +40,7 @@ internal struct EffectTests { ) #expect(effect is Task) - #expect(effect.erasedToAnyEffect().task == effect.task) + #expect(effect.eraseToAnyEffect().task == effect.task) await effect.value } } @@ -67,7 +67,7 @@ internal struct EffectTests { } #expect(effect is TestableEffect) - #expect(effect.erasedToAnyEffect().task == effect.task) + #expect(effect.eraseToAnyEffect().task == effect.task) await effect.value } } @@ -86,7 +86,7 @@ internal struct EffectTests { ) #expect(effect is Task) - #expect(effect.erasedToAnyEffect().task == effect.task) + #expect(effect.eraseToAnyEffect().task == effect.task) await effect.value } } @@ -112,7 +112,7 @@ internal struct EffectTests { } #expect(effect is TestableEffect) - #expect(effect.erasedToAnyEffect().task == effect.task) + #expect(effect.eraseToAnyEffect().task == effect.task) await effect.value } } @@ -130,7 +130,7 @@ internal struct EffectTests { ) #expect(effect is Task) - #expect(effect.erasedToAnyEffect().task == effect.task) + #expect(effect.eraseToAnyEffect().task == effect.task) await effect.value } } @@ -159,7 +159,7 @@ internal struct EffectTests { } #expect(effect is TestableEffect>>) - #expect(effect.erasedToAnyEffect().task == effect.task) + #expect(effect.eraseToAnyEffect().task == effect.task) await effect.value.value.value } } @@ -176,7 +176,7 @@ internal struct EffectTests { } #expect(effect is Task>, Never>) - #expect(effect.erasedToAnyEffect().task == effect.task) + #expect(effect.eraseToAnyEffect().task == effect.task) await effect.value.value.value } }