Skip to content

Commit 62383f6

Browse files
authored
Docs (#3)
1 parent b4bf65b commit 62383f6

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+2705
-1005
lines changed

README.md

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,136 @@
33
</p>
44

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

78
<p align="center">
89
<img src="https://img.shields.io/badge/Swift-6.1-EF5239?logo=swift&labelColor=white" />
910
<a href="https://codecov.io/gh/NSFatalError/Probing">
1011
<img src="https://codecov.io/gh/NSFatalError/Probing/graph/badge.svg?token=CDPR2O8BZO" />
1112
</a>
1213
</p>
14+
15+
---
16+
17+
#### Contents
18+
- [What Problem Probing Solves?](#what-problem-probing-solves)
19+
- [How Probing Works?](#how-probing-works)
20+
- [Documentation & Sample Project](#documentation--sample-project)
21+
- [Installation](#installation)
22+
23+
## What Problem Probing Solves?
24+
25+
Testing asynchronous code remains challenging, even with Swift Concurrency and Swift Testing.
26+
Some of the persistent difficulties include:
27+
28+
- **Hidden states**: When invoking methods on objects, often with complex dependencies between them, it’s not enough
29+
to inspect just the final output of the function. Inspecting the internal state changes during execution, such as loading states in view models,
30+
is equally important but notoriously difficult.
31+
- **Non-determinism**: `Task` instances run concurrently and may complete in different orders each time, leading to unpredictable states.
32+
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.
33+
- **Limited runtime control**: Once an asynchronous function is running, influencing its behavior becomes nearly impossible.
34+
This limitation pushes developers to rely on ahead-of-time setups, like intricate mocks, which add complexity and reduce clarity of the test.
35+
36+
Over the years, the Swift community has introduced a number of tools to address these challenges, each with its own strengths:
37+
38+
- **Quick/Nimble**: Polling with the designated matchers allows checking changes to hidden states,
39+
but it can lead to flaky tests and is generally not concurrency-safe.
40+
- **Combine/RxSwift**: Reactive paradigms are powerful, but they can be difficult to set up and may introduce unnecessary abstraction,
41+
especially now that `AsyncSequence` covers many use cases natively.
42+
- **ComposableArchitecture**: Provides a robust approach for testing UI logic, but it’s tightly coupled
43+
to its own architectural patterns and isn’t suited for other application layers.
44+
45+
These tools have pushed the ecosystem forward and work well within their intended contexts.
46+
Still, none provide a lightweight, general-purpose way to tackle all of the listed problems that embraces the Swift Concurrency model.
47+
That's why I have designed and developed `Probing`.
48+
49+
## How Probing Works?
50+
51+
The `Probing` package consists of two main modules:
52+
- `Probing`, which you add as a dependency to the targets you want to test
53+
- `ProbeTesting`, which you add as a dependency to your test targets
54+
55+
With `Probing`, you can define **probes** - suspension points typically placed after a state change,
56+
conceptually similar to breakpoints, but accessible and targetable from your tests.
57+
You can also define **effects**, which make `Task` instances controllable and predictable.
58+
59+
Then, with the help of `ProbeTesting`, you write a sequence of **dispatches** that advance your program to a desired state.
60+
This flattens the execution hierarchy of side effects, allowing you to write tests from the user’s perspective,
61+
as a clear and deterministic flow of events:
62+
63+
```swift
64+
@Test
65+
func testLoading() async throws {
66+
try await withProbing {
67+
await viewModel.load()
68+
} dispatchedBy: { dispatcher in
69+
#expect(viewModel.isLoading == false)
70+
#expect(viewModel.download == nil)
71+
72+
try await dispatcher.runUpToProbe()
73+
#expect(viewModel.isLoading == true)
74+
#expect(viewModel.download == nil)
75+
76+
downloaderMock.shouldFailDownload = false
77+
try await dispatcher.runUntilExitOfBody()
78+
#expect(viewModel.isLoading == false)
79+
#expect(viewModel.download != nil)
80+
81+
#expect(viewModel.prefetchedData == nil)
82+
try await dispatcher.runUntilEffectCompleted("backgroundFetch")
83+
#expect(viewModel.prefetchedData != nil)
84+
}
85+
}
86+
```
87+
88+
`ProbeTesting` also includes robust error handling. It provides recovery suggestions for every error it throws,
89+
guiding you toward a solution and making it easier to get started with the API.
90+
91+
## Documentation & Sample Project
92+
93+
The public interface of `Probing` and `ProbeTesting` is relatively small, yet powerful.
94+
95+
You can download the `ProbingPlayground` sample project from its [GitHub page](https://github.com/NSFatalError/ProbingPlayground).
96+
97+
## Installation
98+
99+
To use `Probing`, declare it as a dependency in your `Package.swift` or via Xcode project settings.
100+
Add a dependency on `Probing` in the targets you want to test, and `ProbeTesting` in your test targets:
101+
102+
```swift
103+
let package = Package(
104+
name: "MyPackage",
105+
dependencies: [
106+
.package(
107+
url: "https://github.com/NSFatalError/Probing",
108+
from: "1.0.0"
109+
)
110+
],
111+
targets: [
112+
.target(
113+
name: "MyModule",
114+
dependencies: [
115+
.product(name: "Probing", package: "Probing")
116+
]
117+
),
118+
.testTarget(
119+
name: "MyModuleTests",
120+
dependencies: [
121+
"MyModule",
122+
.product(name: "ProbeTesting", package: "Probing")
123+
]
124+
)
125+
]
126+
)
127+
```
128+
129+
Supported platforms:
130+
- macOS 15.0 or later
131+
- iOS 18.0 or later
132+
- tvOS 18.0 or later
133+
- watchOS 11.0 or later
134+
- visionOS 2.0 or later
135+
136+
Other requirements:
137+
- Swift 6.1 or later
138+
- Xcode 16.3 or later
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# ``DeeplyCopyable-module``
2+
3+
Create copies of objects without sharing underlying storage, while remaining otherwise value-equal.
4+
5+
## Overview
6+
7+
In Swift, we can recognize three groups of `Copyable` types:
8+
- Value types that don't store any reference types (directly or indirectly):
9+
- Basic types, like `Bool`, `Int`, etc.
10+
- Enums without associated values, or with associated values that don't hold any reference types.
11+
- Structs without stored properties, or with stored properties that don't hold any reference types.
12+
- Value types that store reference types (directly or indirectly), optionally implementing copy-on-write mechanisms:
13+
- Many standard collection types, like `String`, `Array`, `Dictionary`, `Set`, etc.
14+
- Enums with associated values that hold reference types.
15+
- Structs with stored properties that hold reference types.
16+
- Reference types:
17+
- Classes
18+
- Actors
19+
20+
Instances of these types can be copied either explicitly (by assigning them to another variable) or implicitly (by passing them as arguments to functions).
21+
However, only the first group of types supports copying without sharing any underlying storage - in other words, they can be **deeply copied**.
22+
23+
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.
24+
The easiest way to add this functionality is to apply the ``DeeplyCopyable()`` macro to the type’s declaration:
25+
26+
```swift
27+
@DeeplyCopyable
28+
final class Person {
29+
let name: String
30+
var age: Int
31+
32+
init(name: String, age: Int) {
33+
self.name = name
34+
self.age = age
35+
}
36+
}
37+
38+
let person = Person(name: "Kamil", age: 25)
39+
let deepCopy = person.deepCopy()
40+
41+
person.age += 1
42+
print(person.age) // 26
43+
print(deepCopy.age) // 25
44+
```
45+
46+
## Topics
47+
48+
### Making Deep Copies
49+
50+
- ``DeeplyCopyable()``
51+
- ``DeeplyCopyable-protocol``

Sources/DeeplyCopyable/Protocols/DeeplyCopyable.swift

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
// Copyright © 2025 Kamil Strzelecki. All rights reserved.
77
//
88

9+
/// Defines and implements conformance to the ``DeeplyCopyable-protocol`` protocol for final classes, structs and enums.
10+
///
11+
/// - Important: If any stored property or associated value of the type is not ``DeeplyCopyable-protocol``, the code generated by the macro
12+
/// 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
13+
/// their conformance if they come from a third-party module.
14+
///
915
@attached(
1016
member,
1117
names: named(init(deeplyCopying:))
@@ -20,13 +26,29 @@ public macro DeeplyCopyable() = #externalMacro(
2026
type: "DeeplyCopyableMacro"
2127
)
2228

23-
public protocol DeeplyCopyable {
29+
/// A type whose instances can be copied without sharing underlying storage, while remaining otherwise value-equal.
30+
///
31+
/// A deep copy of an instance, created via ``init(deeplyCopying:)``, will ultimately not share any direct on indirect references
32+
/// with the original instance. As a result, mutations to one instance will never affect the other.
33+
///
34+
/// - Note: For types that use copy-on-write mechanism, underlying storage may be shared until the first mutation.
35+
/// This implementation detail does not affect any of the guarantees provided by `DeeplyCopyable` protocol.
36+
///
37+
public protocol DeeplyCopyable: Copyable {
2438

39+
/// Creates a new instance that is value-equal to `other`, but shares no underlying storage.
40+
///
41+
/// - Parameter other: The instance to deep copy.
42+
///
2543
init(deeplyCopying other: Self)
2644
}
2745

2846
extension DeeplyCopyable {
2947

48+
/// Creates a new instance that is value-equal to this instance, but shares no underlying storage.
49+
///
50+
/// - Returns: A deep copy of this instance.
51+
///
3052
public func deepCopy() -> Self {
3153
.init(deeplyCopying: self)
3254
}

Sources/DeeplyCopyable/Protocols/DeeplyCopyableByAssignment.swift

Lines changed: 33 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import Foundation
1010

11-
public protocol DeeplyCopyableByAssignment: DeeplyCopyable {}
11+
internal protocol DeeplyCopyableByAssignment: DeeplyCopyable {}
1212

1313
extension DeeplyCopyableByAssignment {
1414

@@ -17,57 +17,57 @@ extension DeeplyCopyableByAssignment {
1717
}
1818
}
1919

20-
extension Int: DeeplyCopyableByAssignment {}
21-
extension Int8: DeeplyCopyableByAssignment {}
22-
extension Int16: DeeplyCopyableByAssignment {}
23-
extension Int32: DeeplyCopyableByAssignment {}
24-
extension Int64: DeeplyCopyableByAssignment {}
25-
extension Int128: DeeplyCopyableByAssignment {}
20+
extension Int: DeeplyCopyable, DeeplyCopyableByAssignment {}
21+
extension Int8: DeeplyCopyable, DeeplyCopyableByAssignment {}
22+
extension Int16: DeeplyCopyable, DeeplyCopyableByAssignment {}
23+
extension Int32: DeeplyCopyable, DeeplyCopyableByAssignment {}
24+
extension Int64: DeeplyCopyable, DeeplyCopyableByAssignment {}
25+
extension Int128: DeeplyCopyable, DeeplyCopyableByAssignment {}
2626

27-
extension UInt: DeeplyCopyableByAssignment {}
28-
extension UInt8: DeeplyCopyableByAssignment {}
29-
extension UInt16: DeeplyCopyableByAssignment {}
30-
extension UInt32: DeeplyCopyableByAssignment {}
31-
extension UInt64: DeeplyCopyableByAssignment {}
32-
extension UInt128: DeeplyCopyableByAssignment {}
27+
extension UInt: DeeplyCopyable, DeeplyCopyableByAssignment {}
28+
extension UInt8: DeeplyCopyable, DeeplyCopyableByAssignment {}
29+
extension UInt16: DeeplyCopyable, DeeplyCopyableByAssignment {}
30+
extension UInt32: DeeplyCopyable, DeeplyCopyableByAssignment {}
31+
extension UInt64: DeeplyCopyable, DeeplyCopyableByAssignment {}
32+
extension UInt128: DeeplyCopyable, DeeplyCopyableByAssignment {}
3333

34-
extension Double: DeeplyCopyableByAssignment {}
35-
extension Float: DeeplyCopyableByAssignment {}
36-
extension Float16: DeeplyCopyableByAssignment {}
37-
extension Decimal: DeeplyCopyableByAssignment {}
34+
extension Double: DeeplyCopyable, DeeplyCopyableByAssignment {}
35+
extension Float: DeeplyCopyable, DeeplyCopyableByAssignment {}
36+
extension Float16: DeeplyCopyable, DeeplyCopyableByAssignment {}
37+
extension Decimal: DeeplyCopyable, DeeplyCopyableByAssignment {}
3838

39-
extension Bool: DeeplyCopyableByAssignment {}
40-
extension Character: DeeplyCopyableByAssignment {}
41-
extension Duration: DeeplyCopyableByAssignment {}
42-
extension UUID: DeeplyCopyableByAssignment {}
39+
extension Bool: DeeplyCopyable, DeeplyCopyableByAssignment {}
40+
extension Character: DeeplyCopyable, DeeplyCopyableByAssignment {}
41+
extension Duration: DeeplyCopyable, DeeplyCopyableByAssignment {}
42+
extension UUID: DeeplyCopyable, DeeplyCopyableByAssignment {}
4343

44-
extension URL: DeeplyCopyableByAssignment {}
45-
extension URLComponents: DeeplyCopyableByAssignment {}
46-
extension URLQueryItem: DeeplyCopyableByAssignment {}
44+
extension URL: DeeplyCopyable, DeeplyCopyableByAssignment {}
45+
extension URLComponents: DeeplyCopyable, DeeplyCopyableByAssignment {}
46+
extension URLQueryItem: DeeplyCopyable, DeeplyCopyableByAssignment {}
4747

48-
extension Date: DeeplyCopyableByAssignment {}
49-
extension DateComponents: DeeplyCopyableByAssignment {}
50-
extension TimeZone: DeeplyCopyableByAssignment {}
51-
extension Calendar: DeeplyCopyableByAssignment {}
48+
extension Date: DeeplyCopyable, DeeplyCopyableByAssignment {}
49+
extension DateComponents: DeeplyCopyable, DeeplyCopyableByAssignment {}
50+
extension TimeZone: DeeplyCopyable, DeeplyCopyableByAssignment {}
51+
extension Calendar: DeeplyCopyable, DeeplyCopyableByAssignment {}
5252

53-
extension Locale: DeeplyCopyableByAssignment {}
54-
extension PersonNameComponents: DeeplyCopyableByAssignment {}
53+
extension Locale: DeeplyCopyable, DeeplyCopyableByAssignment {}
54+
extension PersonNameComponents: DeeplyCopyable, DeeplyCopyableByAssignment {}
5555

56-
extension Data: DeeplyCopyableByAssignment {
56+
extension Data: DeeplyCopyable, DeeplyCopyableByAssignment {
5757

5858
public init(deeplyCopying other: Self) {
5959
self = other
6060
}
6161
}
6262

63-
extension String: DeeplyCopyableByAssignment {
63+
extension String: DeeplyCopyable, DeeplyCopyableByAssignment {
6464

6565
public init(deeplyCopying other: Self) {
6666
self = other
6767
}
6868
}
6969

70-
extension Substring: DeeplyCopyableByAssignment {
70+
extension Substring: DeeplyCopyable, DeeplyCopyableByAssignment {
7171

7272
public init(deeplyCopying other: Self) {
7373
self = other
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# ``EquatableObject``
2+
3+
Automatically generate property-wise `Equatable` conformance for `final` classes using a macro.
4+
5+
## Overview
6+
7+
Swift automatically synthesizes `Equatable` conformance for structs and enums, but not for classes.
8+
The ``EquatableObject()`` macro provides this functionality for `final` classes, allowing you to compare instances based on their stored properties.
9+
10+
## Topics
11+
12+
### Adding Equatable Conformance
13+
14+
- ``EquatableObject()``

Sources/EquatableObject/EquatableObject.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
// Copyright © 2025 Kamil Strzelecki. All rights reserved.
77
//
88

9+
/// Defines and implements conformance to the `Equatable` protocol for `final` classes.
10+
///
11+
/// - Important: If any stored property of the class is not `Equatable`, the code generated by the macro will fail to compile.
12+
///
913
@attached(
1014
member,
1115
names: named(==)

Sources/ProbeTesting/Dispatcher/AsyncSignal.swift

Lines changed: 0 additions & 30 deletions
This file was deleted.

0 commit comments

Comments
 (0)