Skip to content

Commit f367d86

Browse files
authored
Minor documentation refinements (#5)
1 parent 45b63e0 commit f367d86

File tree

2 files changed

+203
-4
lines changed

2 files changed

+203
-4
lines changed

README.md

Lines changed: 201 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
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>
6+
<p align="center">Breakpoints for Swift Testing - precise control over side effects and fully observable state transitions in asynchronous functions</p>
77

88
<p align="center">
99
<a href="https://swiftpackageindex.com/NSFatalError/Probing"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FNSFatalError%2FProbing%2Fbadge%3Ftype%3Dswift-versions" /></a>
@@ -17,6 +17,7 @@
1717
- [What Problem Probing Solves?](#what-problem-probing-solves)
1818
- [How Probing Works?](#how-probing-works)
1919
- [Documentation & Sample Project](#documentation--sample-project)
20+
- [Examples](#examples)
2021
- [Installation](#installation)
2122

2223
## What Problem Probing Solves?
@@ -29,7 +30,7 @@ to inspect just the final output of the function. Inspecting the internal state
2930
is equally important but notoriously difficult.
3031
- **Non-determinism**: `Task` instances run concurrently and may complete in different orders each time, leading to unpredictable states.
3132
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.
32-
- **Limited runtime control**: Once an asynchronous function is running, influencing its behavior becomes nearly impossible.
33+
- **Limited runtime control**: Once an asynchronous function is running, influencing its behavior becomes hard.
3334
This limitation pushes developers to rely on ahead-of-time setups, like intricate mocks, which add complexity and reduce clarity of the test.
3435

3536
Over the years, the Swift community has introduced a number of tools to address these challenges, each with its own strengths:
@@ -56,7 +57,7 @@ conceptually similar to breakpoints, but accessible and targetable from your tes
5657
You can also define **effects**, which make `Task` instances controllable and predictable.
5758

5859
Then, with the help of `ProbeTesting`, you write a sequence of **dispatches** that advance your program to a desired state.
59-
This flattens the execution hierarchy of side effects, allowing you to write tests from the user’s perspective,
60+
This flattens the execution tree of side effects, allowing you to write tests from the user’s perspective,
6061
as a clear and deterministic flow of events:
6162

6263
```swift
@@ -95,6 +96,203 @@ Full documentation is available on the Swift Package Index:
9596

9697
You can download the `ProbingPlayground` sample project from its [GitHub page](https://github.com/NSFatalError/ProbingPlayground).
9798

99+
## Examples
100+
101+
The `CHANGED` and `ADDED` comments highlight how the view model in the examples
102+
has been adapted to support testing with `ProbeTesting`. As you can see, the required changes
103+
are small and don’t require any architectural shift.
104+
105+
### Observing State Transitions
106+
107+
```swift
108+
// ViewModel.swift
109+
110+
func uploadImage(_ item: ImageItem) async {
111+
do {
112+
uploadState = .uploading
113+
await #probe() // ADDED
114+
let image = try await item.loadImage()
115+
let processedImage = try await processor.processImage(image)
116+
try await uploader.uploadImage(processedImage)
117+
uploadState = .success
118+
} catch {
119+
uploadState = .error
120+
}
121+
122+
await #probe() // ADDED
123+
try? await Task.sleep(for: .seconds(3))
124+
uploadState = nil
125+
}
126+
```
127+
128+
```swift
129+
// ViewModelTests.swift
130+
131+
@Test
132+
func testUploadingImage() async throws {
133+
try await withProbing {
134+
await viewModel.uploadImage(ImageMock())
135+
} dispatchedBy: { dispatcher in
136+
#expect(viewModel.uploadState == nil)
137+
138+
try await dispatcher.runUpToProbe()
139+
#expect(uploader.uploadImageCallsCount == 0)
140+
#expect(viewModel.uploadState == .uploading)
141+
142+
try await dispatcher.runUpToProbe()
143+
#expect(uploader.uploadImageCallsCount == 1)
144+
#expect(viewModel.uploadState == .success)
145+
146+
try await dispatcher.runUntilExitOfBody()
147+
#expect(viewModel.uploadState == nil)
148+
}
149+
}
150+
```
151+
152+
### Just-in-Time Mocking
153+
154+
```swift
155+
// ViewModel.swift
156+
157+
func updateLocation() async {
158+
locationState = .unknown
159+
await #probe() // ADDED
160+
161+
do {
162+
for try await update in locationProvider.getUpdates() {
163+
try Task.checkCancellation()
164+
165+
if update.authorizationDenied {
166+
locationState = .error
167+
} else if let isNear = update.location?.isNearSanFrancisco() {
168+
locationState = isNear ? .near : .far
169+
} else {
170+
locationState = .unknown
171+
}
172+
await #probe() // ADDED
173+
}
174+
} catch {
175+
locationState = .error
176+
}
177+
}
178+
```
179+
180+
```swift
181+
// ViewModelTests.swift
182+
183+
@Test
184+
func testUpdatingLocation() async throws {
185+
try await withProbing {
186+
await viewModel.beginUpdatingLocation()
187+
} dispatchedBy: { dispatcher in
188+
#expect(viewModel.locationState == nil)
189+
190+
locationProvider.continuation.yield(.init(location: .sanFrancisco))
191+
try await dispatcher.runUpToProbe()
192+
#expect(viewModel.locationState == .near)
193+
194+
locationProvider.continuation.yield(.init(location: .init(latitude: 0, longitude: 0)))
195+
try await dispatcher.runUpToProbe()
196+
#expect(viewModel.locationState == .far)
197+
198+
locationProvider.continuation.yield(.init(location: .sanFrancisco))
199+
try await dispatcher.runUpToProbe()
200+
#expect(viewModel.locationState == .near)
201+
202+
locationProvider.continuation.yield(.init(location: nil, authorizationDenied: true))
203+
try await dispatcher.runUpToProbe()
204+
#expect(viewModel.locationState == .error)
205+
206+
locationProvider.continuation.yield(.init(location: .sanFrancisco))
207+
try await dispatcher.runUpToProbe()
208+
#expect(viewModel.locationState == .near)
209+
210+
locationProvider.continuation.finish(throwing: ErrorMock())
211+
try await dispatcher.runUntilExitOfBody()
212+
#expect(viewModel.locationState == .error)
213+
}
214+
}
215+
```
216+
217+
### Controlling Side Effects
218+
219+
```swift
220+
// ViewModel.swift
221+
222+
private var downloadImageEffects = [ImageQuality: any Effect<Void>]() // CHANGED
223+
224+
func downloadImage() {
225+
downloadImageEffects.values.forEach { $0.cancel() }
226+
downloadImageEffects.removeAll()
227+
downloadState = .downloading
228+
229+
downloadImage(withQuality: .low)
230+
downloadImage(withQuality: .high)
231+
}
232+
233+
private func downloadImage(withQuality quality: ImageQuality) {
234+
downloadImageEffects[quality] = #Effect("\(quality)") { // CHANGED
235+
defer {
236+
downloadImageEffects[quality] = nil
237+
}
238+
239+
do {
240+
let image = try await downloader.downloadImage(withQuality: quality)
241+
try Task.checkCancellation()
242+
imageDownloadSucceeded(with: image, quality: quality)
243+
} catch is CancellationError {
244+
return
245+
} catch {
246+
imageDownloadFailed()
247+
}
248+
}
249+
}
250+
```
251+
252+
```swift
253+
// ViewModelTests.swift
254+
255+
@Test
256+
func testDownloadingImage() async throws {
257+
try await withProbing {
258+
await viewModel.downloadImage()
259+
} dispatchedBy: { dispatcher in
260+
await #expect(viewModel.downloadState == nil)
261+
262+
try await dispatcher.runUntilExitOfBody()
263+
#expect(viewModel.downloadState?.isDownloading == true)
264+
265+
try await dispatcher.runUntilEffectCompleted("low")
266+
#expect(viewModel.downloadState?.quality == .low)
267+
268+
try await dispatcher.runUntilEffectCompleted("high")
269+
#expect(viewModel.downloadState?.quality == .high)
270+
}
271+
}
272+
273+
@Test
274+
func testDownloadingImageWhenLowQualityDownloadFailsFirst() async throws {
275+
try await withProbing {
276+
viewModel.downloadImage()
277+
} dispatchedBy: { dispatcher in
278+
#expect(viewModel.downloadState == nil)
279+
280+
try await dispatcher.runUntilExitOfBody()
281+
#expect(viewModel.downloadState?.isDownloading == true)
282+
283+
downloader.shouldFailDownload = true
284+
try await dispatcher.runUntilEffectCompleted("low")
285+
#expect(viewModel.downloadState?.isDownloading == true)
286+
287+
downloader.shouldFailDownload = false
288+
try await dispatcher.runUntilEffectCompleted("high")
289+
#expect(viewModel.downloadState?.quality == .high)
290+
}
291+
}
292+
293+
// ...
294+
```
295+
98296
## Installation
99297

100298
To use `Probing`, declare it as a dependency in your `Package.swift` or via Xcode project settings.

Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ import Testing
1919
/// - Explicitly, at declared `#probe()` macros within the `body` and nested effects
2020
/// - Implicitly, immediately after initializing effects with `#Effect` macros, preventing them from starting until required
2121
///
22-
/// This ensures that no part of the tested code runs concurrently with the expectations defined in the `test`.
22+
/// This ensures that no part of the tested code runs concurrently with the expectations defined in the `test`,
23+
/// as long as your code uses `#Effect` macros instead of the `Task` APIs.
2324
///
2425
/// - SeeAlso: For details on how probe and effect identifiers are constructed, see the `Probing` documentation.
2526
///

0 commit comments

Comments
 (0)