From a74a80fb9416bef68e50ab455142348fa84d15a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Fri, 2 Oct 2026 16:20:40 +0200 Subject: [PATCH 1/9] feat!: DependencyInjection package trait, deprecate DeallocTester (4.0) - One DeallocTests target; STRV Dependency Injection behind the DependencyInjection trait (off by default, not downloaded when off) - Remove the DeallocTestsDIFree product and the symlinked target - Require Swift 6.1 (tools-version 6.1) - Deprecate DeallocTester, DeallocTest, DeallocTestable, ClassNameIdentifiable - Remove DefaultInitializable - DIFree sample uses expectDeallocation with XCTest; DI sample enables the trait - CI tests both trait configurations, samples build on macos-26 - README migration guide, CHANGELOG BREAKING CHANGE: DeallocTestsDIFree is removed and DI support requires the DependencyInjection trait. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 14 ++- CHANGELOG.md | 17 +++ Package.resolved | 2 +- Package.swift | 41 +++---- README.md | 106 +++++++++++++++--- .../project.pbxproj | 14 +-- .../xcshareddata/swiftpm/Package.resolved | 15 --- .../DeallocTestConformances.swift | 16 --- .../MainCoordinatorDeallocTester.swift | 61 +++++----- .../project.pbxproj | 14 ++- .../DeallocTestConformances.swift | 5 + .../DependencyGraphDeallocTester.swift | 1 + .../MainCoordinatorDeallocTester.swift | 1 + .../DeallocTestable+Internals.swift | 1 + Sources/DeallocTests/DeallocTestable.swift | 1 + Sources/DeallocTests/DeallocTester.swift | 18 +-- Sources/DeallocTests/DefaultValue.swift | 86 -------------- .../DeinitializationObserver.swift | 2 + ...pectDeallocation+DependencyInjection.swift | 2 +- Sources/DeallocTestsDIFree | 1 - .../ExpectDeallocationDIFreeTests.swift | 27 ----- .../DeallocTesterDIFreeTests.swift | 19 ++-- .../DeallocTesterTests.swift | 16 ++- .../ExpectDeallocationTests.swift | 13 ++- 24 files changed, 236 insertions(+), 257 deletions(-) delete mode 100644 SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved delete mode 100644 SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/DeallocTestConformances.swift delete mode 100644 Sources/DeallocTests/DefaultValue.swift delete mode 120000 Sources/DeallocTestsDIFree delete mode 100644 Tests/DeallocTestsDIFreeTests/ExpectDeallocationDIFreeTests.swift rename Tests/{DeallocTestsDIFreeTests => DeallocTestsTests}/DeallocTesterDIFreeTests.swift (78%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e9af6c..d1965d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,12 +11,17 @@ concurrency: jobs: package: - name: Package tests (macOS) + name: Package tests (macOS, ${{ matrix.traits.name }}) runs-on: macos-15 + strategy: + matrix: + traits: + - { name: "default traits", flags: "" } + - { name: "DependencyInjection", flags: "--traits DependencyInjection" } steps: - uses: actions/checkout@v4 - name: Run tests - run: swift test + run: swift test ${{ matrix.traits.flags }} package-ios: name: Package tests (iOS Simulator) @@ -30,12 +35,13 @@ jobs: | jq -r '[.devices | to_entries[] | select(.key | contains("iOS")) | .value[] | select(.name | startswith("iPhone"))][0].udid') echo "Simulator: $UDID" xcodebuild test \ - -scheme DeallocTests-Package \ + -scheme DeallocTests \ -destination "id=$UDID" sample-apps: name: Sample apps (iOS) - runs-on: macos-15 + # Enabling a package trait from an Xcode project needs Xcode 26.4 or later + runs-on: macos-26 strategy: matrix: sample: [DeallocTestsAppSPM, DeallocTestsAppDIFreeSPM] diff --git a/CHANGELOG.md b/CHANGELOG.md index d8f9e87..2f83fa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 4.0.0 + +See "Migrating to 4.0" in the README. + +### Breaking +- STRV Dependency Injection support is now the `DependencyInjection` package trait, off by default. Without it, the dependency isn't downloaded. +- The `DeallocTestsDIFree` product is removed. Use the `DeallocTests` product and `import DeallocTests`. +- Swift 6.1 (Xcode 16.3) is required. Enabling the trait from an Xcode project needs Xcode 26.4. +- `DefaultInitializable` is removed. + +### Deprecated +- `DeallocTester`, `DeallocTest`, `DeallocTestable` and `ClassNameIdentifiable`. Use `expectDeallocation`. They will be removed in 5.0. + +### Changed +- The sample apps use `expectDeallocation`: `DeallocTestsAppDIFreeSPM` with XCTest, `DeallocTestsAppSPM` with Swift Testing and the `DependencyInjection` trait. +- CI tests both trait configurations. + ## 3.3.0 ### Added diff --git a/Package.resolved b/Package.resolved index 079bab0..c097015 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "03f6a00fd6dd53633e26cf2e21b6657b3434cd1af7cce0ab3346d4822e73b9b4", + "originHash" : "a44d746ebb3eb351e83ad607a462290a63e9aa1d54f6ce70caa79d0341e911b3", "pins" : [ { "identity" : "ios-dependency-injection", diff --git a/Package.swift b/Package.swift index d12c0d3..638245d 100644 --- a/Package.swift +++ b/Package.swift @@ -1,6 +1,6 @@ -// swift-tools-version:6.0.0 +// swift-tools-version:6.1 // -// DeallocTests.swift +// Package.swift // DeallocTests // // Created by Daniel Cech on 01/04/19. @@ -20,9 +20,11 @@ let package = Package( name: "DeallocTests", targets: ["DeallocTests"] ), - .library( - name: "DeallocTestsDIFree", - targets: ["DeallocTestsDIFree"] + ], + traits: [ + .trait( + name: "DependencyInjection", + description: "Integration with STRV Dependency Injection: expectDeallocation(of:resolvedFrom:) and the AsyncContainer in DeallocTester" ), ], dependencies: [ @@ -31,23 +33,24 @@ let package = Package( targets: [ .target( name: "DeallocTests", - dependencies: [.product(name: "DependencyInjection", package: "ios-dependency-injection")], - path: "Sources/DeallocTests", - swiftSettings: [.define("DEALLOC_TESTS_DI")] - ), - .target( - name: "DeallocTestsDIFree", - path: "Sources/DeallocTestsDIFree" + dependencies: [ + .product( + name: "DependencyInjection", + package: "ios-dependency-injection", + condition: .when(traits: ["DependencyInjection"]) + ) + ] ), .testTarget( name: "DeallocTestsTests", - dependencies: ["DeallocTests"], - path: "Tests/DeallocTestsTests" - ), - .testTarget( - name: "DeallocTestsDIFreeTests", - dependencies: ["DeallocTestsDIFree"], - path: "Tests/DeallocTestsDIFreeTests" + dependencies: [ + "DeallocTests", + .product( + name: "DependencyInjection", + package: "ios-dependency-injection", + condition: .when(traits: ["DependencyInjection"]) + ) + ] ), ], swiftLanguageModes: [.v6] diff --git a/README.md b/README.md index 3d1850c..75dbab6 100644 --- a/README.md +++ b/README.md @@ -27,34 +27,34 @@ DeallocTests work well with apps that use MVVM-C (MVVM with ViewCoordinators) ar ## STRV Dependency Injection library -The main version of DeallocTests uses [STRV Dependency Injection library](https://github.com/strvcom/ios-dependency-injection) as the only dependency. The support of dependency injection is a great benefit, but DeallocTests also work without it. If you don't use STRV Dependency Injection in your app, use the `DeallocTestsDIFree` product instead. +DeallocTests has no dependencies by default. The integration with [STRV Dependency Injection library](https://github.com/strvcom/ios-dependency-injection) is behind the `DependencyInjection` [package trait](#installation): enable it and the library is pulled in, otherwise it's not even downloaded. ## Requirements - iOS 17.0+ / macOS 13.0+ -- Swift 6.0+ / Xcode 16.0+ -- Swift Testing or XCTest. `.checksDeallocation` needs Swift 6.1 (Xcode 16.3) or later. +- Swift 6.1+ / Xcode 16.3+ +- Swift Testing or XCTest +- Enabling the `DependencyInjection` trait from an Xcode project needs Xcode 26.4 or later ## Installation DeallocTests is distributed via [Swift Package Manager](https://swift.org/package-manager/). Add it to the **test target** of your app: ``` swift -// swift-tools-version:6.0 +// swift-tools-version:6.1 import PackageDescription let package = Package( name: "HelloDeallocTests", dependencies: [ - .package(url: "https://github.com/strvcom/DeallocTests.git", .upToNextMajor(from: "3.3.0")) + .package(url: "https://github.com/strvcom/DeallocTests.git", .upToNextMajor(from: "4.0.0")) ], targets: [ .testTarget( name: "HelloDeallocTestsTests", dependencies: [ "HelloDeallocTests", - // or "DeallocTestsDIFree" if you don't use STRV Dependency Injection .product(name: "DeallocTests", package: "DeallocTests") ] ) @@ -62,7 +62,19 @@ let package = Package( ) ``` -In Xcode, add the package via *File › Add Package Dependencies…* and link the `DeallocTests` (or `DeallocTestsDIFree`) product to your test target only. +If you use STRV Dependency Injection, enable the `DependencyInjection` trait: + +``` swift +.package(url: "https://github.com/strvcom/DeallocTests.git", .upToNextMajor(from: "4.0.0"), traits: ["DependencyInjection"]) +``` + +In Xcode, add the package via *File › Add Package Dependencies…* and link the `DeallocTests` product to your test target only. To use STRV Dependency Injection, enable the `DependencyInjection` trait for the package in Xcode 26.4 or later. The project file then lists it on the package reference: + +``` +traits = ( + DependencyInjection, +); +``` ## Usage @@ -174,7 +186,7 @@ In XCTest, keep the object in a local variable. A property of the test case live ### STRV Dependency Injection -With the `DeallocTests` product, a dependency can be resolved from an `AsyncContainer`, released together with the container's shared instances and checked: +With the `DependencyInjection` trait, a dependency can be resolved from an `AsyncContainer`, released together with the container's shared instances and checked: ```swift @Test func apiManager() async { @@ -187,9 +199,12 @@ With the `DeallocTests` product, a dependency can be resolved from an `AsyncCont Following the dependency graph, check the simplest dependencies first, then the ones that use them. -### Scenario API: `DeallocTester` +### Deprecated: `DeallocTester` + +`DeallocTester`, `DeallocTest` and `DeallocTestable` are deprecated in 4.0 and will be removed in 5.0. They still work. See [Migrating to 4.0](#migrating-to-40). -`DeallocTester` is the original XCTest API. It goes through a list of objects one by one, typically all screens of a coordinator and then the coordinator itself. It is still supported, but new tests should use `expectDeallocation`. +
+ Documentation of the deprecated API 1. Conform the tested classes to `DeallocTestable` in your test target. No changes to the main target are needed: @@ -240,7 +255,7 @@ Set `DeallocTester.isLoggingEnabled = true` to print `Alloc`/`Dealloc` messages #### Dependency Injection in `DeallocTester` -With the `DeallocTests` product, `objectCreation` receives an `AsyncContainer`. Before every step the container is cleaned and `registerDependencies()` is called. Shared instances are released before the check: +With the `DependencyInjection` trait, `objectCreation` receives an `AsyncContainer`. Before every step the container is cleaned and `registerDependencies()` is called. Shared instances are released before the check: ```swift final class DependencyGraphDeallocTester: DeallocTester { @@ -264,21 +279,76 @@ final class DependencyGraphDeallocTester: DeallocTester { } ``` -With `DeallocTestsDIFree`, `objectCreation` takes no parameter: `DeallocTest(objectCreation: { MyObject() })`. +Without the trait, `objectCreation` takes no parameter: `DeallocTest(objectCreation: { MyObject() })`. + +
+ +## Migrating to 4.0 + +**Dependency Injection.** The `DeallocTestsDIFree` product is gone. Everyone uses the `DeallocTests` product and `import DeallocTests`: + +- If you used `DeallocTests` with STRV Dependency Injection, enable the `DependencyInjection` trait (see [Installation](#installation)). +- If you used `DeallocTestsDIFree`, link the `DeallocTests` product instead and replace `import DeallocTestsDIFree` with `import DeallocTests`. No trait is needed. + +**`DeallocTester`.** Existing tests keep working but produce deprecation warnings. Each `DeallocTest` becomes one `expectDeallocation` call, and the `DeallocTestable` conformances can be deleted: + +```swift +// Before +final class MainCoordinatorDeallocTester: DeallocTester { + @MainActor + func test_mainCoordinatorDealloc() async { + let coordinator = MainCoordinator() + let expectation = expectation(description: "dealloc test") + + await performDeallocTest( + deallocTests: [ + DeallocTest(objectCreation: { _ in coordinator.createFirstViewController() }), + DeallocTest(objectCreation: { _ in MainCoordinator() }) + ], + expectation: expectation + ) + + await fulfillment(of: [expectation], timeout: 60) + } +} + +// After (XCTest; in Swift Testing the calls are the same) +final class MainCoordinatorDeallocTests: XCTestCase { + @MainActor + func test_firstScreen() async { + let coordinator = MainCoordinator() + await expectDeallocation(.present) { coordinator.createFirstViewController() } + } + + @MainActor + func test_coordinator() async { + await expectDeallocation { MainCoordinator() } + } +} +``` + +| `DeallocTester` | `expectDeallocation` | +|---|---| +| View controllers are always presented | Choose `.present`, `.push`, `.loadView` or `.hosting` | +| `registerDependencies()` + `objectCreation: { $0.resolve(...) }` | `expectDeallocation(of:resolvedFrom:)` with your own `AsyncContainer` | +| `checkClasses` | `trackForDeallocation(_:)` inside the closure | +| `actionBeforeCheck` | `afterRelease` | +| `deallocationTimeout` | `timeout` | + +**`DefaultInitializable`** is removed. It wasn't related to dealloc testing. ## Sample Apps -The folder `SampleApps` contains two demo projects, `DeallocTestsAppSPM` (with STRV Dependency Injection) and `DeallocTestsAppDIFreeSPM`. The application itself is very simple: there are just three screens in the navigation stack, all handled by `MainCoordinator`. +The folder `SampleApps` contains two demo projects. The application itself is very simple: there are just three screens in the navigation stack, all handled by `MainCoordinator`. -- `DeallocTestConformances.swift` adds the `DeallocTestable` conformances to all tested classes. -- `MainCoordinatorDeallocTester.swift` defines the testing scenario for `MainCoordinator`: the three view controllers one by one, then the coordinator itself. -- `DependencyGraphDeallocTester.swift` (DI sample only) checks a service resolved from the container. -- `ExpectDeallocationTests.swift` (DI sample only) does the same checks with `expectDeallocation` and Swift Testing. +- `DeallocTestsAppDIFreeSPM` checks the screens and the coordinator with `expectDeallocation` in **XCTest** (`MainCoordinatorDeallocTester.swift`). +- `DeallocTestsAppSPM` enables the `DependencyInjection` trait. `ExpectDeallocationTests.swift` does the checks with `expectDeallocation` in **Swift Testing**, including a service resolved from an `AsyncContainer`. The other test files show the deprecated `DeallocTester` API. The sample app intentionally contains a memory leak in `SecondViewController.swift`. This class contains a closure with a strong reference to `self`. The test fails with: ``` -DeallocTester.swift:233: error: -[DeallocTestsAppSPMTests.MainCoordinatorDeallocTester test_mainCoordinatorDealloc] : failed - Failed: dealloc test #1 failed on classes: [DeallocTestsAppSPM.SecondViewController] (1 tracked instance(s) still alive) +MainCoordinatorDeallocTester.swift:25: error: -[DeallocTestsAppSPMTests.MainCoordinatorDeallocTester test_secondScreen] : failed - DeallocTestsAppSPM.SecondViewController was not deallocated within 2 sec. Possible causes: + • `someClosure` is a closure. Make sure it captures self weakly ``` If you comment out the first line and uncomment the second one, the retain cycle disappears and the test will succeed. diff --git a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj index e69854b..477bd05 100644 --- a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj +++ b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj @@ -19,9 +19,8 @@ 27A0804E245C879C0037F1DB /* ThirdViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A0804D245C879C0037F1DB /* ThirdViewController.swift */; }; 27A08051245C87C40037F1DB /* MainCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A08050245C87C40037F1DB /* MainCoordinator.swift */; }; 27A08053245CBA7A0037F1DB /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A08052245CBA790037F1DB /* SceneDelegate.swift */; }; - 27A08055245CBBD10037F1DB /* DeallocTestConformances.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A08054245CBBD10037F1DB /* DeallocTestConformances.swift */; }; 27A08057245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A08056245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift */; }; - ADCB58E527FD840C009E0DEB /* DeallocTestsDIFree in Frameworks */ = {isa = PBXBuildFile; productRef = ADCB58E427FD840C009E0DEB /* DeallocTestsDIFree */; }; + ADCB58E527FD840C009E0DEB /* DeallocTests in Frameworks */ = {isa = PBXBuildFile; productRef = ADCB58E427FD840C009E0DEB /* DeallocTests */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -51,7 +50,6 @@ 27A0804D245C879C0037F1DB /* ThirdViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThirdViewController.swift; sourceTree = ""; }; 27A08050245C87C40037F1DB /* MainCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainCoordinator.swift; sourceTree = ""; }; 27A08052245CBA790037F1DB /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - 27A08054245CBBD10037F1DB /* DeallocTestConformances.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeallocTestConformances.swift; sourceTree = ""; }; 27A08056245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainCoordinatorDeallocTester.swift; sourceTree = ""; }; 7E877B2A5D7E5C94D45AE696 /* Pods_DeallocTestsAppCocoapodsTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_DeallocTestsAppCocoapodsTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; ADCB58E327FD82B4009E0DEB /* DeallocTests */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = DeallocTests; path = ../..; sourceTree = ""; }; @@ -69,7 +67,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - ADCB58E527FD840C009E0DEB /* DeallocTestsDIFree in Frameworks */, + ADCB58E527FD840C009E0DEB /* DeallocTests in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -126,7 +124,6 @@ isa = PBXGroup; children = ( 27A0803F245C859E0037F1DB /* Info.plist */, - 27A08054245CBBD10037F1DB /* DeallocTestConformances.swift */, 27A08056245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift */, ); path = DeallocTestsAppSPMTests; @@ -195,7 +192,7 @@ ); name = DeallocTestsAppSPMTests; packageProductDependencies = ( - ADCB58E427FD840C009E0DEB /* DeallocTestsDIFree */, + ADCB58E427FD840C009E0DEB /* DeallocTests */, ); productName = DeallocTestsAppCocoapodsTests; productReference = 27A08039245C859E0037F1DB /* DeallocTestsAppSPMTests.xctest */; @@ -280,7 +277,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 27A08055245CBBD10037F1DB /* DeallocTestConformances.swift in Sources */, 27A08057245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift in Sources */, 275ACCAC246D4C2E00FEE52F /* TestAppDelegate.swift in Sources */, 275ACCAD246D4C2E00FEE52F /* TestSceneDelegate.swift in Sources */, @@ -544,9 +540,9 @@ /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ - ADCB58E427FD840C009E0DEB /* DeallocTestsDIFree */ = { + ADCB58E427FD840C009E0DEB /* DeallocTests */ = { isa = XCSwiftPackageProductDependency; - productName = DeallocTestsDIFree; + productName = DeallocTests; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 7fa1551..0000000 --- a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,15 +0,0 @@ -{ - "originHash" : "a4a705ed389078d4ae491a782d2cc5bb828f2b7e7408f815fea6287b2dc5fd3e", - "pins" : [ - { - "identity" : "ios-dependency-injection", - "kind" : "remoteSourceControl", - "location" : "https://github.com/strvcom/ios-dependency-injection.git", - "state" : { - "revision" : "bff058aab9f85fd687be3daddb0d38d094b56e7b", - "version" : "2.0.1" - } - } - ], - "version" : 3 -} diff --git a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/DeallocTestConformances.swift b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/DeallocTestConformances.swift deleted file mode 100644 index e37d67f..0000000 --- a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/DeallocTestConformances.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// DeallocTestConformances.swift -// DeallocTestsAppTests -// -// Created by Daniel Cech on 01/05/2020. -// Copyright © 2020 STRV. All rights reserved. -// - -import Foundation -import DeallocTestsDIFree -@testable import DeallocTestsAppSPM - -extension MainCoordinator: @retroactive DeallocTestable {} -extension FirstViewController: @retroactive DeallocTestable {} -extension SecondViewController: @retroactive DeallocTestable {} -extension ThirdViewController: @retroactive DeallocTestable {} diff --git a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift index b1ce821..1648b82 100644 --- a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift +++ b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift @@ -6,48 +6,37 @@ // Copyright © 2020 STRV. All rights reserved. // -import Foundation -import DeallocTestsDIFree +import DeallocTests +import XCTest @testable import DeallocTestsAppSPM - -class MainCoordinatorDeallocTester: DeallocTester { - var mainCoordinator: MainCoordinator? { - return MainCoordinator() +/// Dealloc tests with XCTest. No `DeallocTestable` conformances are needed. +final class MainCoordinatorDeallocTester: XCTestCase { + @MainActor + func test_firstScreen() async { + let coordinator = MainCoordinator() + await expectDeallocation(.present) { coordinator.createFirstViewController() } } + /// Fails on purpose: `SecondViewController` captures `self` strongly in `viewDidLoad` @MainActor - func test_mainCoordinatorDealloc() async { - deallocTests = [ - DeallocTest( - objectCreation: { [weak self] in - return self?.mainCoordinator?.createFirstViewController() - } - ), - DeallocTest( - objectCreation: { [weak self] in - return self?.mainCoordinator?.createSecondViewController() - } - ), - DeallocTest( - objectCreation: { [weak self] in - return self?.mainCoordinator?.createThirdViewController() - } - ), - DeallocTest( - objectCreation: { - return MainCoordinator() - } - ) - ] - - let expectation = self.expectation(description: "deallocTest test_mainCoordinatorDealloc") + func test_secondScreen() async { + let coordinator = MainCoordinator() + await expectDeallocation(.present) { coordinator.createSecondViewController() } + } - await performDeallocTest( - deallocTests: deallocTests, - expectation: expectation - ) + @MainActor + func test_thirdScreen() async { + let coordinator = MainCoordinator() + await expectDeallocation(.push) { coordinator.createThirdViewController() } + } - await fulfillment(of: [expectation], timeout: 200) + @MainActor + func test_coordinator() async { + await expectDeallocation { + let coordinator = MainCoordinator() + _ = coordinator.initialViewController() + return coordinator + } } } diff --git a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj index a4b3e8a..cae2b1d 100644 --- a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj +++ b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj @@ -56,7 +56,6 @@ 27A08052245CBA790037F1DB /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 27A08054245CBBD10037F1DB /* DeallocTestConformances.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeallocTestConformances.swift; sourceTree = ""; }; 27A08056245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainCoordinatorDeallocTester.swift; sourceTree = ""; }; - ADCB58D827FD7B01009E0DEB /* DeallocTests */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = DeallocTests; path = ../..; sourceTree = ""; }; ADCB58DE27FD7CC9009E0DEB /* APIManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIManager.swift; sourceTree = ""; }; ADCB58E027FD7CF3009E0DEB /* DependencyGraphDeallocTester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependencyGraphDeallocTester.swift; sourceTree = ""; }; AD0E0E0E2F00000100000002 /* ExpectDeallocationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpectDeallocationTests.swift; sourceTree = ""; }; @@ -160,7 +159,6 @@ ADCB58D727FD7B01009E0DEB /* Packages */ = { isa = PBXGroup; children = ( - ADCB58D827FD7B01009E0DEB /* DeallocTests */, ); name = Packages; sourceTree = ""; @@ -245,6 +243,7 @@ ); mainGroup = 27A0801A245C859C0037F1DB; packageReferences = ( + AD0E0E0E2F00000100000010 /* XCLocalSwiftPackageReference "../.." */, ); productRefGroup = 27A08024245C859C0037F1DB /* Products */; projectDirPath = ""; @@ -559,9 +558,20 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + AD0E0E0E2F00000100000010 /* XCLocalSwiftPackageReference "../.." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../..; + traits = ( + DependencyInjection, + ); + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ ADCB58E627FD8756009E0DEB /* DeallocTests */ = { isa = XCSwiftPackageProductDependency; + package = AD0E0E0E2F00000100000010 /* XCLocalSwiftPackageReference "../.." */; productName = DeallocTests; }; /* End XCSwiftPackageProductDependency section */ diff --git a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/DeallocTestConformances.swift b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/DeallocTestConformances.swift index 565b52c..816d837 100644 --- a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/DeallocTestConformances.swift +++ b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/DeallocTestConformances.swift @@ -10,8 +10,13 @@ import Foundation import DeallocTests @testable import DeallocTestsAppSPM +@available(*, deprecated) extension MainCoordinator: @retroactive DeallocTestable {} +@available(*, deprecated) extension FirstViewController: @retroactive DeallocTestable {} +@available(*, deprecated) extension SecondViewController: @retroactive DeallocTestable {} +@available(*, deprecated) extension ThirdViewController: @retroactive DeallocTestable {} +@available(*, deprecated) extension APIManager: @retroactive DeallocTestable {} diff --git a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/DependencyGraphDeallocTester.swift b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/DependencyGraphDeallocTester.swift index 2840fd9..ce7ad73 100644 --- a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/DependencyGraphDeallocTester.swift +++ b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/DependencyGraphDeallocTester.swift @@ -13,6 +13,7 @@ import DependencyInjection import DeallocTests import XCTest +@available(*, deprecated, message: "Shows the deprecated DeallocTester API. See ExpectDeallocationTests for the current API.") class DependencyGraphDeallocTester: DeallocTester { @MainActor func test_dependencyGraphDealloc() async { diff --git a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift index e99fe88..7dbe73b 100644 --- a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift +++ b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift @@ -11,6 +11,7 @@ import DeallocTests @testable import DeallocTestsAppSPM +@available(*, deprecated, message: "Shows the deprecated DeallocTester API. See ExpectDeallocationTests for the current API.") class MainCoordinatorDeallocTester: DeallocTester { var mainCoordinator: MainCoordinator? diff --git a/Sources/DeallocTests/DeallocTestable+Internals.swift b/Sources/DeallocTests/DeallocTestable+Internals.swift index 7776336..bd02215 100644 --- a/Sources/DeallocTests/DeallocTestable+Internals.swift +++ b/Sources/DeallocTests/DeallocTestable+Internals.swift @@ -8,6 +8,7 @@ import Foundation +@available(*, deprecated) extension DeallocTestable { /// This stores the `DeinitializationObserver`. It's private so you /// cannot interfere with this outside. Also we're using a strong retain diff --git a/Sources/DeallocTests/DeallocTestable.swift b/Sources/DeallocTests/DeallocTestable.swift index e4364ed..a502458 100644 --- a/Sources/DeallocTests/DeallocTestable.swift +++ b/Sources/DeallocTests/DeallocTestable.swift @@ -16,6 +16,7 @@ enum AssociatedKeys { } /// Protocol for any object that implements this logic +@available(*, deprecated, message: "Use expectDeallocation(_:timeout:afterRelease:of:), which needs no DeallocTestable conformance. See \"Migrating to 4.0\" in the README.") @MainActor public protocol DeallocTestable: ClassNameIdentifiable { func initializeDeallocTestSupport() diff --git a/Sources/DeallocTests/DeallocTester.swift b/Sources/DeallocTests/DeallocTester.swift index 5df1b79..9be7ecf 100644 --- a/Sources/DeallocTests/DeallocTester.swift +++ b/Sources/DeallocTests/DeallocTester.swift @@ -8,7 +8,7 @@ import Foundation -#if DEALLOC_TESTS_DI +#if DependencyInjection import DependencyInjection #endif @@ -18,8 +18,9 @@ import XCTest import UIKit #endif +@available(*, deprecated, message: "Use expectDeallocation(_:timeout:afterRelease:of:), which needs no DeallocTestable conformance. See \"Migrating to 4.0\" in the README.") public struct DeallocTest { -#if DEALLOC_TESTS_DI +#if DependencyInjection public typealias ObjectCreationClosure = @MainActor (AsyncContainer) async -> AnyObject? #else public typealias ObjectCreationClosure = @MainActor () async -> AnyObject? @@ -38,6 +39,7 @@ public struct DeallocTest { } } +@available(*, deprecated, message: "Use expectDeallocation(_:timeout:afterRelease:of:), which needs no DeallocTestable conformance. See \"Migrating to 4.0\" in the README.") open class DeallocTester: XCTestCase { // MARK: - Properties @@ -62,7 +64,7 @@ open class DeallocTester: XCTestCase { public var presentingController: UIViewController! #endif -#if DEALLOC_TESTS_DI +#if DependencyInjection /// Dependency Injection container // swiftlint:disable:next implicitly_unwrapped_optional public var container: AsyncContainer! @@ -100,7 +102,7 @@ open class DeallocTester: XCTestCase { override open func setUp() async throws { try await super.setUp() - #if DEALLOC_TESTS_DI + #if DependencyInjection container = AsyncContainer() #endif @@ -139,6 +141,7 @@ open class DeallocTester: XCTestCase { // MARK: - Private +@available(*, deprecated) private extension DeallocTester { var registry: DeallocRegistry { DeallocRegistry.shared @@ -148,7 +151,7 @@ private extension DeallocTester { func performDeallocTest(_ deallocTest: DeallocTest, index: Int) async { registry.reset() - #if DEALLOC_TESTS_DI + #if DependencyInjection await container.clean() await registerDependencies() #endif @@ -160,7 +163,7 @@ private extension DeallocTester { return } - #if DEALLOC_TESTS_DI + #if DependencyInjection await container.releaseSharedInstances() #endif @@ -173,7 +176,7 @@ private extension DeallocTester { /// Returns `false` when the step cannot be checked @MainActor func createAndExercise(_ deallocTest: DeallocTest, index: Int) async -> Bool { - #if DEALLOC_TESTS_DI + #if DependencyInjection let instance = await deallocTest.objectCreation(container) #else let instance = await deallocTest.objectCreation() @@ -236,6 +239,7 @@ private extension DeallocTester { } #if canImport(UIKit) +@available(*, deprecated) private extension DeallocTester { /// Presents and dismisses the controller to run its lifecycle @MainActor diff --git a/Sources/DeallocTests/DefaultValue.swift b/Sources/DeallocTests/DefaultValue.swift deleted file mode 100644 index 6c24314..0000000 --- a/Sources/DeallocTests/DefaultValue.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// DefaultValue.swift -// DeallocTests-iOS -// -// Created by Daniel Cech on 15/05/2020. -// Copyright © 2020 DanielCech. All rights reserved. -// - -import Foundation - -@available(*, deprecated, message: "DefaultInitializable is unrelated to dealloc testing and will be removed in DeallocTests 4.0") -public protocol DefaultInitializable { - static var defaultValue: Self { get } -} - -@available(*, deprecated) -extension Int: DefaultInitializable { - public static var defaultValue: Int { - return Int.random(in: 0 ... 100) - } -} - -@available(*, deprecated) -extension Float: DefaultInitializable { - public static var defaultValue: Float { - return Float.random(in: 0 ... 100) - } -} - -@available(*, deprecated) -extension Double: DefaultInitializable { - public static var defaultValue: Double { - return Double.random(in: 0 ... 100) - } -} - -@available(*, deprecated) -extension Bool: DefaultInitializable { - public static var defaultValue: Bool { - return Bool.random() - } -} - -@available(*, deprecated) -extension String: DefaultInitializable { - public static var defaultValue: String { - let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - return String((0 ..< 10).map{ _ in letters.randomElement()! }) - } -} - -@available(*, deprecated) -extension URL: DefaultInitializable { - public static var defaultValue: URL { - return URL(string: "http://google.com")! - } -} - -@available(*, deprecated) -extension Array: DefaultInitializable { - public static var defaultValue: Array { - return [] - } -} - -@available(*, deprecated) -extension Dictionary: DefaultInitializable { - public static var defaultValue: Dictionary { - return [:] - } -} - -@available(*, deprecated) -extension Set: DefaultInitializable { - public static var defaultValue: Set { - return Set() - } -} - -@available(*, deprecated) -extension Optional: DefaultInitializable { - public static var defaultValue: Optional { - return nil - } -} - diff --git a/Sources/DeallocTests/DeinitializationObserver.swift b/Sources/DeallocTests/DeinitializationObserver.swift index cacbbcd..b30fe5c 100644 --- a/Sources/DeallocTests/DeinitializationObserver.swift +++ b/Sources/DeallocTests/DeinitializationObserver.swift @@ -9,10 +9,12 @@ import Foundation import os +@available(*, deprecated, message: "Use expectDeallocation(_:timeout:afterRelease:of:), which needs no DeallocTestable conformance. See \"Migrating to 4.0\" in the README.") public protocol ClassNameIdentifiable: AnyObject { var myClass: AnyClass { get } } +@available(*, deprecated) public extension ClassNameIdentifiable { var myClass: AnyClass { return type(of: self) diff --git a/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift b/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift index 255ed5f..c3441e7 100644 --- a/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift +++ b/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift @@ -5,7 +5,7 @@ // Copyright © 2026 STRV. All rights reserved. // -#if DEALLOC_TESTS_DI +#if DependencyInjection import DependencyInjection import Foundation diff --git a/Sources/DeallocTestsDIFree b/Sources/DeallocTestsDIFree deleted file mode 120000 index e2e6836..0000000 --- a/Sources/DeallocTestsDIFree +++ /dev/null @@ -1 +0,0 @@ -DeallocTests \ No newline at end of file diff --git a/Tests/DeallocTestsDIFreeTests/ExpectDeallocationDIFreeTests.swift b/Tests/DeallocTestsDIFreeTests/ExpectDeallocationDIFreeTests.swift deleted file mode 100644 index 6a97b8c..0000000 --- a/Tests/DeallocTestsDIFreeTests/ExpectDeallocationDIFreeTests.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// ExpectDeallocationDIFreeTests.swift -// DeallocTests -// -// Copyright © 2026 STRV. All rights reserved. -// - -import DeallocTestsDIFree -import Testing - -final class PlainObject {} - -@Suite("expectDeallocation without Dependency Injection") -@MainActor -struct ExpectDeallocationDIFreeTests { - @Test func cleanObjectPasses() async { - await expectDeallocation { PlainObject() } - } - - @Test func retainCycleIsReported() async { - await withKnownIssue { - await expectDeallocation(timeout: .milliseconds(100)) { LeakingObject() } - } matching: { issue in - issue.comments.contains { $0.rawValue.contains("LeakingObject was not deallocated") } - } - } -} diff --git a/Tests/DeallocTestsDIFreeTests/DeallocTesterDIFreeTests.swift b/Tests/DeallocTestsTests/DeallocTesterDIFreeTests.swift similarity index 78% rename from Tests/DeallocTestsDIFreeTests/DeallocTesterDIFreeTests.swift rename to Tests/DeallocTestsTests/DeallocTesterDIFreeTests.swift index fc99172..7187874 100644 --- a/Tests/DeallocTestsDIFreeTests/DeallocTesterDIFreeTests.swift +++ b/Tests/DeallocTestsTests/DeallocTesterDIFreeTests.swift @@ -5,19 +5,13 @@ // Copyright © 2026 STRV. All rights reserved. // -import DeallocTestsDIFree -import XCTest - -final class CleanObject: DeallocTestable {} +#if !DependencyInjection -final class LeakingObject: DeallocTestable { - var closure: (() -> Void)? - - init() { - closure = { _ = self } - } -} +import DeallocTests +import XCTest +/// `DeallocTester` without the DependencyInjection trait: `objectCreation` takes no container +@available(*, deprecated, message: "Tests the deprecated DeallocTester API") final class DeallocTesterDIFreeTests: DeallocTester { @MainActor func test_cleanObject_passes() async { @@ -36,6 +30,7 @@ final class DeallocTesterDIFreeTests: DeallocTester { } } +@available(*, deprecated) private extension DeallocTesterDIFreeTests { @MainActor func run(_ deallocTests: [DeallocTest]) async { @@ -46,3 +41,5 @@ private extension DeallocTesterDIFreeTests { await fulfillment(of: [expectation], timeout: 10) } } + +#endif diff --git a/Tests/DeallocTestsTests/DeallocTesterTests.swift b/Tests/DeallocTestsTests/DeallocTesterTests.swift index cab0e35..5d17dfb 100644 --- a/Tests/DeallocTestsTests/DeallocTesterTests.swift +++ b/Tests/DeallocTestsTests/DeallocTesterTests.swift @@ -5,14 +5,18 @@ // Copyright © 2026 STRV. All rights reserved. // -import DependencyInjection +#if DependencyInjection + import DependencyInjection +#endif import XCTest @testable import DeallocTests // MARK: - Fixtures +@available(*, deprecated, message: "Tests the deprecated DeallocTester API") final class CleanObject: DeallocTestable {} +@available(*, deprecated, message: "Tests the deprecated DeallocTester API") final class LeakingObject: DeallocTestable { var closure: (() -> Void)? @@ -26,8 +30,12 @@ final class NotTestableObject {} protocol Service: AnyObject, Sendable {} +@available(*, deprecated, message: "Tests the deprecated DeallocTester API") final class SharedService: Service, DeallocTestable {} +final class ContainerService: Service {} + +@available(*, deprecated, message: "Tests the deprecated DeallocTester API") @MainActor enum Leaks { static var retained = [AnyObject]() @@ -35,6 +43,9 @@ enum Leaks { // MARK: - Tests +#if DependencyInjection + +@available(*, deprecated, message: "Tests the deprecated DeallocTester API") final class DeallocTesterTests: DeallocTester { @MainActor func test_cleanObject_passes() async { @@ -130,6 +141,7 @@ final class DeallocTesterTests: DeallocTester { } } +@available(*, deprecated) private extension DeallocTesterTests { @MainActor func run(_ deallocTests: [DeallocTest]) async { @@ -140,3 +152,5 @@ private extension DeallocTesterTests { await fulfillment(of: [expectation], timeout: 10) } } + +#endif diff --git a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift index f0d0885..e6d1f3d 100644 --- a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift +++ b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift @@ -6,9 +6,12 @@ // import DeallocTests -import DependencyInjection import Testing +#if DependencyInjection + import DependencyInjection +#endif + #if canImport(AppKit) import AppKit #endif @@ -143,19 +146,21 @@ struct ExpectDeallocationTests { // MARK: - Dependency Injection +#if DependencyInjection + @Suite("expectDeallocation with AsyncContainer") @MainActor struct ExpectDeallocationDependencyInjectionTests { let container = AsyncContainer() @Test func sharedInstanceIsReleasedWithContainer() async { - await container.register(type: Service.self, in: .shared) { _ in SharedService() } + await container.register(type: Service.self, in: .shared) { _ in ContainerService() } await expectDeallocation(of: Service.self, resolvedFrom: container) } @Test func newInstanceIsChecked() async { - await container.register(type: Service.self, in: .new) { _ in SharedService() } + await container.register(type: Service.self, in: .new) { _ in ContainerService() } await expectDeallocation(of: Service.self, resolvedFrom: container) } @@ -171,6 +176,8 @@ struct ExpectDeallocationDependencyInjectionTests { } } +#endif + // MARK: - trackForDeallocation #if compiler(>=6.1) From 60afff1881b72555162a7a64cf1127b50c9799e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Fri, 2 Oct 2026 16:46:54 +0200 Subject: [PATCH 2/9] feat!: make DependencyInjection a default trait Most projects use STRV Dependency Injection, so it's on by default. Projects that don't use it opt out with `traits: []` (or an empty traits list in an Xcode project) and the dependency isn't downloaded. - DI sample relies on the default trait - DIFree sample opts out in its Xcode project - CI tests the default and --disable-default-traits Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 6 +++--- CHANGELOG.md | 6 +++--- Package.resolved | 15 --------------- Package.swift | 2 ++ README.md | 18 +++++++++--------- .../project.pbxproj | 13 +++++++++++-- .../project.pbxproj | 3 --- 7 files changed, 28 insertions(+), 35 deletions(-) delete mode 100644 Package.resolved diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1965d4..a62d4d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +16,8 @@ jobs: strategy: matrix: traits: - - { name: "default traits", flags: "" } - - { name: "DependencyInjection", flags: "--traits DependencyInjection" } + - { name: "DependencyInjection (default)", flags: "" } + - { name: "without DependencyInjection", flags: "--disable-default-traits" } steps: - uses: actions/checkout@v4 - name: Run tests @@ -40,7 +40,7 @@ jobs: sample-apps: name: Sample apps (iOS) - # Enabling a package trait from an Xcode project needs Xcode 26.4 or later + # Package traits in an Xcode project need Xcode 26.4 or later runs-on: macos-26 strategy: matrix: diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f83fa6..9c5c4a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,16 +5,16 @@ See "Migrating to 4.0" in the README. ### Breaking -- STRV Dependency Injection support is now the `DependencyInjection` package trait, off by default. Without it, the dependency isn't downloaded. +- STRV Dependency Injection support is now the `DependencyInjection` package trait. It's on by default; with `traits: []` the dependency isn't downloaded. - The `DeallocTestsDIFree` product is removed. Use the `DeallocTests` product and `import DeallocTests`. -- Swift 6.1 (Xcode 16.3) is required. Enabling the trait from an Xcode project needs Xcode 26.4. +- Swift 6.1 (Xcode 16.3) is required. Turning the trait off from an Xcode project needs Xcode 26.4. - `DefaultInitializable` is removed. ### Deprecated - `DeallocTester`, `DeallocTest`, `DeallocTestable` and `ClassNameIdentifiable`. Use `expectDeallocation`. They will be removed in 5.0. ### Changed -- The sample apps use `expectDeallocation`: `DeallocTestsAppDIFreeSPM` with XCTest, `DeallocTestsAppSPM` with Swift Testing and the `DependencyInjection` trait. +- The sample apps use `expectDeallocation`: `DeallocTestsAppDIFreeSPM` with XCTest and the trait turned off, `DeallocTestsAppSPM` with Swift Testing and the default trait. - CI tests both trait configurations. ## 3.3.0 diff --git a/Package.resolved b/Package.resolved deleted file mode 100644 index c097015..0000000 --- a/Package.resolved +++ /dev/null @@ -1,15 +0,0 @@ -{ - "originHash" : "a44d746ebb3eb351e83ad607a462290a63e9aa1d54f6ce70caa79d0341e911b3", - "pins" : [ - { - "identity" : "ios-dependency-injection", - "kind" : "remoteSourceControl", - "location" : "https://github.com/strvcom/ios-dependency-injection.git", - "state" : { - "revision" : "bff058aab9f85fd687be3daddb0d38d094b56e7b", - "version" : "2.0.1" - } - } - ], - "version" : 3 -} diff --git a/Package.swift b/Package.swift index 638245d..b54acd6 100644 --- a/Package.swift +++ b/Package.swift @@ -26,6 +26,8 @@ let package = Package( name: "DependencyInjection", description: "Integration with STRV Dependency Injection: expectDeallocation(of:resolvedFrom:) and the AsyncContainer in DeallocTester" ), + // Most projects use STRV Dependency Injection. Projects that don't can opt out with `traits: []`. + .default(enabledTraits: ["DependencyInjection"]), ], dependencies: [ .package(url: "https://github.com/strvcom/ios-dependency-injection.git", .upToNextMajor(from: "2.0.0")) diff --git a/README.md b/README.md index 75dbab6..74e10cf 100644 --- a/README.md +++ b/README.md @@ -27,14 +27,14 @@ DeallocTests work well with apps that use MVVM-C (MVVM with ViewCoordinators) ar ## STRV Dependency Injection library -DeallocTests has no dependencies by default. The integration with [STRV Dependency Injection library](https://github.com/strvcom/ios-dependency-injection) is behind the `DependencyInjection` [package trait](#installation): enable it and the library is pulled in, otherwise it's not even downloaded. +DeallocTests integrates with [STRV Dependency Injection library](https://github.com/strvcom/ios-dependency-injection). The integration is the `DependencyInjection` [package trait](#installation), which is on by default. Projects that don't use STRV Dependency Injection can turn it off, and the library is then not even downloaded. ## Requirements - iOS 17.0+ / macOS 13.0+ - Swift 6.1+ / Xcode 16.3+ - Swift Testing or XCTest -- Enabling the `DependencyInjection` trait from an Xcode project needs Xcode 26.4 or later +- Turning the `DependencyInjection` trait off from an Xcode project needs Xcode 26.4 or later ## Installation @@ -62,17 +62,16 @@ let package = Package( ) ``` -If you use STRV Dependency Injection, enable the `DependencyInjection` trait: +This includes the STRV Dependency Injection integration. If your project doesn't use STRV Dependency Injection, turn off the default trait, so the library isn't downloaded: ``` swift -.package(url: "https://github.com/strvcom/DeallocTests.git", .upToNextMajor(from: "4.0.0"), traits: ["DependencyInjection"]) +.package(url: "https://github.com/strvcom/DeallocTests.git", .upToNextMajor(from: "4.0.0"), traits: []) ``` -In Xcode, add the package via *File › Add Package Dependencies…* and link the `DeallocTests` product to your test target only. To use STRV Dependency Injection, enable the `DependencyInjection` trait for the package in Xcode 26.4 or later. The project file then lists it on the package reference: +In Xcode, add the package via *File › Add Package Dependencies…* and link the `DeallocTests` product to your test target only. To turn the STRV Dependency Injection integration off, disable the package's default traits in Xcode 26.4 or later. The package reference in the project file then has an empty list: ``` traits = ( - DependencyInjection, ); ``` @@ -287,8 +286,8 @@ Without the trait, `objectCreation` takes no parameter: `DeallocTest(objectCreat **Dependency Injection.** The `DeallocTestsDIFree` product is gone. Everyone uses the `DeallocTests` product and `import DeallocTests`: -- If you used `DeallocTests` with STRV Dependency Injection, enable the `DependencyInjection` trait (see [Installation](#installation)). -- If you used `DeallocTestsDIFree`, link the `DeallocTests` product instead and replace `import DeallocTestsDIFree` with `import DeallocTests`. No trait is needed. +- If you used `DeallocTests` with STRV Dependency Injection, nothing changes. The `DependencyInjection` trait is on by default. +- If you used `DeallocTestsDIFree`, link the `DeallocTests` product instead, replace `import DeallocTestsDIFree` with `import DeallocTests`, and turn the default trait off (see [Installation](#installation)) so STRV Dependency Injection isn't downloaded. **`DeallocTester`.** Existing tests keep working but produce deprecation warnings. Each `DeallocTest` becomes one `expectDeallocation` call, and the `DeallocTestable` conformances can be deleted: @@ -342,7 +341,8 @@ final class MainCoordinatorDeallocTests: XCTestCase { The folder `SampleApps` contains two demo projects. The application itself is very simple: there are just three screens in the navigation stack, all handled by `MainCoordinator`. - `DeallocTestsAppDIFreeSPM` checks the screens and the coordinator with `expectDeallocation` in **XCTest** (`MainCoordinatorDeallocTester.swift`). -- `DeallocTestsAppSPM` enables the `DependencyInjection` trait. `ExpectDeallocationTests.swift` does the checks with `expectDeallocation` in **Swift Testing**, including a service resolved from an `AsyncContainer`. The other test files show the deprecated `DeallocTester` API. +- `DeallocTestsAppDIFreeSPM` turns the `DependencyInjection` trait off in its Xcode project, so STRV Dependency Injection isn't downloaded. +- `DeallocTestsAppSPM` uses the default `DependencyInjection` trait. `ExpectDeallocationTests.swift` does the checks with `expectDeallocation` in **Swift Testing**, including a service resolved from an `AsyncContainer`. The other test files show the deprecated `DeallocTester` API. The sample app intentionally contains a memory leak in `SecondViewController.swift`. This class contains a closure with a strong reference to `self`. The test fails with: diff --git a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj index 477bd05..1f0efc9 100644 --- a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj +++ b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj @@ -52,7 +52,6 @@ 27A08052245CBA790037F1DB /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 27A08056245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainCoordinatorDeallocTester.swift; sourceTree = ""; }; 7E877B2A5D7E5C94D45AE696 /* Pods_DeallocTestsAppCocoapodsTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_DeallocTestsAppCocoapodsTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - ADCB58E327FD82B4009E0DEB /* DeallocTests */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = DeallocTests; path = ../..; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -150,7 +149,6 @@ ADCB58E227FD82B4009E0DEB /* Packages */ = { isa = PBXGroup; children = ( - ADCB58E327FD82B4009E0DEB /* DeallocTests */, ); name = Packages; sourceTree = ""; @@ -227,6 +225,7 @@ ); mainGroup = 27A0801A245C859C0037F1DB; packageReferences = ( + AD0E0E0E2F00000100000020 /* XCLocalSwiftPackageReference "../.." */, ); productRefGroup = 27A08024245C859C0037F1DB /* Products */; projectDirPath = ""; @@ -539,9 +538,19 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + AD0E0E0E2F00000100000020 /* XCLocalSwiftPackageReference "../.." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../..; + traits = ( + ); + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ ADCB58E427FD840C009E0DEB /* DeallocTests */ = { isa = XCSwiftPackageProductDependency; + package = AD0E0E0E2F00000100000020 /* XCLocalSwiftPackageReference "../.." */; productName = DeallocTests; }; /* End XCSwiftPackageProductDependency section */ diff --git a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj index cae2b1d..9d64da8 100644 --- a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj +++ b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj @@ -562,9 +562,6 @@ AD0E0E0E2F00000100000010 /* XCLocalSwiftPackageReference "../.." */ = { isa = XCLocalSwiftPackageReference; relativePath = ../..; - traits = ( - DependencyInjection, - ); }; /* End XCLocalSwiftPackageReference section */ From 279f57b93f5e6d2ad7aab35f31fd2e1cf3184f96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Fri, 2 Oct 2026 16:57:35 +0200 Subject: [PATCH 3/9] docs: single 4.0.0 changelog entry Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 50 +++++++++++++++----------------------------------- 1 file changed, 15 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c5c4a0..f4f3f4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,53 +4,33 @@ See "Migrating to 4.0" in the README. +### Added +- `expectDeallocation(_:timeout:afterRelease:of:)`: creates an object, runs its lifecycle, releases it and checks that it deallocates. Works in Swift Testing and XCTest, needs no `DeallocTestable` conformance and reports leaks at the line of the test. +- Lifecycles: `.none`, `.loadView`, `.present`, `.push` (with an optional interaction while on screen), `.hosting` for SwiftUI views (UIKit and AppKit) and `.custom`. +- Leak messages list likely causes found in the leaked object's stored properties: closures, `Task`s, Combine subscriptions, timers and reference cycles through properties. +- `trackForDeallocation(_:)`: an `XCTestCase` method and the `.checksDeallocation` Swift Testing trait for checking objects at the end of ordinary unit tests. Inside an `expectDeallocation` closure, it checks the object together with the tested one. +- `expectDeallocation(of:resolvedFrom:)` for dependencies resolved from an `AsyncContainer`. +- Swift Testing and XCTest tests of the library on macOS and the iOS simulator, and GitHub Actions CI. + ### Breaking -- STRV Dependency Injection support is now the `DependencyInjection` package trait. It's on by default; with `traits: []` the dependency isn't downloaded. +- STRV Dependency Injection support is the `DependencyInjection` package trait. It's on by default; with `traits: []` the dependency isn't downloaded. - The `DeallocTestsDIFree` product is removed. Use the `DeallocTests` product and `import DeallocTests`. - Swift 6.1 (Xcode 16.3) is required. Turning the trait off from an Xcode project needs Xcode 26.4. - `DefaultInitializable` is removed. +- `DeallocTestable` no longer requires `Sendable`. +- `Alloc`/`Dealloc` logging is off by default (`DeallocTester.isLoggingEnabled`). ### Deprecated - `DeallocTester`, `DeallocTest`, `DeallocTestable` and `ClassNameIdentifiable`. Use `expectDeallocation`. They will be removed in 5.0. -### Changed -- The sample apps use `expectDeallocation`: `DeallocTestsAppDIFreeSPM` with XCTest and the trait turned off, `DeallocTestsAppSPM` with Swift Testing and the default trait. -- CI tests both trait configurations. - -## 3.3.0 - -### Added -- Leak messages list likely causes found in the leaked object's stored properties: closures, `Task`s, Combine subscriptions, timers and reference cycles through properties. -- `.hosting { … }` lifecycle that shows a SwiftUI view built from the object in a test window (UIKit and AppKit), so `onAppear` and `.task` run. -- `trackForDeallocation(_:)` inside an `expectDeallocation` closure checks the object together with the tested one, in Swift Testing and XCTest. - -## 3.2.0 - -### Added -- `expectDeallocation(_:timeout:afterRelease:of:)`: creates an object, runs its lifecycle, releases it and checks that it deallocates. Works in Swift Testing and XCTest, needs no `DeallocTestable` conformance and reports leaks at the line of the test. -- Lifecycles: `.none`, `.loadView`, `.present`, `.push` (with an optional interaction while on screen) and `.custom`. -- `trackForDeallocation(_:)` for checking objects at the end of ordinary unit tests: an `XCTestCase` method, and the `.checksDeallocation` Swift Testing trait (Swift 6.1+). -- `expectDeallocation(of:resolvedFrom:)` for dependencies resolved from an `AsyncContainer` (`DeallocTests` product only). -- Swift Testing sample tests in `DeallocTestsAppSPM`. - -## 3.1.0 - -### Fixed +### Fixed (`DeallocTester`) - Dealloc tests no longer hang on macOS. - A `nil` or non-`DeallocTestable` object no longer crashes or hangs the test. - Leaks are detected per instance instead of per class. - Thread-safe dealloc tracking; no more associated-object key warnings. -- The dependency URL uses https, so the package resolves without SSH access to GitHub. - -### Changed - Polling with `deallocationTimeout` (2 s) replaces the fixed delays. -- The presenting controller is created automatically; the test window is cleaned up in `tearDown`. -- `DeallocTestable` no longer requires `Sendable`. -- `Alloc`/`Dealloc` logging is off by default (`DeallocTester.isLoggingEnabled`). -- `setUp()` is `open`. - -### Deprecated -- `DefaultInitializable`, to be removed in 4.0. +- The presenting controller is created automatically; the test window is cleaned up in `tearDown`. `setUp()` is `open`. +- The dependency URL uses https, so the package resolves without SSH access to GitHub. ### Removed -- Travis CI, Danger, Carthage, jazzy and unused headers. CI runs on GitHub Actions. +- Travis CI, Danger, Carthage, jazzy and unused headers. From 4f97f6518f32aa159630cbb087b6314fc9a77cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Fri, 2 Oct 2026 17:35:58 +0200 Subject: [PATCH 4/9] fix: readable names of private types in leak messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swift names private and local types Module.(unknown context at $…).Name. Found while adopting DeallocTests in JustFlip. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + Package.resolved | 15 +++++++++++++++ .../Expectation/DeallocationTracker.swift | 9 ++++++++- .../ExpectDeallocationTests.swift | 19 +++++++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 Package.resolved diff --git a/CHANGELOG.md b/CHANGELOG.md index f4f3f4d..5d15bd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ See "Migrating to 4.0" in the README. - `expectDeallocation(_:timeout:afterRelease:of:)`: creates an object, runs its lifecycle, releases it and checks that it deallocates. Works in Swift Testing and XCTest, needs no `DeallocTestable` conformance and reports leaks at the line of the test. - Lifecycles: `.none`, `.loadView`, `.present`, `.push` (with an optional interaction while on screen), `.hosting` for SwiftUI views (UIKit and AppKit) and `.custom`. - Leak messages list likely causes found in the leaked object's stored properties: closures, `Task`s, Combine subscriptions, timers and reference cycles through properties. +- Leak messages show readable names for private and local types, without Swift's `(unknown context at $…)`. - `trackForDeallocation(_:)`: an `XCTestCase` method and the `.checksDeallocation` Swift Testing trait for checking objects at the end of ordinary unit tests. Inside an `expectDeallocation` closure, it checks the object together with the tested one. - `expectDeallocation(of:resolvedFrom:)` for dependencies resolved from an `AsyncContainer`. - Swift Testing and XCTest tests of the library on macOS and the iOS simulator, and GitHub Actions CI. diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..b0e2fba --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "e5040c7b1c52e363badaef6255e97818a4270c1fe87da50a7dbbec29c73be249", + "pins" : [ + { + "identity" : "ios-dependency-injection", + "kind" : "remoteSourceControl", + "location" : "https://github.com/strvcom/ios-dependency-injection.git", + "state" : { + "revision" : "bff058aab9f85fd687be3daddb0d38d094b56e7b", + "version" : "2.0.1" + } + } + ], + "version" : 3 +} diff --git a/Sources/DeallocTests/Expectation/DeallocationTracker.swift b/Sources/DeallocTests/Expectation/DeallocationTracker.swift index 044838b..3c8988f 100644 --- a/Sources/DeallocTests/Expectation/DeallocationTracker.swift +++ b/Sources/DeallocTests/Expectation/DeallocationTracker.swift @@ -29,7 +29,7 @@ final class DeallocationTracker { func track(_ object: AnyObject, at location: TestSourceLocation) { trackedObjects.append( - TrackedObject(object: object, typeName: String(reflecting: type(of: object)), location: location) + TrackedObject(object: object, typeName: Self.readableTypeName(of: object), location: location) ) } @@ -61,6 +61,13 @@ final class DeallocationTracker { trackedObjects.removeAll() } + /// Module-qualified type name without the `(unknown context at $…)` part + /// that Swift adds for private and local types + static func readableTypeName(of object: AnyObject) -> String { + String(reflecting: type(of: object)) + .replacingOccurrences(of: #"\(unknown context at \$[0-9a-fA-F]+\)\."#, with: "", options: .regularExpression) + } + static func leakMessage(typeName: String, timeout: Duration, hints: [String] = []) -> String { let summary = "\(typeName) was not deallocated within \(timeout.formatted(.units(allowed: [.seconds, .milliseconds])))." diff --git a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift index e6d1f3d..2d7447d 100644 --- a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift +++ b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift @@ -41,6 +41,15 @@ final class Cache { struct LifecycleError: Error {} +/// Swift names private types `Module.(unknown context at $…).Name` +private final class PrivateRetainCycle { + var closure: (() -> Void)? + + init() { + closure = { _ = self } + } +} + /// Matches leak reports attributed to this file func isLeakReport(of typeName: String) -> (Issue) -> Bool { { issue in @@ -66,6 +75,16 @@ struct ExpectDeallocationTests { } } + @Test func privateTypeNameIsReadable() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { PrivateRetainCycle() } + } matching: { issue in + issue.comments.contains { comment in + comment.rawValue.hasPrefix("DeallocTestsTests.PrivateRetainCycle was not deallocated") + } + } + } + @Test func customLifecycleRunsBeforeRelease() async { var exercisedObject: ObjectIdentifier? From d68e1525f161e24d72ea651d452e2d8c1438e201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Fri, 2 Oct 2026 17:53:00 +0200 Subject: [PATCH 5/9] fix: name the concrete type when a resolved dependency is a value type The message named the protocol twice ("CreateDeckUseCase resolved for CreateDeckUseCase") and didn't say why the check is skipped. Found while adopting DeallocTests in JustFlip, whose deck use cases are structs. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 +- .../ExpectDeallocation+DependencyInjection.swift | 7 ++++++- Tests/DeallocTestsTests/ExpectDeallocationTests.swift | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d15bd3..608a641 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ See "Migrating to 4.0" in the README. - Leak messages list likely causes found in the leaked object's stored properties: closures, `Task`s, Combine subscriptions, timers and reference cycles through properties. - Leak messages show readable names for private and local types, without Swift's `(unknown context at $…)`. - `trackForDeallocation(_:)`: an `XCTestCase` method and the `.checksDeallocation` Swift Testing trait for checking objects at the end of ordinary unit tests. Inside an `expectDeallocation` closure, it checks the object together with the tested one. -- `expectDeallocation(of:resolvedFrom:)` for dependencies resolved from an `AsyncContainer`. +- `expectDeallocation(of:resolvedFrom:)` for dependencies resolved from an `AsyncContainer`. A dependency that turns out to be a value type is reported with its concrete type, since it can't leak. - Swift Testing and XCTest tests of the library on macOS and the iOS simulator, and GitHub Actions CI. ### Breaking diff --git a/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift b/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift index c3441e7..83bbe55 100644 --- a/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift +++ b/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift @@ -59,7 +59,12 @@ private func resolveAndTrack( // A value type would be boxed into a temporary object that deallocates immediately guard Mirror(reflecting: dependency).displayStyle == .class else { - reportIssue("\(Swift.type(of: dependency)) resolved for \(type) is not a class instance", at: location) + // `as Any` gives the concrete type instead of the protocol it was resolved as + let concreteType = Swift.type(of: dependency as Any) + reportIssue( + "\(concreteType) resolved for \(type) is a value type, so it can't leak. Check the class instances it holds instead.", + at: location + ) return false } diff --git a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift index 2d7447d..4916184 100644 --- a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift +++ b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift @@ -190,7 +190,7 @@ struct ExpectDeallocationDependencyInjectionTests { await withKnownIssue { await expectDeallocation(of: AnyService.self, resolvedFrom: container) } matching: { issue in - issue.comments.contains { $0.rawValue.contains("is not a class instance") } + issue.comments.contains { $0.rawValue.hasPrefix("ValueService resolved for AnyService is a value type") } } } } From 347e44f4206a1fd3bf9ad76311727962265a2e2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Fri, 2 Oct 2026 18:13:50 +0200 Subject: [PATCH 6/9] feat: leak hints mention external owners Hints only inspect the leaked object's own properties. In JustFlip, a SettingsCoordinator kept alive by its parent's childCoordinators got a hint about its own cancellables; the message now also says the object may be held from outside (a parent's list of children, a cache, a singleton). Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + README.md | 2 ++ .../DeallocTests/Expectation/DeallocationTracker.swift | 4 +++- Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift | 8 ++++++++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 608a641..a422c35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ See "Migrating to 4.0" in the README. - Lifecycles: `.none`, `.loadView`, `.present`, `.push` (with an optional interaction while on screen), `.hosting` for SwiftUI views (UIKit and AppKit) and `.custom`. - Leak messages list likely causes found in the leaked object's stored properties: closures, `Task`s, Combine subscriptions, timers and reference cycles through properties. - Leak messages show readable names for private and local types, without Swift's `(unknown context at $…)`. +- With hints, the leak message also reminds that the object may be held from outside (a parent's list of children, a cache, a singleton), which hints can't see. - `trackForDeallocation(_:)`: an `XCTestCase` method and the `.checksDeallocation` Swift Testing trait for checking objects at the end of ordinary unit tests. Inside an `expectDeallocation` closure, it checks the object together with the tested one. - `expectDeallocation(of:resolvedFrom:)` for dependencies resolved from an `AsyncContainer`. A dependency that turns out to be a value type is reported with its concrete type, since it can't leak. - Swift Testing and XCTest tests of the library on macOS and the iOS simulator, and GitHub Actions CI. diff --git a/README.md b/README.md index 74e10cf..9327a6b 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ The same calls work inside an `XCTestCase`. A leak fails at the line of the test LeakTests.swift:12: MyApp.ProfileViewController was not deallocated within 2 sec. Possible causes: • `onUpdate` is a closure. Make sure it captures self weakly • `self.viewModel.owner` refers back to the object. That's a retain cycle unless one of the references is weak + • Or something outside still holds it: a parent's list of children, a cache or a singleton ``` The hints come from the leaked object's stored properties: closures, `Task`s, Combine subscriptions, timers, and reference cycles through properties. Reflection can't tell weak properties from strong ones or look inside closures, so treat them as suggestions. @@ -349,6 +350,7 @@ The sample app intentionally contains a memory leak in `SecondViewController.swi ``` MainCoordinatorDeallocTester.swift:25: error: -[DeallocTestsAppSPMTests.MainCoordinatorDeallocTester test_secondScreen] : failed - DeallocTestsAppSPM.SecondViewController was not deallocated within 2 sec. Possible causes: • `someClosure` is a closure. Make sure it captures self weakly + • Or something outside still holds it: a parent's list of children, a cache or a singleton ``` If you comment out the first line and uncomment the second one, the retain cycle disappears and the test will succeed. diff --git a/Sources/DeallocTests/Expectation/DeallocationTracker.swift b/Sources/DeallocTests/Expectation/DeallocationTracker.swift index 3c8988f..72952bb 100644 --- a/Sources/DeallocTests/Expectation/DeallocationTracker.swift +++ b/Sources/DeallocTests/Expectation/DeallocationTracker.swift @@ -76,6 +76,8 @@ final class DeallocationTracker { + "delegates that aren't weak, timers, notification observers and long-running tasks or subscriptions." } - return summary + " Possible causes:\n" + hints.map { " • \($0)" }.joined(separator: "\n") + // Hints only see the object's own properties; the reference can also come from outside + let causes = hints + ["Or something outside still holds it: a parent's list of children, a cache or a singleton"] + return summary + " Possible causes:\n" + causes.map { " • \($0)" }.joined(separator: "\n") } } diff --git a/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift b/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift index 31b0512..a967c4b 100644 --- a/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift +++ b/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift @@ -138,6 +138,14 @@ struct LeakHintsTests { } } + @Test func hintsMentionExternalOwners() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { ClosureLeak() } + } matching: { issue in + isLeakReport(of: "ClosureLeak", mentioning: "something outside still holds it")(issue) + } + } + @Test func leakWithoutSuspectsGetsGenericMessage() async { let cache = Cache() From 9854c26d113c1159a047887192abc061b74407a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Fri, 2 Oct 2026 18:18:36 +0200 Subject: [PATCH 7/9] fix: readable @Observable property names in leak hints @Observable stores each property as _name and adds _$observationRegistrar. In JustFlip an @Observable store's hint read `_cancellables`. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + .../DeallocTests/Expectation/LeakHints.swift | 7 ++++-- .../LeakHintsAndSwiftUITests.swift | 22 +++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a422c35..1a7fc11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ See "Migrating to 4.0" in the README. - Leak messages list likely causes found in the leaked object's stored properties: closures, `Task`s, Combine subscriptions, timers and reference cycles through properties. - Leak messages show readable names for private and local types, without Swift's `(unknown context at $…)`. - With hints, the leak message also reminds that the object may be held from outside (a parent's list of children, a cache, a singleton), which hints can't see. +- Hints show `@Observable` properties by their declared names, without the macro's `_` prefix and registrar. - `trackForDeallocation(_:)`: an `XCTestCase` method and the `.checksDeallocation` Swift Testing trait for checking objects at the end of ordinary unit tests. Inside an `expectDeallocation` closure, it checks the object together with the tested one. - `expectDeallocation(of:resolvedFrom:)` for dependencies resolved from an `AsyncContainer`. A dependency that turns out to be a value type is reported with its concrete type, since it can't leak. - Swift Testing and XCTest tests of the library on macOS and the iOS simulator, and GitHub Actions CI. diff --git a/Sources/DeallocTests/Expectation/LeakHints.swift b/Sources/DeallocTests/Expectation/LeakHints.swift index f6345a3..3cdc3cc 100644 --- a/Sources/DeallocTests/Expectation/LeakHints.swift +++ b/Sources/DeallocTests/Expectation/LeakHints.swift @@ -108,7 +108,8 @@ enum LeakHints { } for child in currentMirror.children { - guard let label = child.label else { + // `_$observationRegistrar` is generated by `@Observable`, not the app's state + guard let label = child.label, !label.hasPrefix("_$") else { continue } properties.append(Property(label: cleaned(label), value: child.value)) @@ -145,6 +146,8 @@ enum LeakHints { private static func cleaned(_ label: String) -> String { // Lazy properties are stored as `$__lazy_storage_$_name` - label.replacingOccurrences(of: "$__lazy_storage_$_", with: "") + let label = label.replacingOccurrences(of: "$__lazy_storage_$_", with: "") + // `@Observable` stores each property as `_name` + return label.hasPrefix("_") ? String(label.dropFirst()) : label } } diff --git a/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift b/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift index a967c4b..5e78f23 100644 --- a/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift +++ b/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift @@ -6,6 +6,7 @@ // import Combine +import Observation import DeallocTests import SwiftUI import Testing @@ -20,6 +21,17 @@ final class ClosureLeak { } } +/// `@Observable` stores the closure as `_onUpdate` +@available(macOS 14, iOS 17, *) +@Observable +final class ObservableClosureLeak { + var onUpdate: (() -> Void)? + + init() { + onUpdate = { _ = self } + } +} + final class CycleParent { var child: CycleChild? @@ -122,6 +134,16 @@ struct LeakHintsTests { } } + @Test @available(macOS 14, iOS 17, *) + func observablePropertyNamesAreReadable() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { ObservableClosureLeak() } + } matching: { issue in + isLeakReport(of: "ObservableClosureLeak", mentioning: "• `onUpdate` is a closure")(issue) + && !issue.comments.contains { $0.rawValue.contains("observationRegistrar") } + } + } + @Test func propertyCycleIsShown() async { await withKnownIssue { await expectDeallocation(timeout: .milliseconds(100)) { CycleParent() } From ab342de98f533cdb97984f4ab8e197a521b6531b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Fri, 2 Oct 2026 18:43:38 +0200 Subject: [PATCH 8/9] fix: wait up to 10 s for UIKit lifecycle states In JustFlip's full test run, a host window sometimes took longer than 2 s to appear on a loaded simulator, failing the check with 'The test window could not be shown'. The waits return as soon as the state is reached. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + Sources/DeallocTests/Expectation/Lifecycle.swift | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a7fc11..c20cfc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ See "Migrating to 4.0" in the README. - Leak messages show readable names for private and local types, without Swift's `(unknown context at $…)`. - With hints, the leak message also reminds that the object may be held from outside (a parent's list of children, a cache, a singleton), which hints can't see. - Hints show `@Observable` properties by their declared names, without the macro's `_` prefix and registrar. +- UIKit lifecycles wait up to 10 s for a screen to appear, be dismissed or popped, instead of 2 s, so they stay reliable on a loaded simulator. - `trackForDeallocation(_:)`: an `XCTestCase` method and the `.checksDeallocation` Swift Testing trait for checking objects at the end of ordinary unit tests. Inside an `expectDeallocation` closure, it checks the object together with the tested one. - `expectDeallocation(of:resolvedFrom:)` for dependencies resolved from an `AsyncContainer`. A dependency that turns out to be a value type is reported with its concrete type, since it can't leak. - Swift Testing and XCTest tests of the library on macOS and the iOS simulator, and GitHub Actions CI. diff --git a/Sources/DeallocTests/Expectation/Lifecycle.swift b/Sources/DeallocTests/Expectation/Lifecycle.swift index 120da6c..38b7326 100644 --- a/Sources/DeallocTests/Expectation/Lifecycle.swift +++ b/Sources/DeallocTests/Expectation/Lifecycle.swift @@ -57,9 +57,11 @@ extension Lifecycle { } } -/// Polls the condition until it holds or the timeout elapses +/// Polls the condition until it holds or the timeout elapses. Returns as soon as the +/// condition holds, so the generous default only costs time when UIKit really is stuck, +/// e.g. on a simulator loaded by many tests running in parallel. @MainActor -func waitUntil(timeout: Duration = .seconds(2), _ condition: @MainActor () -> Bool) async -> Bool { +func waitUntil(timeout: Duration = .seconds(10), _ condition: @MainActor () -> Bool) async -> Bool { let clock = ContinuousClock() let deadline = clock.now + timeout From e80136849eac7a868e3826ce1867e187d1410865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Fri, 2 Oct 2026 19:03:12 +0200 Subject: [PATCH 9/9] feat: support STRV Dependency Injection 1.0.4 to 2.x DeallocTests only uses AsyncContainer's init, clean(), releaseSharedInstances() and resolve(type:). JustFlip, on DI 1.x, needed a revision override to adopt DeallocTests 4; verified with a consumer pinned to DI 1.0.4. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + Package.resolved | 15 --------------- Package.swift | 4 +++- 3 files changed, 4 insertions(+), 16 deletions(-) delete mode 100644 Package.resolved diff --git a/CHANGELOG.md b/CHANGELOG.md index c20cfc2..6e0f568 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ See "Migrating to 4.0" in the README. ### Breaking - STRV Dependency Injection support is the `DependencyInjection` package trait. It's on by default; with `traits: []` the dependency isn't downloaded. +- Works with STRV Dependency Injection 1.0.4 up to 2.x. - The `DeallocTestsDIFree` product is removed. Use the `DeallocTests` product and `import DeallocTests`. - Swift 6.1 (Xcode 16.3) is required. Turning the trait off from an Xcode project needs Xcode 26.4. - `DefaultInitializable` is removed. diff --git a/Package.resolved b/Package.resolved deleted file mode 100644 index b0e2fba..0000000 --- a/Package.resolved +++ /dev/null @@ -1,15 +0,0 @@ -{ - "originHash" : "e5040c7b1c52e363badaef6255e97818a4270c1fe87da50a7dbbec29c73be249", - "pins" : [ - { - "identity" : "ios-dependency-injection", - "kind" : "remoteSourceControl", - "location" : "https://github.com/strvcom/ios-dependency-injection.git", - "state" : { - "revision" : "bff058aab9f85fd687be3daddb0d38d094b56e7b", - "version" : "2.0.1" - } - } - ], - "version" : 3 -} diff --git a/Package.swift b/Package.swift index b54acd6..176cfeb 100644 --- a/Package.swift +++ b/Package.swift @@ -30,7 +30,9 @@ let package = Package( .default(enabledTraits: ["DependencyInjection"]), ], dependencies: [ - .package(url: "https://github.com/strvcom/ios-dependency-injection.git", .upToNextMajor(from: "2.0.0")) + // DeallocTests only uses AsyncContainer's init, clean(), releaseSharedInstances() and + // resolve(type:), which DI 1.x and 2.x both have. + .package(url: "https://github.com/strvcom/ios-dependency-injection.git", "1.0.4" ..< "3.0.0") ], targets: [ .target(