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
}
}