{"id":32150564,"url":"https://github.com/nsfatalerror/probing","last_synced_at":"2026-02-18T09:37:51.502Z","repository":{"id":285268809,"uuid":"957566288","full_name":"NSFatalError/Probing","owner":"NSFatalError","description":"Breakpoints for Swift Testing - precise control over side effects and fully observable state transitions in asynchronous functions","archived":false,"fork":false,"pushed_at":"2025-05-16T11:41:42.000Z","size":1169,"stargazers_count":35,"open_issues_count":0,"forks_count":0,"subscribers_count":2,"default_branch":"main","last_synced_at":"2025-09-26T13:47:38.208Z","etag":null,"topics":["concurrency","execution-control","side-effects","state-transitions","swift","swift-testing"],"latest_commit_sha":null,"homepage":"","language":"Swift","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/NSFatalError.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null}},"created_at":"2025-03-30T17:21:24.000Z","updated_at":"2025-09-07T11:33:06.000Z","dependencies_parsed_at":"2025-05-07T12:21:54.868Z","dependency_job_id":"d1907bd5-c5bd-4dce-9f19-ff33025300e5","html_url":"https://github.com/NSFatalError/Probing","commit_stats":null,"previous_names":["nsfatalerror/probing"],"tags_count":3,"template":false,"template_full_name":null,"purl":"pkg:github/NSFatalError/Probing","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/NSFatalError%2FProbing","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/NSFatalError%2FProbing/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/NSFatalError%2FProbing/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/NSFatalError%2FProbing/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/NSFatalError","download_url":"https://codeload.github.com/NSFatalError/Probing/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/NSFatalError%2FProbing/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":280240317,"owners_count":26296527,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","status":"online","status_checked_at":"2025-10-21T02:00:06.614Z","response_time":58,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["concurrency","execution-control","side-effects","state-transitions","swift","swift-testing"],"created_at":"2025-10-21T10:05:37.670Z","updated_at":"2025-10-21T10:05:41.775Z","avatar_url":"https://github.com/NSFatalError.png","language":"Swift","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cp align=\"center\"\u003e\n    \u003cimg src=\"Images/Probing.png\" height=\"220\" /\u003e\n\u003c/p\u003e\n\n\u003ch1 align=\"center\"\u003eProbing\u003c/h1\u003e\n\u003cp align=\"center\"\u003eBreakpoints for Swift Testing - precise control over side effects and fully observable state transitions in asynchronous functions\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n\u003ca href=\"https://swiftpackageindex.com/NSFatalError/Probing\"\u003e\u003cimg src=\"https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FNSFatalError%2FProbing%2Fbadge%3Ftype%3Dswift-versions\" /\u003e\u003c/a\u003e\n\u003ca href=\"https://swiftpackageindex.com/NSFatalError/Probing\"\u003e\u003cimg src=\"https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FNSFatalError%2FProbing%2Fbadge%3Ftype%3Dplatforms\" /\u003e\u003c/a\u003e\n\u003ca href=\"https://codecov.io/gh/NSFatalError/Probing\"\u003e\u003cimg src=\"https://codecov.io/gh/NSFatalError/Probing/graph/badge.svg?token=CDPR2O8BZO\" /\u003e\u003c/a\u003e\n\u003c/p\u003e\n\n---\n\n#### Contents\n- [What Problem Probing Solves?](#what-problem-probing-solves)\n- [How Probing Works?](#how-probing-works)\n- [Documentation \u0026 Sample Project](#documentation--sample-project)\n- [Examples](#examples)\n- [Installation](#installation)\n\n## What Problem Probing Solves?\n\nTesting asynchronous code remains challenging, even with Swift Concurrency and Swift Testing. \nSome of the persistent difficulties include:\n\n- **Unobservable state transitions**: When invoking methods on objects, often with complex dependencies between them, it’s not enough \nto inspect just the final output of the function. Inspecting the internal state changes during execution, such as loading states in view models, \nis equally important but notoriously difficult.\n- **Non-determinism**: `Task` instances run concurrently and may complete in different orders each time, leading to unpredictable states. \nEven 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.\n- **Limited runtime control**: Once an asynchronous function is running, influencing its behavior becomes hard. \nThis limitation pushes developers to rely on ahead-of-time setups, like intricate mocks, which add complexity and reduce clarity of the test.\n\nOver the years, the Swift community has introduced a number of tools to address these challenges, each with its own strengths:\n\n- **Quick/Nimble**: Polling with the designated matchers allows checking changes to object state, \nbut it can lead to flaky tests and is generally not concurrency-safe.\n- **Combine/RxSwift**: Reactive paradigms are powerful, but they can be difficult to set up and may introduce unnecessary abstraction, \nespecially now that `AsyncSequence` covers many use cases natively.\n- **ComposableArchitecture**: Provides a robust approach for testing UI logic, but it’s tightly coupled \nto its own architectural patterns and isn’t suited for other application layers.\n\nThese tools have pushed the ecosystem forward and work well within their intended contexts. \nStill, none provide a lightweight, general-purpose way to tackle all of the listed problems that embraces the Swift Concurrency model.\nThat's why I have designed and developed `Probing`.\n\n## How Probing Works?\n\nThe `Probing` package consists of two main modules:\n- `Probing`, which you add as a dependency to the targets you want to test\n- `ProbeTesting`, which you add as a dependency to your test targets\n\nWith `Probing`, you can define **probes** - suspension points typically placed after a state change,\nconceptually similar to breakpoints, but accessible and targetable from your tests.\nYou can also define **effects**, which make `Task` instances controllable and predictable.\n\nThen, with the help of `ProbeTesting`, you write a sequence of **dispatches** that advance your program to a desired state. \nThis flattens the execution tree of side effects, allowing you to write tests from the user’s perspective,\nas a clear and deterministic flow of events:\n\n```swift\n@Test\nfunc testLoading() async throws {\n    try await withProbing {\n        await viewModel.load()\n    } dispatchedBy: { dispatcher in\n        #expect(viewModel.isLoading == false)\n        #expect(viewModel.download == nil)\n\n        try await dispatcher.runUpToProbe()\n        #expect(viewModel.isLoading == true)\n        #expect(viewModel.download == nil)\n\n        downloaderMock.shouldFailDownload = false\n        try await dispatcher.runUntilExitOfBody()\n        #expect(viewModel.isLoading == false)\n        #expect(viewModel.download != nil)\n\n        #expect(viewModel.prefetchedData == nil)\n        try await dispatcher.runUntilEffectCompleted(\"backgroundFetch\")\n        #expect(viewModel.prefetchedData != nil)\n    }\n}\n```\n\n`ProbeTesting` also includes robust error handling. It provides recovery suggestions for every error it throws, \nguiding you toward a solution and making it easier to get started with the API.\n\n## Documentation \u0026 Sample Project\n\nFull documentation is available on the Swift Package Index:\n- [Probing](https://swiftpackageindex.com/NSFatalError/Probing/documentation/probing)\n- [ProbeTesting](https://swiftpackageindex.com/NSFatalError/Probing/documentation/probetesting)\n\nYou can download the `ProbingPlayground` sample project from its [GitHub page](https://github.com/NSFatalError/ProbingPlayground).\n\n## Examples\n\nThe `CHANGED` and `ADDED` comments highlight how the view model in the examples has been adapted \nto support testing with `ProbeTesting`. As you can see, the changes are minimal and don’t require any architectural shift.\n\n### Observing State Transitions\n\n```swift\n// ViewModel.swift\n\nfunc uploadImage(_ item: ImageItem) async {\n    do {\n        uploadState = .uploading\n        await #probe() // ADDED\n        let image = try await item.loadImage()\n        let processedImage = try await processor.processImage(image)\n        try await uploader.uploadImage(processedImage)\n        uploadState = .success\n    } catch {\n        uploadState = .error\n    }\n\n    await #probe() // ADDED\n    try? await Task.sleep(for: .seconds(3))\n    uploadState = nil\n}\n```\n\n```swift\n// ViewModelTests.swift\n\n@Test\nfunc testUploadingImage() async throws {\n    try await withProbing {\n        await viewModel.uploadImage(ImageMock())\n    } dispatchedBy: { dispatcher in\n        #expect(viewModel.uploadState == nil)\n\n        try await dispatcher.runUpToProbe()\n        #expect(uploader.uploadImageCallsCount == 0)\n        #expect(viewModel.uploadState == .uploading)\n\n        try await dispatcher.runUpToProbe()\n        #expect(uploader.uploadImageCallsCount == 1)\n        #expect(viewModel.uploadState == .success)\n\n        try await dispatcher.runUntilExitOfBody()\n        #expect(viewModel.uploadState == nil)\n    }\n}\n```\n\n### Just-in-Time Mocking\n\n```swift\n// ViewModel.swift\n\nfunc updateLocation() async {\n    locationState = .unknown\n    await #probe() // ADDED\n\n    do {\n        for try await update in locationProvider.getUpdates() {\n            try Task.checkCancellation()\n\n            if update.authorizationDenied {\n                locationState = .error\n            } else if let isNear = update.location?.isNearSanFrancisco() {\n                locationState = isNear ? .near : .far\n            } else {\n                locationState = .unknown\n            }\n            await #probe() // ADDED\n        }\n    } catch {\n        locationState = .error\n    }\n}\n```\n\n```swift\n// ViewModelTests.swift\n\n@Test\nfunc testUpdatingLocation() async throws {\n    try await withProbing {\n        await viewModel.beginUpdatingLocation()\n    } dispatchedBy: { dispatcher in\n        #expect(viewModel.locationState == nil)\n\n        locationProvider.continuation.yield(.init(location: .sanFrancisco))\n        try await dispatcher.runUpToProbe()\n        #expect(viewModel.locationState == .near)\n        \n        locationProvider.continuation.yield(.init(location: .init(latitude: 0, longitude: 0)))\n        try await dispatcher.runUpToProbe()\n        #expect(viewModel.locationState == .far)\n        \n        locationProvider.continuation.yield(.init(location: .sanFrancisco))\n        try await dispatcher.runUpToProbe()\n        #expect(viewModel.locationState == .near)\n        \n        locationProvider.continuation.yield(.init(location: nil, authorizationDenied: true))\n        try await dispatcher.runUpToProbe()\n        #expect(viewModel.locationState == .error)\n        \n        locationProvider.continuation.yield(.init(location: .sanFrancisco))\n        try await dispatcher.runUpToProbe()\n        #expect(viewModel.locationState == .near)\n\n        locationProvider.continuation.finish(throwing: ErrorMock())\n        try await dispatcher.runUntilExitOfBody()\n        #expect(viewModel.locationState == .error)\n    }\n}\n```\n\n### Controlling Side Effects\n\n```swift\n// ViewModel.swift\n\nprivate var downloadImageEffects = [ImageQuality: any Effect\u003cVoid\u003e]() // CHANGED\n\nfunc downloadImage() {\n    downloadImageEffects.values.forEach { $0.cancel() }\n    downloadImageEffects.removeAll()\n    downloadState = .downloading\n\n    downloadImage(withQuality: .low)\n    downloadImage(withQuality: .high)\n}\n\nprivate func downloadImage(withQuality quality: ImageQuality) {\n    downloadImageEffects[quality] = #Effect(\"\\(quality)\") { // CHANGED\n        defer {\n            downloadImageEffects[quality] = nil\n        }\n\n        do {\n            let image = try await downloader.downloadImage(withQuality: quality)\n            try Task.checkCancellation()\n            imageDownloadSucceeded(with: image, quality: quality)\n        } catch is CancellationError {\n            return\n        } catch {\n            imageDownloadFailed()\n        }\n    }\n}\n```\n\n```swift\n// ViewModelTests.swift\n\n@Test\nfunc testDownloadingImage() async throws {\n    try await withProbing {\n        await viewModel.downloadImage()\n    } dispatchedBy: { dispatcher in\n        await #expect(viewModel.downloadState == nil)\n\n        try await dispatcher.runUntilExitOfBody()\n        #expect(viewModel.downloadState?.isDownloading == true)\n\n        try await dispatcher.runUntilEffectCompleted(\"low\")\n        #expect(viewModel.downloadState?.quality == .low)\n\n        try await dispatcher.runUntilEffectCompleted(\"high\")\n        #expect(viewModel.downloadState?.quality == .high)\n    }\n}\n\n@Test\nfunc testDownloadingImageWhenHighQualityDownloadSucceedsFirst() async throws { ... }\n\n@Test\nfunc testDownloadingImageWhenHighQualityDownloadFailsAfterLowQualityDownloadSucceeds() async throws { ... }\n\n@Test\nfunc testDownloadingImageRepeatedly() async throws { ... }\n\n// ...\n```\n\n## Installation\n\nTo use `Probing`, declare it as a dependency in your `Package.swift` or via Xcode project settings.\nAdd a dependency on `Probing` in the targets you want to test, and `ProbeTesting` in your test targets:\n\n```swift\nlet package = Package(\n    name: \"MyPackage\",\n    dependencies: [\n        .package(\n            url: \"https://github.com/NSFatalError/Probing\",\n            from: \"1.0.0\"\n        )\n    ],\n    targets: [\n        .target(\n            name: \"MyModule\",\n            dependencies: [\n                .product(name: \"Probing\", package: \"Probing\")\n            ]\n        ),\n        .testTarget(\n            name: \"MyModuleTests\",\n            dependencies: [\n                \"MyModule\",\n                .product(name: \"ProbeTesting\", package: \"Probing\")\n            ]\n        )\n    ]\n)\n```\n\nSupported platforms:\n- macOS 15.0 or later\n- iOS 18.0 or later\n- tvOS 18.0 or later\n- watchOS 11.0 or later\n- visionOS 2.0 or later\n\nOther requirements:\n- Swift 6.1 or later\n- Xcode 16.3 or later\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fnsfatalerror%2Fprobing","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fnsfatalerror%2Fprobing","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fnsfatalerror%2Fprobing/lists"}