diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4e9af6c..a62d4d4 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: "DependencyInjection (default)", flags: "" }
+ - { name: "without DependencyInjection", flags: "--disable-default-traits" }
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
+ # Package traits in an Xcode project need Xcode 26.4 or later
+ runs-on: macos-26
strategy:
matrix:
sample: [DeallocTestsAppSPM, DeallocTestsAppDIFreeSPM]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d8f9e87..6e0f568 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,39 +1,41 @@
# Changelog
-## 3.3.0
+## 4.0.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
+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) 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`.
+- 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.
+- 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.
+
+### 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.
+- `DeallocTestable` no longer requires `Sendable`.
+- `Alloc`/`Dealloc` logging is off by default (`DeallocTester.isLoggingEnabled`).
-## 3.1.0
+### Deprecated
+- `DeallocTester`, `DeallocTest`, `DeallocTestable` and `ClassNameIdentifiable`. Use `expectDeallocation`. They will be removed in 5.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.
diff --git a/Package.resolved b/Package.resolved
deleted file mode 100644
index 079bab0..0000000
--- a/Package.resolved
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "originHash" : "03f6a00fd6dd53633e26cf2e21b6657b3434cd1af7cce0ab3346d4822e73b9b4",
- "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 d12c0d3..176cfeb 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,34 +20,41 @@ 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"
),
+ // 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"))
+ // 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(
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..9327a6b 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 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.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
+- Turning the `DependencyInjection` trait off 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,18 @@ 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.
+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: [])
+```
+
+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 = (
+);
+```
## Usage
@@ -99,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.
@@ -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,78 @@ 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, 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:
+
+```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`).
+- `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:
```
-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
+ • 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/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj
index e69854b..1f0efc9 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,10 +50,8 @@
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 = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -69,7 +66,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
- ADCB58E527FD840C009E0DEB /* DeallocTestsDIFree in Frameworks */,
+ ADCB58E527FD840C009E0DEB /* DeallocTests in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -126,7 +123,6 @@
isa = PBXGroup;
children = (
27A0803F245C859E0037F1DB /* Info.plist */,
- 27A08054245CBBD10037F1DB /* DeallocTestConformances.swift */,
27A08056245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift */,
);
path = DeallocTestsAppSPMTests;
@@ -153,7 +149,6 @@
ADCB58E227FD82B4009E0DEB /* Packages */ = {
isa = PBXGroup;
children = (
- ADCB58E327FD82B4009E0DEB /* DeallocTests */,
);
name = Packages;
sourceTree = "";
@@ -195,7 +190,7 @@
);
name = DeallocTestsAppSPMTests;
packageProductDependencies = (
- ADCB58E427FD840C009E0DEB /* DeallocTestsDIFree */,
+ ADCB58E427FD840C009E0DEB /* DeallocTests */,
);
productName = DeallocTestsAppCocoapodsTests;
productReference = 27A08039245C859E0037F1DB /* DeallocTestsAppSPMTests.xctest */;
@@ -230,6 +225,7 @@
);
mainGroup = 27A0801A245C859C0037F1DB;
packageReferences = (
+ AD0E0E0E2F00000100000020 /* XCLocalSwiftPackageReference "../.." */,
);
productRefGroup = 27A08024245C859C0037F1DB /* Products */;
projectDirPath = "";
@@ -280,7 +276,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 */,
@@ -543,10 +538,20 @@
};
/* End XCConfigurationList section */
+/* Begin XCLocalSwiftPackageReference section */
+ AD0E0E0E2F00000100000020 /* XCLocalSwiftPackageReference "../.." */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = ../..;
+ traits = (
+ );
+ };
+/* End XCLocalSwiftPackageReference section */
+
/* Begin XCSwiftPackageProductDependency section */
- ADCB58E427FD840C009E0DEB /* DeallocTestsDIFree */ = {
+ ADCB58E427FD840C009E0DEB /* DeallocTests */ = {
isa = XCSwiftPackageProductDependency;
- productName = DeallocTestsDIFree;
+ package = AD0E0E0E2F00000100000020 /* XCLocalSwiftPackageReference "../.." */;
+ 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..9d64da8 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,17 @@
};
/* End XCConfigurationList section */
+/* Begin XCLocalSwiftPackageReference section */
+ AD0E0E0E2F00000100000010 /* XCLocalSwiftPackageReference "../.." */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = ../..;
+ };
+/* 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/DeallocationTracker.swift b/Sources/DeallocTests/Expectation/DeallocationTracker.swift
index 044838b..72952bb 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])))."
@@ -69,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/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift b/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift
index 255ed5f..83bbe55 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
@@ -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/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/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
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..4916184 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
@@ -38,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
@@ -63,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?
@@ -143,19 +165,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)
}
@@ -166,11 +190,13 @@ 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") }
}
}
}
+#endif
+
// MARK: - trackForDeallocation
#if compiler(>=6.1)
diff --git a/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift b/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift
index 31b0512..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() }
@@ -138,6 +160,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()