Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
1262a03
Bump PrincipleMacros
NSFatalError May 9, 2025
a0ec05c
-
NSFatalError May 9, 2025
99b8fe5
Added DeeplyCopyable docs
NSFatalError May 7, 2025
13369d4
Added EquatableObject docs
NSFatalError May 7, 2025
2476957
-
NSFatalError May 9, 2025
a9b3ce5
[SwiftFormat] Applied formatting
NSFatalError May 9, 2025
8e62033
-
NSFatalError May 9, 2025
8efc382
-
NSFatalError May 10, 2025
41bc01f
-
NSFatalError May 10, 2025
ac99669
Added EffectName and ProbeName docs
NSFatalError May 10, 2025
c4ccc15
[SwiftFormat] Applied formatting
NSFatalError May 10, 2025
305beb6
Added Effect docs
NSFatalError May 10, 2025
a0fc820
-
NSFatalError May 10, 2025
a43de13
Added #Effect macro docs
NSFatalError May 10, 2025
4c27f33
[SwiftFormat] Applied formatting
NSFatalError May 10, 2025
43cbfd9
-
NSFatalError May 11, 2025
b781d2b
Completed Probing docs
NSFatalError May 11, 2025
a1756ab
Added ProbingDispatcher docs
NSFatalError May 11, 2025
bf1f0a1
[SwiftFormat] Applied formatting
NSFatalError May 11, 2025
bf7f899
-
NSFatalError May 11, 2025
304e7aa
-
NSFatalError May 11, 2025
e6e9f70
-
NSFatalError May 12, 2025
5cad705
-
NSFatalError May 12, 2025
a367f4a
-
NSFatalError May 12, 2025
909e1d6
-
NSFatalError May 12, 2025
166cfbd
Added ProbingOptions docs
NSFatalError May 12, 2025
8dd7dc0
Added withProbing docs
NSFatalError May 12, 2025
c730fa0
Added Examples to ProbeTesting
NSFatalError May 12, 2025
f335a82
Completed ProbeTesting docs
NSFatalError May 12, 2025
d44e967
Added Concurrency article
NSFatalError May 12, 2025
d7c4662
-
NSFatalError May 12, 2025
7b7be37
Added Readme
NSFatalError May 12, 2025
9862123
-
NSFatalError May 12, 2025
df862cf
-
NSFatalError May 12, 2025
92786dc
-
NSFatalError May 12, 2025
0c01e95
-
NSFatalError May 12, 2025
f0c879c
-
NSFatalError May 12, 2025
3a32a1a
[SwiftFormat] Applied formatting
NSFatalError May 12, 2025
9be7504
-
NSFatalError May 12, 2025
4c4ed0a
-
NSFatalError May 12, 2025
54af143
-
NSFatalError May 12, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 2 additions & 7 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
126 changes: 126 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,136 @@
</p>

<h1 align="center">Probing</h1>
<p align="center">Breakpoints for Swift Testing - precise control over side effects and execution suspension at any point.</p>

<p align="center">
<img src="https://img.shields.io/badge/Swift-6.1-EF5239?logo=swift&labelColor=white" />
<a href="https://codecov.io/gh/NSFatalError/Probing">
<img src="https://codecov.io/gh/NSFatalError/Probing/graph/badge.svg?token=CDPR2O8BZO" />
</a>
</p>

---

#### 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
51 changes: 51 additions & 0 deletions Sources/DeeplyCopyable/Documentation.docc/DeeplyCopyable.md
Original file line number Diff line number Diff line change
@@ -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``
24 changes: 23 additions & 1 deletion Sources/DeeplyCopyable/Protocols/DeeplyCopyable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:))
Expand All @@ -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)
}
Expand Down
66 changes: 33 additions & 33 deletions Sources/DeeplyCopyable/Protocols/DeeplyCopyableByAssignment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import Foundation

public protocol DeeplyCopyableByAssignment: DeeplyCopyable {}
internal protocol DeeplyCopyableByAssignment: DeeplyCopyable {}

extension DeeplyCopyableByAssignment {

Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions Sources/EquatableObject/Documentation.docc/EquatableObject.md
Original file line number Diff line number Diff line change
@@ -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()``
Loading
Loading