From 0caaf8ba61dc69992ab726906d97521de10e3ae5 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Tue, 13 May 2025 20:08:39 +0200 Subject: [PATCH 1/2] - --- README.md | 4 ++-- Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1727730..49eaf68 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

Probing

-

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

+

Breakpoints for Swift Testing - precise control over side effects and fully observable state transitions in asynchronous functions

@@ -29,7 +29,7 @@ to inspect just the final output of the function. Inspecting the internal state 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. +- **Limited runtime control**: Once an asynchronous function is running, influencing its behavior becomes hard. 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: diff --git a/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift b/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift index 6119c4d..5a793c8 100644 --- a/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift +++ b/Sources/ProbeTesting/Dispatcher/ProbingDispatcher.swift @@ -19,7 +19,8 @@ import Testing /// - 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`. +/// This ensures that no part of the tested code runs concurrently with the expectations defined in the `test`, +/// as long as your code uses `#Effect` macros instead of the `Task` APIs. /// /// - SeeAlso: For details on how probe and effect identifiers are constructed, see the `Probing` documentation. /// From 7fa1dfb84d4a5f9a7f7bf68fed6bd94fdd8cbfea Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Tue, 13 May 2025 20:35:22 +0200 Subject: [PATCH 2/2] - --- README.md | 200 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 199 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 49eaf68..66acf88 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ - [What Problem Probing Solves?](#what-problem-probing-solves) - [How Probing Works?](#how-probing-works) - [Documentation & Sample Project](#documentation--sample-project) +- [Examples](#examples) - [Installation](#installation) ## What Problem Probing Solves? @@ -56,7 +57,7 @@ conceptually similar to breakpoints, but accessible and targetable from your tes 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, +This flattens the execution tree of side effects, allowing you to write tests from the user’s perspective, as a clear and deterministic flow of events: ```swift @@ -95,6 +96,203 @@ Full documentation is available on the Swift Package Index: You can download the `ProbingPlayground` sample project from its [GitHub page](https://github.com/NSFatalError/ProbingPlayground). +## Examples + +The `CHANGED` and `ADDED` comments highlight how the view model in the examples +has been adapted to support testing with `ProbeTesting`. As you can see, the required changes +are small and don’t require any architectural shift. + +### Observing State Transitions + +```swift +// ViewModel.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 +} +``` + +```swift +// ViewModelTests.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) + } +} +``` + +### Just-in-Time Mocking + +```swift +// ViewModel.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 + } +} +``` + +```swift +// ViewModelTests.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) + } +} +``` + +### Controlling Side Effects + +```swift +// ViewModel.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() + } + } +} +``` + +```swift +// ViewModelTests.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) + } +} + +@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) + } +} + +// ... +``` + ## Installation To use `Probing`, declare it as a dependency in your `Package.swift` or via Xcode project settings.