diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fff48ab..a62d4d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,11 +20,6 @@ jobs: - { name: "without DependencyInjection", flags: "--disable-default-traits" } steps: - uses: actions/checkout@v4 - - name: Check DeallocTestsCore rules - # Core is shared with DeallocWatcher, which apps link: no test frameworks, no classes or actors - run: | - ! grep -rnE "^\s*(@testable )?import (XCTest|Testing)\b" Sources/DeallocTestsCore - ! grep -rnE "^\s*(public |package |internal |private |fileprivate |final |open )*(class|actor) [A-Z]" Sources/DeallocTestsCore - name: Run tests run: swift test ${{ matrix.traits.flags }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b7473a..ad855aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,15 +5,17 @@ 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. +- `expectDeallocation(_:timeout:afterRelease:of:)`: creates an object, runs its lifecycle, releases it and checks that it deallocates. Works in Swift Testing and XCTest, checks any class without conformances 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 list likely causes found in the leaked object's stored properties: closures, `Task`s, Combine subscriptions, timers and reference cycles through properties, including a property that holds the object itself. Properties that are `nil` or empty are skipped. - Leak messages show readable names for private and local types, without Swift's `(unknown context at $…)`. +- Durations in messages read the same in every locale ("400 ms", "3.2 sec"). - 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. -- `DeallocationConfiguration` with the `.deallocationTimeout(_:)` and `.deallocationIssues(_:)` Swift Testing traits for a test or a whole suite, and `withDeallocationConfiguration(_:operation:)` for XCTest. Leaks can be reported as warnings that don't fail the test. +- `DeallocationConfiguration` with the `.deallocationTimeout(_:)`, `.deallocationGracePeriod(_:)` and `.deallocationIssues(_:)` Swift Testing traits for a test or a whole suite, and `withDeallocationConfiguration(_:operation:)` for XCTest (also around `invokeTest()` for a whole class). Leaks can be reported as warnings that don't fail the test. +- Grace period: an object still alive at the timeout is watched a little longer (3 s by default). If it goes away then, it's reported as a warning, "released after 3.2 sec … bounded retention, not a leak", instead of a failure. It's skipped when leaks are reported as warnings anyway. - `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. @@ -23,19 +25,9 @@ See "Migrating to 4.0" in the README. - 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`). +- `DeallocTester`, `DeallocTest`, `DeallocTestable` and `ClassNameIdentifiable` are removed. Each `DeallocTest` becomes one `expectDeallocation` call; see "Migrating to 4.0" in the README. -### Deprecated -- `DeallocTester`, `DeallocTest`, `DeallocTestable` and `ClassNameIdentifiable`. Use `expectDeallocation`. They will be removed in 5.0. - -### 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. -- Polling with `deallocationTimeout` (2 s) replaces the fixed delays. -- The presenting controller is created automatically; the test window is cleaned up in `tearDown`. `setUp()` is `open`. +### Fixed - The dependency URL uses https, so the package resolves without SSH access to GitHub. ### Removed diff --git a/Package.swift b/Package.swift index f7841ac..8c8fd35 100644 --- a/Package.swift +++ b/Package.swift @@ -24,26 +24,17 @@ let package = Package( traits: [ .trait( name: "DependencyInjection", - description: "Integration with STRV Dependency Injection: expectDeallocation(of:resolvedFrom:) and the AsyncContainer in DeallocTester" + description: "Integration with STRV Dependency Injection: expectDeallocation(of:resolvedFrom:)" ), - // Most projects use STRV Dependency Injection. Projects that don't can opt out with `traits: []`. .default(enabledTraits: ["DependencyInjection"]), ], dependencies: [ - // 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: [ - // Shared by DeallocTests and the upcoming DeallocWatcher: no XCTest, no Swift Testing, - // no classes or actors (it may be linked into both an app and its test bundle) - .target( - name: "DeallocTestsCore" - ), .target( name: "DeallocTests", dependencies: [ - "DeallocTestsCore", .product( name: "DependencyInjection", package: "ios-dependency-injection", @@ -51,10 +42,6 @@ let package = Package( ) ] ), - .testTarget( - name: "DeallocTestsCoreTests", - dependencies: ["DeallocTestsCore"] - ), .testTarget( name: "DeallocTestsTests", dependencies: [ diff --git a/README.md b/README.md index 803de87..00bed5b 100644 --- a/README.md +++ b/README.md @@ -77,9 +77,9 @@ traits = ( ## Usage -### `expectDeallocation` (recommended) +### `expectDeallocation` -`expectDeallocation` creates an object, runs its lifecycle, releases it and checks that it deallocates. It works in **Swift Testing and XCTest**, any class can be checked without a `DeallocTestable` conformance, and a leak is reported at the line of your test. +`expectDeallocation` creates an object, runs its lifecycle, releases it and checks that it deallocates. It works in **Swift Testing and XCTest**, any class can be checked without conformances or changes to the app target, and a leak is reported at the line of your test. ```swift import DeallocTests @@ -107,7 +107,7 @@ struct LeakTests { The same calls work inside an `XCTestCase`. A leak fails at the line of the test, and the message points at the likely cause: ``` -LeakTests.swift:12: MyApp.ProfileViewController was not deallocated within 2 sec. Possible causes: +LeakTests.swift:12: MyApp.ProfileViewController was not deallocated within 2 sec. It was watched for another 3 sec after that. 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 @@ -156,11 +156,21 @@ await expectDeallocation(.present) { Other parameters: -- `timeout` sets how long to wait for the deallocation (2 seconds by default). The check passes as soon as the object is gone. +- `timeout` sets how long to wait for this object to deallocate (2 seconds by default). The check passes as soon as the object is gone. +- `afterRelease` runs after the object is released and before the check, e.g. to clear a cache that legitimately holds it. + +#### Configuring a test, a suite or a test case class -#### Configuring a suite +`timeout` is the only option a call takes, because it describes the object: some screens take longer to go away. Everything else is a policy for a whole test or suite, set through `DeallocationConfiguration`: -Instead of passing the same values to every call, configure a test or a whole suite with traits: +| Option | Default | Swift Testing trait | +|---|---|---| +| `timeout` | 2 sec | `.deallocationTimeout(_:)` | +| `gracePeriod` | 3 sec | `.deallocationGracePeriod(_:)` | +| `severity` | `.error` | `.deallocationIssues(_:)` | + +- An object still alive at the timeout is watched for the **grace period**. If it goes away then, the check reports a warning, "released after 3.2 sec … bounded retention, not a leak", instead of failing. Passing checks never wait for it; only real leaks take the timeout plus the grace period to report. `.zero` turns it off. +- With `severity` `.warning`, leaks are reported as warnings that don't fail the test, e.g. while adopting dealloc tests in an existing project. The grace period is skipped then, since it can't change the outcome. ```swift @Suite(.deallocationTimeout(.seconds(5))) @@ -171,14 +181,21 @@ struct ScreenDeallocTests { … } struct LegacyDeallocTests { … } ``` -A test's own trait wins over its suite's, and a value passed to the call wins over both. In XCTest, use `withDeallocationConfiguration`: +A test's own trait wins over its suite's, and a `timeout` passed to the call wins over both. In XCTest, use `withDeallocationConfiguration` around a test's code, or around `invokeTest()` for a whole test case class: ```swift await withDeallocationConfiguration({ $0.timeout = .seconds(5) }) { await expectDeallocation(.present) { makeProfileViewController() } } + +final class LegacyDeallocTests: XCTestCase { + override func invokeTest() { + withDeallocationConfiguration({ $0.severity = .warning }) { + super.invokeTest() + } + } +} ``` -- `afterRelease` runs after the object is released and before the check, e.g. to clear a cache that legitimately holds it. `.present` needs a test target with a host app, because modal presentation needs a window scene. The other lifecycles also work in package tests. @@ -203,7 +220,7 @@ func test_loadsProfile() async { } ``` -In XCTest, keep the object in a local variable. A property of the test case lives until the test case is released. +In XCTest, keep the object in a local variable. A property of the test case lives until the test case is released. The check uses the configuration in effect where `trackForDeallocation` is called. ### STRV Dependency Injection @@ -220,90 +237,6 @@ With the `DependencyInjection` trait, a dependency can be resolved from an `Asyn Following the dependency graph, check the simplest dependencies first, then the ones that use them. -### 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). - -
- Documentation of the deprecated API - -1. Conform the tested classes to `DeallocTestable` in your test target. No changes to the main target are needed: - -```swift -import DeallocTests -@testable import MyApp - -extension MainCoordinator: @retroactive DeallocTestable {} -extension FirstViewController: @retroactive DeallocTestable {} -``` - -2. Subclass `DeallocTester` and describe the scenario: - -```swift -import DeallocTests -@testable import MyApp - -final class MainCoordinatorDeallocTester: DeallocTester { - @MainActor - func test_mainCoordinatorDealloc() async { - let mainCoordinator = MainCoordinator() - let expectation = expectation(description: "dealloc test") - - await performDeallocTest( - deallocTests: [ - DeallocTest(objectCreation: { [mainCoordinator] _ in mainCoordinator.createFirstViewController() }), - DeallocTest(objectCreation: { [mainCoordinator] _ in mainCoordinator.createSecondViewController() }), - DeallocTest(objectCreation: { _ in MainCoordinator() }) - ], - expectation: expectation - ) - - await fulfillment(of: [expectation], timeout: 60) - } -} -``` - -Each `DeallocTest` creates an object, releases it and checks that it was deallocated: - -- A `UIViewController` is presented full screen and dismissed first, so its whole lifecycle runs. The presenting controller is created automatically. You can still call `showPresentingController()` and assign `presentingController` yourself. -- Any other object is released right away. -- After release, DeallocTests waits up to `deallocationTimeout` (2 seconds by default) for every tracked instance to deallocate. Leaks are detected per instance, so a second leaked instance of the same class is caught. -- `checkClasses` restricts the check to the listed classes. Each listed class must have been tracked (it is `DeallocTestable` and `initializeDeallocTestSupport()` was called on it). -- `actionBeforeCheck` runs after the object is released and before the check. -- A failing step is reported with `XCTFail` and the scenario continues with the next step. The expectation is always fulfilled. - -Set `DeallocTester.isLoggingEnabled = true` to print `Alloc`/`Dealloc` messages for every tracked object. - -#### Dependency Injection in `DeallocTester` - -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 { - override func registerDependencies() async { - await container.register(type: APIManaging.self, in: .shared) { _ in APIManager() } - } - - @MainActor - func test_dependencyGraphDealloc() async { - let expectation = expectation(description: "dealloc test") - - await performDeallocTest( - deallocTests: [ - DeallocTest(objectCreation: { await $0.resolve(type: APIManaging.self) as AnyObject }) - ], - expectation: expectation - ) - - await fulfillment(of: [expectation], timeout: 60) - } -} -``` - -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`: @@ -311,7 +244,7 @@ Without the trait, `objectCreation` takes no parameter: `DeallocTest(objectCreat - 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: +**`DeallocTester` is removed**, together with `DeallocTest`, `DeallocTestable` and `ClassNameIdentifiable`. Each `DeallocTest` becomes one `expectDeallocation` call, and the `DeallocTestable` conformances are deleted: ```swift // Before @@ -355,6 +288,7 @@ final class MainCoordinatorDeallocTests: XCTestCase { | `checkClasses` | `trackForDeallocation(_:)` inside the closure | | `actionBeforeCheck` | `afterRelease` | | `deallocationTimeout` | `timeout` | +| `isLoggingEnabled` (`Alloc`/`Dealloc` output) | Leak messages name the leaked object and its likely causes | **`DefaultInitializable`** is removed. It wasn't related to dealloc testing. @@ -364,12 +298,12 @@ The folder `SampleApps` contains two demo projects. The application itself is ve - `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. +- `DeallocTestsAppSPM` uses the default `DependencyInjection` trait. `ExpectDeallocationTests.swift` does the checks with `expectDeallocation` in **Swift Testing**, including a service resolved from an `AsyncContainer`. 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: ``` -MainCoordinatorDeallocTester.swift:25: error: -[DeallocTestsAppSPMTests.MainCoordinatorDeallocTester test_secondScreen] : failed - DeallocTestsAppSPM.SecondViewController was not deallocated within 2 sec. Possible causes: +MainCoordinatorDeallocTester.swift:25: error: -[DeallocTestsAppSPMTests.MainCoordinatorDeallocTester test_secondScreen] : failed - DeallocTestsAppSPM.SecondViewController was not deallocated within 2 sec. It was watched for another 3 sec after that. 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 ``` diff --git a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift index 1648b82..a98600c 100644 --- a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift +++ b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift @@ -10,7 +10,6 @@ import DeallocTests import XCTest @testable import DeallocTestsAppSPM -/// Dealloc tests with XCTest. No `DeallocTestable` conformances are needed. final class MainCoordinatorDeallocTester: XCTestCase { @MainActor func test_firstScreen() async { @@ -18,7 +17,6 @@ final class MainCoordinatorDeallocTester: XCTestCase { await expectDeallocation(.present) { coordinator.createFirstViewController() } } - /// Fails on purpose: `SecondViewController` captures `self` strongly in `viewDidLoad` @MainActor func test_secondScreen() async { let coordinator = MainCoordinator() diff --git a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj index 9d64da8..7222538 100644 --- a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj +++ b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj @@ -19,10 +19,7 @@ 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 */; }; ADCB58DF27FD7CC9009E0DEB /* APIManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADCB58DE27FD7CC9009E0DEB /* APIManager.swift */; }; - ADCB58E127FD7CF3009E0DEB /* DependencyGraphDeallocTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADCB58E027FD7CF3009E0DEB /* DependencyGraphDeallocTester.swift */; }; AD0E0E0E2F00000100000001 /* ExpectDeallocationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0E0E0E2F00000100000002 /* ExpectDeallocationTests.swift */; }; ADCB58E727FD8756009E0DEB /* DeallocTests in Frameworks */ = {isa = PBXBuildFile; productRef = ADCB58E627FD8756009E0DEB /* DeallocTests */; }; /* End PBXBuildFile section */ @@ -54,10 +51,7 @@ 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 = ""; }; 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 = ""; }; /* End PBXFileReference section */ @@ -130,9 +124,6 @@ isa = PBXGroup; children = ( 27A0803F245C859E0037F1DB /* Info.plist */, - 27A08054245CBBD10037F1DB /* DeallocTestConformances.swift */, - 27A08056245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift */, - ADCB58E027FD7CF3009E0DEB /* DependencyGraphDeallocTester.swift */, AD0E0E0E2F00000100000002 /* ExpectDeallocationTests.swift */, ); path = DeallocTestsAppSPMTests; @@ -295,9 +286,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 27A08055245CBBD10037F1DB /* DeallocTestConformances.swift in Sources */, - 27A08057245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift in Sources */, - ADCB58E127FD7CF3009E0DEB /* DependencyGraphDeallocTester.swift in Sources */, AD0E0E0E2F00000100000001 /* ExpectDeallocationTests.swift in Sources */, 275ACCAC246D4C2E00FEE52F /* TestAppDelegate.swift in Sources */, 275ACCAD246D4C2E00FEE52F /* TestSceneDelegate.swift in Sources */, diff --git a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/DeallocTestConformances.swift b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/DeallocTestConformances.swift deleted file mode 100644 index 816d837..0000000 --- a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/DeallocTestConformances.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// DeallocTestConformances.swift -// DeallocTestsAppTests -// -// Created by Daniel Cech on 01/05/2020. -// Copyright © 2020 STRV. All rights reserved. -// - -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 deleted file mode 100644 index ce7ad73..0000000 --- a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/DependencyGraphDeallocTester.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// DependencyGraphDeallocTester.swift -// DeallocTestsAppSPMTests -// -// Created by Jan Schwarz on 06.04.2022. -// Copyright © 2022 STRV. All rights reserved. -// - -import Foundation - -@testable import DeallocTestsAppSPM -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 { - deallocTests = [ - DeallocTest( - objectCreation: { await $0.resolve(type: APIManaging.self) as AnyObject } - ), - ] - - let expectation = self.expectation(description: "deallocTest test_todayCoordinatorDealloc") - - await performDeallocTest( - deallocTests: deallocTests, - expectation: expectation - ) - - await fulfillment(of: [expectation], timeout: 200) - } - - override func registerDependencies() async { - await container.register(type: APIManaging.self, in: .new, factory: { _ in APIManager()}) - } -} diff --git a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/ExpectDeallocationTests.swift b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/ExpectDeallocationTests.swift index 0a40ada..decc55b 100644 --- a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/ExpectDeallocationTests.swift +++ b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/ExpectDeallocationTests.swift @@ -10,8 +10,6 @@ import DependencyInjection import Testing @testable import DeallocTestsAppSPM -/// The same checks as `MainCoordinatorDeallocTester` and `DependencyGraphDeallocTester`, -/// written with `expectDeallocation`. No `DeallocTestable` conformances are needed. @Suite("Dealloc tests") @MainActor struct ExpectDeallocationTests { @@ -21,7 +19,6 @@ struct ExpectDeallocationTests { await expectDeallocation(.present) { coordinator.createFirstViewController() } } - /// Fails on purpose: `SecondViewController` captures `self` strongly in `viewDidLoad` @Test func secondScreen() async { await expectDeallocation(.push) { coordinator.createSecondViewController() } } @@ -45,10 +42,8 @@ struct ExpectDeallocationTests { await expectDeallocation(of: APIManaging.self, resolvedFrom: container) } - #if compiler(>=6.1) - @Test(.checksDeallocation) func trackedController() { - let controller = trackForDeallocation(coordinator.createThirdViewController()) - controller.loadViewIfNeeded() - } - #endif + @Test(.checksDeallocation) func trackedController() { + let controller = trackForDeallocation(coordinator.createThirdViewController()) + controller.loadViewIfNeeded() + } } diff --git a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift deleted file mode 100644 index 7dbe73b..0000000 --- a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// MainCoordinatorDeallocTester.swift -// DeallocTestsAppTests -// -// Created by Daniel Cech on 01/05/2020. -// Copyright © 2020 STRV. All rights reserved. -// - -import Foundation -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? - - @MainActor - func test_mainCoordinatorDealloc() async { - presentingController = await showPresentingController() - - mainCoordinator = MainCoordinator() - - 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: { _ in - return MainCoordinator() - } - ) - ] - - let expectation = self.expectation(description: "deallocTest test_mainCoordinatorDealloc") - - await performDeallocTest( - deallocTests: deallocTests, - expectation: expectation - ) - - await fulfillment(of: [expectation], timeout: 200) - } -} diff --git a/Sources/DeallocTests/DeallocTestable+Internals.swift b/Sources/DeallocTests/DeallocTestable+Internals.swift deleted file mode 100644 index bd02215..0000000 --- a/Sources/DeallocTests/DeallocTestable+Internals.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// DeallocTestable+Internals.swift -// DeallocTests -// -// Created by Dan Cech on 08.04.2019. -// Copyright © 2019 STRV. All rights reserved. -// - -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 - /// which will ensure that the `DeinitializationObserver` is deinitialized - /// at the same time as your object. - private var deinitializationObserver: DeinitializationObserver? { - get { - return objc_getAssociatedObject(self, &AssociatedKeys.deinitializationObserver) as? DeinitializationObserver - } - set { - objc_setAssociatedObject( - self, - &AssociatedKeys.deinitializationObserver, - newValue, - objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } - } - - public var deallocTestSupportInstalled: Bool? { - get { - return objc_getAssociatedObject(self, &AssociatedKeys.deallocTestSupportInstalled) as? Bool - } - set { - objc_setAssociatedObject( - self, - &AssociatedKeys.deallocTestSupportInstalled, - newValue, - objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } - } - - /// Starts tracking this instance. Calling it repeatedly has no effect. - public func initializeDeallocTestSupport() { - if deallocTestSupportInstalled != nil { - return - } - - deinitializationObserver = DeinitializationObserver(myClass: myClass) - deallocTestSupportInstalled = true - } -} diff --git a/Sources/DeallocTests/DeallocTestable.swift b/Sources/DeallocTests/DeallocTestable.swift deleted file mode 100644 index a502458..0000000 --- a/Sources/DeallocTests/DeallocTestable.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// DeallocTestable.swift -// DeallocTests -// -// Created by Dan Cech on 16.01.2019. -// Copyright © 2019 STRV. All rights reserved. -// - -import Foundation - -/// We're using objc associated objects to have this `DeinitializationObserver` -/// stored inside the protocol extension. The keys are only used for their address. -enum AssociatedKeys { - @MainActor static var deinitializationObserver: UInt8 = 0 - @MainActor static var deallocTestSupportInstalled: UInt8 = 0 -} - -/// 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 deleted file mode 100644 index 9be7ecf..0000000 --- a/Sources/DeallocTests/DeallocTester.swift +++ /dev/null @@ -1,277 +0,0 @@ -// -// DeallocTester.swift -// DeallocTests -// -// Created by Dan Cech on 17.01.2019. -// Copyright © 2019 STRV. All rights reserved. -// - -import Foundation - -#if DependencyInjection - import DependencyInjection -#endif - -import XCTest - -#if canImport(UIKit) - 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 DependencyInjection - public typealias ObjectCreationClosure = @MainActor (AsyncContainer) async -> AnyObject? -#else - public typealias ObjectCreationClosure = @MainActor () async -> AnyObject? -#endif - - public typealias SimpleClosure = (() -> Void) - - public var objectCreation: ObjectCreationClosure - public var checkClasses: [AnyClass]? - public var actionBeforeCheck: SimpleClosure? - - public init(objectCreation: @escaping ObjectCreationClosure, checkClasses: [AnyClass]? = nil, actionBeforeCheck: SimpleClosure? = nil) { - self.objectCreation = objectCreation - self.checkClasses = checkClasses - self.actionBeforeCheck = actionBeforeCheck - } -} - -@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 - - public var deallocTests = [DeallocTest]() - - /// How long to wait for tracked objects to deallocate before the step fails - open var deallocationTimeout: Duration = .seconds(2) - - /// Prints `Alloc`/`Dealloc` messages for every tracked object. Off by default. - public static var isLoggingEnabled: Bool { - get { DeallocRegistry.shared.isLoggingEnabled } - set { DeallocRegistry.shared.isLoggingEnabled = newValue } - } - -#if canImport(UIKit) - // swiftlint:disable:next implicitly_unwrapped_optional - var window: UIWindow! - - /// Controller used for presenting tested view controllers. - /// It's created automatically when a tested object is a `UIViewController`. - // swiftlint:disable:next implicitly_unwrapped_optional - public var presentingController: UIViewController! -#endif - -#if DependencyInjection - /// Dependency Injection container - // swiftlint:disable:next implicitly_unwrapped_optional - public var container: AsyncContainer! -#endif - - /// Initialize DI container with shared dependency registrations - @MainActor - open func registerDependencies() async { - // Override in descendants. Initialize assembler from main project - } - -#if canImport(UIKit) - /// Controller for presenting tested controllers - @MainActor - public func showPresentingController() async -> UIViewController { - // `UIApplication.shared` is accessed via KVC so the library stays app-extension safe - if let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication, - let windowScene = application.connectedScenes.first as? UIWindowScene { - window = UIWindow(windowScene: windowScene) - } else { - window = UIWindow(frame: UIScreen.main.bounds) - } - - let viewController = UIViewController() - viewController.view.backgroundColor = .clear - - window.rootViewController = viewController - window.windowLevel = UIWindow.Level.alert + 1 - window.makeKeyAndVisible() - - return viewController - } -#endif - - override open func setUp() async throws { - try await super.setUp() - - #if DependencyInjection - container = AsyncContainer() - #endif - - DeallocRegistry.shared.reset() - } - - override open func tearDown() { - #if canImport(UIKit) - let window = window - self.window = nil - presentingController = nil - - // XCTest calls the synchronous tearDown on the main thread - MainActor.assumeIsolated { - window?.isHidden = true - } - #endif - - super.tearDown() - } - - /// Instantiate and release tested items one by one. - /// The expectation is always fulfilled, failures are reported via `XCTFail`. - @MainActor - public func performDeallocTest( - deallocTests: [DeallocTest], - expectation: XCTestExpectation - ) async { - for (index, deallocTest) in deallocTests.enumerated() { - await performDeallocTest(deallocTest, index: index) - } - - expectation.fulfill() - } -} - -// MARK: - Private - -@available(*, deprecated) -private extension DeallocTester { - var registry: DeallocRegistry { - DeallocRegistry.shared - } - - @MainActor - func performDeallocTest(_ deallocTest: DeallocTest, index: Int) async { - registry.reset() - - #if DependencyInjection - await container.clean() - await registerDependencies() - #endif - - registry.log("\nChecking:") - - // The tested instance lives only inside this call - guard await createAndExercise(deallocTest, index: index) else { - return - } - - #if DependencyInjection - await container.releaseSharedInstances() - #endif - - deallocTest.actionBeforeCheck?() - - await waitForDeallocation() - checkResult(checkedClasses: deallocTest.checkClasses, index: index) - } - - /// Returns `false` when the step cannot be checked - @MainActor - func createAndExercise(_ deallocTest: DeallocTest, index: Int) async -> Bool { - #if DependencyInjection - let instance = await deallocTest.objectCreation(container) - #else - let instance = await deallocTest.objectCreation() - #endif - - guard let instance else { - XCTFail("Failed: objectCreation of dealloc test #\(index) returned nil") - return false - } - - guard let testable = instance as? DeallocTestable else { - XCTFail("Failed: class \(NSStringFromClass(type(of: instance))) is not DeallocTestable") - return false - } - - testable.initializeDeallocTestSupport() - - #if canImport(UIKit) - if let controller = instance as? UIViewController { - return await presentAndDismiss(controller) - } - #endif - - return true - } - - /// Polls until every tracked instance is gone or the timeout elapses - @MainActor - func waitForDeallocation() async { - let deadline = ContinuousClock.now + deallocationTimeout - - while registry.hasLiveInstances, ContinuousClock.now < deadline { - try? await Task.sleep(for: .milliseconds(10)) - } - } - - @MainActor - func checkResult(checkedClasses: [AnyClass]?, index: Int) { - let entries = registry.entries - var failedClasses = [AnyClass]() - - if let checkedClasses { - for checkedClass in checkedClasses { - let matching = entries.filter { $0.objectClass == checkedClass } - if matching.isEmpty || matching.contains(where: { !$0.isDeallocated }) { - failedClasses.append(checkedClass) - } - } - } else { - for entry in entries where !entry.isDeallocated && !failedClasses.contains(where: { $0 == entry.objectClass }) { - failedClasses.append(entry.objectClass) - } - } - - if !failedClasses.isEmpty { - let liveCount = entries.filter { !$0.isDeallocated }.count - XCTFail("Failed: dealloc test #\(index) failed on classes: \(failedClasses) (\(liveCount) tracked instance(s) still alive)") - } - } -} - -#if canImport(UIKit) -@available(*, deprecated) -private extension DeallocTester { - /// Presents and dismisses the controller to run its lifecycle - @MainActor - func presentAndDismiss(_ controller: UIViewController) async -> Bool { - if presentingController == nil { - presentingController = await showPresentingController() - } - - guard let presentingController, presentingController.view.window != nil else { - XCTFail("Failed: presentingController is not in a window hierarchy") - return false - } - - if presentingController.presentedViewController != nil { - await withCheckedContinuation { continuation in - presentingController.dismiss(animated: false) { continuation.resume() } - } - } - - controller.modalPresentationStyle = .fullScreen - - await withCheckedContinuation { continuation in - presentingController.present(controller, animated: false) { continuation.resume() } - } - - await Task.yield() - - await withCheckedContinuation { continuation in - presentingController.dismiss(animated: false) { continuation.resume() } - } - - return true - } -} -#endif diff --git a/Sources/DeallocTests/DeinitializationObserver.swift b/Sources/DeallocTests/DeinitializationObserver.swift deleted file mode 100644 index b30fe5c..0000000 --- a/Sources/DeallocTests/DeinitializationObserver.swift +++ /dev/null @@ -1,106 +0,0 @@ -// -// DeinitializationObserver.swift -// DeallocTests -// -// Created by Dan Cech on 16.03.2019. -// Copyright © 2019 STRV. All rights reserved. -// - -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) - } -} - -/// Thread-safe record of the instances tracked during a single dealloc test step. -/// -/// Every tracked instance gets its own token so leaks are detected per instance, -/// not per class. Deinitialization can happen on any thread, hence the lock. -final class DeallocRegistry: Sendable { - struct Entry { - let objectClass: AnyClass - var isDeallocated: Bool - } - - private struct State { - var nextToken = 0 - var entries: [Int: Entry] = [:] - var isLoggingEnabled = false - } - - static let shared = DeallocRegistry() - - private let state = OSAllocatedUnfairLock(uncheckedState: State()) - - var isLoggingEnabled: Bool { - get { state.withLockUnchecked { $0.isLoggingEnabled } } - set { state.withLockUnchecked { $0.isLoggingEnabled = newValue } } - } - - /// Tracked instances in the order they were registered - var entries: [Entry] { - state.withLockUnchecked { state in - state.entries.sorted { $0.key < $1.key }.map(\.value) - } - } - - var hasLiveInstances: Bool { - state.withLockUnchecked { state in - state.entries.values.contains { !$0.isDeallocated } - } - } - - func reset() { - state.withLockUnchecked { $0.entries = [:] } - } - - func registerAllocation(of objectClass: AnyClass) -> Int { - let token = state.withLockUnchecked { state in - let token = state.nextToken - state.nextToken += 1 - state.entries[token] = Entry(objectClass: objectClass, isDeallocated: false) - return token - } - log("Alloc \(objectClass)") - return token - } - - func registerDeallocation(token: Int, of objectClass: AnyClass) { - state.withLockUnchecked { $0.entries[token]?.isDeallocated = true } - log("Dealloc \(objectClass)") - } - - func log(_ message: @autoclosure () -> String) { - guard isLoggingEnabled else { - return - } - print(message()) - } -} - -/// This is a simple object whose job is to report to `DeallocRegistry` -/// when it deinitializes together with its owner. -final class DeinitializationObserver { - private let token: Int - private let myClass: AnyClass - private let registry: DeallocRegistry - - init(myClass: AnyClass, registry: DeallocRegistry = .shared) { - self.myClass = myClass - self.registry = registry - token = registry.registerAllocation(of: myClass) - } - - deinit { - registry.registerDeallocation(token: token, of: myClass) - } -} diff --git a/Sources/DeallocTests/Diagnostics/DurationText.swift b/Sources/DeallocTests/Diagnostics/DurationText.swift new file mode 100644 index 0000000..b70ca39 --- /dev/null +++ b/Sources/DeallocTests/Diagnostics/DurationText.swift @@ -0,0 +1,20 @@ +// +// DurationText.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +enum DurationText { + static func describe(_ duration: Duration) -> String { + let (seconds, attoseconds) = duration.components + let milliseconds = seconds * 1000 + attoseconds / 1_000_000_000_000_000 + + guard milliseconds >= 1000 else { + return "\(milliseconds) ms" + } + + let tenths = milliseconds / 100 + return tenths % 10 == 0 ? "\(tenths / 10) sec" : "\(tenths / 10).\(tenths % 10) sec" + } +} diff --git a/Sources/DeallocTestsCore/LeakHints.swift b/Sources/DeallocTests/Diagnostics/LeakHints.swift similarity index 78% rename from Sources/DeallocTestsCore/LeakHints.swift rename to Sources/DeallocTests/Diagnostics/LeakHints.swift index bbd8ab5..4347eb2 100644 --- a/Sources/DeallocTestsCore/LeakHints.swift +++ b/Sources/DeallocTests/Diagnostics/LeakHints.swift @@ -1,6 +1,6 @@ // // LeakHints.swift -// DeallocTestsCore +// DeallocTests // // Copyright © 2026 STRV. All rights reserved. // @@ -8,16 +8,11 @@ import Foundation /// Looks at the stored properties of a leaked object and points at the usual suspects. -/// -/// `Mirror` can't tell weak properties from strong ones and can't look inside closures, -/// so the hints are suggestions, not proof. -package enum LeakHints { - /// How deep to look for reference cycles that go through properties +enum LeakHints { static let maximumCycleDepth = 4 - /// Upper bound for visited objects, to keep huge object graphs fast static let maximumVisitedObjects = 300 - package static func hints(for object: AnyObject) -> [String] { + static func hints(for object: AnyObject) -> [String] { var hints = [String]() for property in storedProperties(of: object) { @@ -36,6 +31,10 @@ package enum LeakHints { // MARK: - Suspicious properties private static func suspiciousKind(of value: Any) -> String? { + guard let value = unwrapped(value), !isEmptyCollection(value) else { + return nil + } + let typeName = String(describing: type(of: value)) if typeName.contains("->") { @@ -46,20 +45,40 @@ package enum LeakHints { return "a Combine subscription. Make sure its sink captures self weakly" } - if typeName.hasPrefix("Task<") || typeName.hasPrefix("Optional Any? { + let mirror = Mirror(reflecting: value) + + guard mirror.displayStyle == .optional else { + return value + } + + return mirror.children.first.flatMap { unwrapped($0.value) } + } + + private static func isEmptyCollection(_ value: Any) -> Bool { + let mirror = Mirror(reflecting: value) + + switch mirror.displayStyle { + case .collection, .set, .dictionary: + return mirror.children.isEmpty + default: + return false + } + } + // MARK: - Cycles - /// Paths through stored properties that lead back to the object private static func cycles(from root: AnyObject) -> [String] { let rootIdentifier = ObjectIdentifier(root) var visited: Set = [rootIdentifier] @@ -78,7 +97,7 @@ package enum LeakHints { let childPath = "\(path).\(property.label)" let childIdentifier = ObjectIdentifier(child) - if childIdentifier == rootIdentifier, depth > 0 { + if childIdentifier == rootIdentifier { found.append(childPath) } else if isUserDefined(type(of: child)), visited.insert(childIdentifier).inserted { queue.append((child, childPath, depth + 1)) @@ -97,7 +116,6 @@ package enum LeakHints { let value: Any } - /// Stored properties of the object's own class and its user-defined superclasses private static func storedProperties(of object: AnyObject) -> [Property] { var properties = [Property]() var mirror: Mirror? = Mirror(reflecting: object) @@ -108,7 +126,6 @@ package enum LeakHints { } for child in currentMirror.children { - // `_$observationRegistrar` is generated by `@Observable`, not the app's state guard let label = child.label, !label.hasPrefix("_$") else { continue } @@ -121,7 +138,6 @@ package enum LeakHints { return properties } - /// Class instances stored directly or inside optionals, collections, tuples and structs private static func referencedObjects(in value: Any, depth: Int = 0) -> [AnyObject] { let mirror = Mirror(reflecting: value) @@ -136,7 +152,6 @@ package enum LeakHints { return mirror.children.prefix(50).flatMap { referencedObjects(in: $0.value, depth: depth + 1) } } - /// Skips Apple framework classes like `UIView`, so hints point at the app's own code private static func isUserDefined(_ objectClass: AnyClass) -> Bool { guard let bundleIdentifier = Bundle(for: objectClass).bundleIdentifier else { return true @@ -145,9 +160,7 @@ package enum LeakHints { } private static func cleaned(_ label: String) -> String { - // Lazy properties are stored as `$__lazy_storage_$_name` 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/Diagnostics/LeakReport.swift b/Sources/DeallocTests/Diagnostics/LeakReport.swift new file mode 100644 index 0000000..5bee5a0 --- /dev/null +++ b/Sources/DeallocTests/Diagnostics/LeakReport.swift @@ -0,0 +1,42 @@ +// +// LeakReport.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +/// What is known about an object that wasn't deallocated, and how it's described in a failure +struct LeakReport: Sendable { + let typeName: String + let timeout: Duration + let gracePeriod: Duration + let hints: [String] + + init(typeName: String, timeout: Duration, gracePeriod: Duration = .zero, hints: [String] = []) { + self.typeName = typeName + self.timeout = timeout + self.gracePeriod = gracePeriod + self.hints = hints + } + + static func lateReleaseMessage(typeName: String, releasedAfter: Duration, timeout: Duration) -> String { + "\(typeName) was released after \(DurationText.describe(releasedAfter)), later than the \(DurationText.describe(timeout)) timeout. " + + "That's bounded retention, not a leak: something kept it alive for a while, e.g. a task, " + + "an animation or a delayed callback. If that's expected, raise the timeout." + } + + var message: String { + var summary = "\(typeName) was not deallocated within \(DurationText.describe(timeout))." + if gracePeriod > .zero { + summary += " It was watched for another \(DurationText.describe(gracePeriod)) after that." + } + + guard !hints.isEmpty else { + return summary + " Something still holds a strong reference to it: look for closures capturing self, " + + "delegates that aren't weak, timers, notification observers and long-running tasks or subscriptions." + } + + 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/Diagnostics/TypeNames.swift b/Sources/DeallocTests/Diagnostics/TypeNames.swift new file mode 100644 index 0000000..fd621ee --- /dev/null +++ b/Sources/DeallocTests/Diagnostics/TypeNames.swift @@ -0,0 +1,19 @@ +// +// TypeNames.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import Foundation + +enum TypeNames { + static func readableName(of object: AnyObject) -> String { + readableName(of: type(of: object)) + } + + static func readableName(of type: Any.Type) -> String { + String(reflecting: type) + .replacingOccurrences(of: #"\(unknown context at \$[0-9a-fA-F]+\)\."#, with: "", options: .regularExpression) + } +} diff --git a/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift b/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift index c613b41..4f9c5e5 100644 --- a/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift +++ b/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift @@ -5,20 +5,24 @@ // Copyright © 2026 STRV. All rights reserved. // -import Foundation - #if canImport(Testing) import Testing #endif /// How deallocation checks behave by default. /// -/// Every check uses the current configuration unless it passes its own values. Set it for a -/// Swift Testing suite or test with traits such as `.deallocationTimeout(_:)`, or for a block -/// of code with `withDeallocationConfiguration(_:operation:)`. +/// An object is checked with the configuration in effect where it's tracked. Only the timeout +/// can also be passed to `expectDeallocation`, because it describes the object. Set the +/// configuration for a Swift Testing suite or test with traits such as `.deallocationTimeout(_:)`, +/// or for a block of code with `withDeallocationConfiguration(_:operation:)`. public struct DeallocationConfiguration: Sendable { /// How long a check waits for the objects to deallocate public var timeout: Duration = .seconds(2) + /// How much longer to keep watching objects that are still alive after the timeout. + /// An object released in that time is reported as a warning (bounded retention, not a + /// leak) instead of a failure. `.zero` turns it off. Not used when `severity` is `.warning`, + /// because a leak is then reported as a warning anyway. + public var gracePeriod: Duration = .seconds(3) /// Whether a leak fails the test or is reported as a warning public var severity: DeallocationIssueSeverity = .error @@ -61,7 +65,31 @@ public func withDeallocationConfiguration( } } -#if canImport(Testing) && compiler(>=6.1) +/// Runs the operation with a changed deallocation configuration. +/// +/// The synchronous variant, e.g. for a whole XCTest test case class: +/// +/// ```swift +/// final class LegacyDeallocTests: XCTestCase { +/// override func invokeTest() { +/// withDeallocationConfiguration({ $0.severity = .warning }) { +/// super.invokeTest() +/// } +/// } +/// } +/// ``` +public func withDeallocationConfiguration( + _ change: (inout DeallocationConfiguration) -> Void, + operation: () throws -> Result +) rethrows -> Result { + var configuration = DeallocationConfiguration.current + change(&configuration) + return try DeallocationConfiguration.$current.withValue(configuration) { + try operation() + } +} + +#if canImport(Testing) /// Changes the deallocation configuration for a test, or for every test in a suite. /// A test's own trait wins over its suite's. @@ -96,6 +124,11 @@ public extension Trait where Self == DeallocationConfigurationTrait { Self { $0.timeout = timeout } } + /// How much longer to watch objects still alive after the timeout before calling them leaked + static func deallocationGracePeriod(_ gracePeriod: Duration) -> Self { + Self { $0.gracePeriod = gracePeriod } + } + /// Whether leaks fail the test (`.error`, the default) or are reported as warnings static func deallocationIssues(_ severity: DeallocationIssueSeverity) -> Self { Self { $0.severity = severity } diff --git a/Sources/DeallocTests/Expectation/DeallocationTracker.swift b/Sources/DeallocTests/Expectation/DeallocationTracker.swift index f2e2a7c..c609b93 100644 --- a/Sources/DeallocTests/Expectation/DeallocationTracker.swift +++ b/Sources/DeallocTests/Expectation/DeallocationTracker.swift @@ -5,11 +5,7 @@ // Copyright © 2026 STRV. All rights reserved. // -import DeallocTestsCore -import Foundation -/// Keeps weak references to objects and checks that all of them deallocate. -/// No conformance or associated objects are needed, so any class instance can be tracked. @MainActor final class DeallocationTracker { private struct TrackedObject { @@ -18,44 +14,77 @@ final class DeallocationTracker { let location: TestSourceLocation } - /// Tracker that `trackForDeallocation(_:)` adds objects to. - /// Installed by `expectDeallocation` and the `.checksDeallocation` Swift Testing trait. @TaskLocal static var current: DeallocationTracker? private var trackedObjects = [TrackedObject]() - - var isEmpty: Bool { - trackedObjects.isEmpty - } + private var configuration = DeallocationConfiguration.current func track(_ object: AnyObject, at location: TestSourceLocation) { + if trackedObjects.isEmpty { + configuration = DeallocationConfiguration.current + } + trackedObjects.append( TrackedObject(object: object, typeName: TypeNames.readableName(of: object), location: location) ) } - /// Waits until all tracked objects deallocate and reports the ones that didn't within the timeout. - /// - Parameter timeout: Overrides the timeout of the current `DeallocationConfiguration` - func verifyDeallocation(timeout: Duration?) async { - let configuration = DeallocationConfiguration.current + func verifyDeallocation(timeout: Duration? = nil) async { + let objects = trackedObjects + trackedObjects.removeAll() + let timeout = timeout ?? configuration.timeout + let gracePeriod = configuration.severity == .error ? configuration.gracePeriod : .zero + let clock = ContinuousClock() + let start = clock.now + + _ = await Polling.waitUntil(timeout: timeout) { + !objects.contains { $0.object != nil } + } - _ = await Polling.waitUntil(timeout: timeout) { [trackedObjects] in - !trackedObjects.contains { $0.object != nil } + var lateReleases = [(TrackedObject, Duration)]() + var pending = objects.filter { $0.object != nil } + + if !pending.isEmpty, gracePeriod > .zero { + _ = await Polling.waitUntil(timeout: gracePeriod) { + pending.removeAll { trackedObject in + guard trackedObject.object == nil else { + return false + } + lateReleases.append((trackedObject, clock.now - start)) + return true + } + return pending.isEmpty + } } - for trackedObject in trackedObjects { + guard !Task.isCancelled else { + return + } + + for (trackedObject, releasedAfter) in lateReleases { + reportIssue( + LeakReport.lateReleaseMessage(typeName: trackedObject.typeName, releasedAfter: releasedAfter, timeout: timeout), + at: trackedObject.location, + severity: .warning + ) + } + + for trackedObject in pending { guard let object = trackedObject.object else { continue } reportIssue( - LeakReport(typeName: trackedObject.typeName, timeout: timeout, hints: LeakHints.hints(for: object)).message, + LeakReport( + typeName: trackedObject.typeName, + timeout: timeout, + gracePeriod: gracePeriod, + hints: LeakHints.hints(for: object) + ).message, at: trackedObject.location, severity: configuration.severity ) } - - trackedObjects.removeAll() } } diff --git a/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift b/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift index f411fe5..1698d5c 100644 --- a/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift +++ b/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift @@ -8,7 +8,6 @@ #if DependencyInjection import DependencyInjection -import Foundation /// Resolves a dependency, releases it together with the container's shared instances /// and checks that it deallocates. @@ -47,7 +46,6 @@ public func expectDeallocation( await tracker.verifyDeallocation(timeout: timeout) } -/// The dependency only lives inside this call, so it's released when it returns @MainActor private func resolveAndTrack( _ type: Dependency.Type, @@ -57,9 +55,7 @@ private func resolveAndTrack( ) async -> Bool { let dependency = await container.resolve(type: type) - // A value type would be boxed into a temporary object that deallocates immediately guard Mirror(reflecting: dependency).displayStyle == .class else { - // `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.", diff --git a/Sources/DeallocTests/Expectation/ExpectDeallocation.swift b/Sources/DeallocTests/Expectation/ExpectDeallocation.swift index 3306d1b..2fb31d0 100644 --- a/Sources/DeallocTests/Expectation/ExpectDeallocation.swift +++ b/Sources/DeallocTests/Expectation/ExpectDeallocation.swift @@ -5,8 +5,6 @@ // Copyright © 2026 STRV. All rights reserved. // -import Foundation - /// Creates an object, runs its lifecycle, releases it and checks that it deallocates. /// /// Works in Swift Testing and XCTest. A leak is reported at the line that calls this function, @@ -48,8 +46,6 @@ public func expectDeallocation( await tracker.verifyDeallocation(timeout: timeout) } -/// The object only lives inside this call, so it's released when it returns. -/// Returns `false` when the lifecycle couldn't run. @MainActor private func createAndRun( _ makeObject: @MainActor () async throws -> Object, @@ -57,7 +53,6 @@ private func createAndRun( tracker: DeallocationTracker, location: TestSourceLocation ) async rethrows -> Bool { - // `trackForDeallocation(_:)` called from the factory or the lifecycle adds objects to this check try await DeallocationTracker.$current.withValue(tracker) { let object = try await makeObject() tracker.track(object, at: location) diff --git a/Sources/DeallocTests/Expectation/IssueReporting.swift b/Sources/DeallocTests/Expectation/IssueReporting.swift index 455ef2b..948e8d0 100644 --- a/Sources/DeallocTests/Expectation/IssueReporting.swift +++ b/Sources/DeallocTests/Expectation/IssueReporting.swift @@ -11,24 +11,13 @@ import XCTest import Testing #endif -/// Place in the test source where a failure is reported struct TestSourceLocation: Sendable { let fileID: StaticString let filePath: StaticString let line: UInt let column: UInt - - init(fileID: StaticString, filePath: StaticString, line: UInt, column: UInt) { - self.fileID = fileID - self.filePath = filePath - self.line = line - self.column = column - } } -/// Reports an issue to Swift Testing when running inside a Swift Testing test, otherwise to -/// XCTest. Exactly one framework gets it: since Swift 6.4 each framework also records the -/// other's failures, so reporting to both would show every issue twice. func reportIssue(_ message: String, at location: TestSourceLocation, severity: DeallocationIssueSeverity = .error) { #if canImport(Testing) if Test.current != nil { @@ -41,10 +30,9 @@ func reportIssue(_ message: String, at location: TestSourceLocation, severity: D case .error: XCTFail(message, file: location.filePath, line: location.line) case .warning: - // Shown as an expected failure; the test passes let options = XCTExpectedFailure.Options() options.isStrict = false - XCTExpectFailure("Reported as a warning (DeallocationConfiguration.severity)", options: options) { + XCTExpectFailure("Reported as a warning", options: options) { XCTFail(message, file: location.filePath, line: location.line) } } @@ -67,7 +55,7 @@ private func recordSwiftTestingIssue(_ message: String, at location: TestSourceL #if compiler(>=6.3) Issue.record(Comment(rawValue: message), severity: .warning, sourceLocation: sourceLocation) #else - withKnownIssue("Reported as a warning (DeallocationConfiguration.severity)", isIntermittent: true) { + withKnownIssue("Reported as a warning", isIntermittent: true) { Issue.record(Comment(rawValue: message), sourceLocation: sourceLocation) } #endif diff --git a/Sources/DeallocTests/Expectation/Lifecycle+SwiftUI.swift b/Sources/DeallocTests/Expectation/Lifecycle+SwiftUI.swift index 0dc4a0a..25f9f1d 100644 --- a/Sources/DeallocTests/Expectation/Lifecycle+SwiftUI.swift +++ b/Sources/DeallocTests/Expectation/Lifecycle+SwiftUI.swift @@ -32,13 +32,11 @@ public extension Lifecycle { return false } - // Lets SwiftUI call `onAppear` and start `.task` modifiers await settle() await perform(interaction, with: object, at: location) host.remove() - // Lets SwiftUI call `onDisappear` and cancel tasks await settle() return true } @@ -52,7 +50,6 @@ private func settle() async { #if canImport(UIKit) -/// Shows a hosting controller as a child of an empty controller in a test window @MainActor private final class SwiftUIHost { private let window: TestWindow @@ -91,7 +88,6 @@ private final class SwiftUIHost { #elseif canImport(AppKit) -/// Shows a hosting controller in a test window @MainActor private final class SwiftUIHost { private let window: NSWindow diff --git a/Sources/DeallocTests/Expectation/Lifecycle.swift b/Sources/DeallocTests/Expectation/Lifecycle.swift index f9e7479..0c99c29 100644 --- a/Sources/DeallocTests/Expectation/Lifecycle.swift +++ b/Sources/DeallocTests/Expectation/Lifecycle.swift @@ -5,8 +5,6 @@ // Copyright © 2026 STRV. All rights reserved. // -import DeallocTestsCore -import Foundation #if canImport(UIKit) import UIKit @@ -21,8 +19,6 @@ import Foundation public struct Lifecycle: Sendable { public typealias Interaction = @MainActor (Object) async throws -> Void - /// Returns `false` when the lifecycle couldn't run. The failure is already reported then, - /// and the deallocation check is skipped because UIKit may still hold the object. let run: @MainActor (Object, TestSourceLocation) async -> Bool init(run: @escaping @MainActor (Object, TestSourceLocation) async -> Bool) { @@ -58,8 +54,6 @@ extension Lifecycle { } } -/// Waits for a UIKit state. 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(10), _ condition: @MainActor () -> Bool) async -> Bool { await Polling.waitUntil(timeout: timeout, interval: .milliseconds(5), condition) @@ -93,7 +87,6 @@ public extension Lifecycle where Object: UIViewController { let host = TestWindow(rootViewController: hostController) defer { host.close() } - // UIKit postpones presentations from a controller that hasn't appeared yet guard await host.waitUntilVisible(hostController, at: location) else { return false } @@ -159,18 +152,22 @@ public extension Lifecycle where Object: UIViewController { } } -/// Window on top of everything that hosts the tested controllers +@MainActor +private func foregroundWindowScene() -> UIWindowScene? { + guard let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication else { + return nil + } + + let windowScenes = application.connectedScenes.compactMap { $0 as? UIWindowScene } + return windowScenes.first { $0.activationState == .foregroundActive } ?? windowScenes.first +} + @MainActor final class TestWindow { - let rootViewController: UIViewController private let window: UIWindow init(rootViewController: UIViewController) { - self.rootViewController = rootViewController - - // `UIApplication.shared` is accessed via KVC so the library stays app-extension safe - if let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication, - let windowScene = application.connectedScenes.first as? UIWindowScene { + if let windowScene = foregroundWindowScene() { window = UIWindow(windowScene: windowScene) } else { window = UIWindow(frame: UIScreen.main.bounds) @@ -200,8 +197,6 @@ final class TestWindow { } } -/// Empty controller that presents or pushes the tested controller once it has appeared -@MainActor final class HostViewController: UIViewController { private(set) var hasAppeared = false diff --git a/Sources/DeallocTestsCore/Polling.swift b/Sources/DeallocTests/Expectation/Polling.swift similarity index 65% rename from Sources/DeallocTestsCore/Polling.swift rename to Sources/DeallocTests/Expectation/Polling.swift index d6aea2c..790efbe 100644 --- a/Sources/DeallocTestsCore/Polling.swift +++ b/Sources/DeallocTests/Expectation/Polling.swift @@ -1,18 +1,13 @@ // // Polling.swift -// DeallocTestsCore +// DeallocTests // // Copyright © 2026 STRV. All rights reserved. // -import Foundation - -package enum Polling { - /// Polls the condition until it holds or the timeout elapses. Returns as soon as the - /// condition holds. Sleeping between checks lets the run loop drain autorelease pools - /// and finish UIKit transitions. +enum Polling { @MainActor - package static func waitUntil( + static func waitUntil( timeout: Duration, interval: Duration = .milliseconds(10), _ condition: @MainActor () -> Bool diff --git a/Sources/DeallocTests/Expectation/TrackForDeallocation.swift b/Sources/DeallocTests/Expectation/TrackForDeallocation.swift index 0d1a87a..e9d706f 100644 --- a/Sources/DeallocTests/Expectation/TrackForDeallocation.swift +++ b/Sources/DeallocTests/Expectation/TrackForDeallocation.swift @@ -17,6 +17,7 @@ public extension XCTestCase { /// Checks that the object deallocates when the test ends. /// /// Keep the object in a local variable. A property of the test case lives until the test case is released. + /// The check uses the `DeallocationConfiguration` in effect where this method is called. /// /// ```swift /// func test_viewModel() { @@ -28,7 +29,6 @@ public extension XCTestCase { @discardableResult func trackForDeallocation( _ object: Object, - timeout: Duration? = nil, fileID: StaticString = #fileID, filePath: StaticString = #filePath, line: UInt = #line, @@ -36,7 +36,6 @@ public extension XCTestCase { ) -> Object { let location = TestSourceLocation(fileID: fileID, filePath: filePath, line: line, column: column) - // Inside `expectDeallocation`, the object joins its check if let tracker = DeallocationTracker.current { tracker.track(object, at: location) return object @@ -46,7 +45,7 @@ public extension XCTestCase { tracker.track(object, at: location) addTeardownBlock { @MainActor in - await tracker.verifyDeallocation(timeout: timeout) + await tracker.verifyDeallocation() } return object @@ -88,12 +87,10 @@ public func trackForDeallocation( return object } -#if canImport(Testing) && compiler(>=6.1) +#if canImport(Testing) /// Checks that every object passed to `trackForDeallocation(_:)` deallocates when the test ends public struct DeallocationCheckTrait: TestTrait, SuiteTrait, TestScoping { - let timeout: Duration? - public var isRecursive: Bool { true } @@ -114,21 +111,14 @@ public struct DeallocationCheckTrait: TestTrait, SuiteTrait, TestScoping { try await function() } - await tracker.verifyDeallocation(timeout: timeout) + await tracker.verifyDeallocation() } } public extension Trait where Self == DeallocationCheckTrait { /// Checks that every object passed to `trackForDeallocation(_:)` deallocates when the test ends static var checksDeallocation: Self { - checksDeallocation(timeout: nil) - } - - /// Checks that every object passed to `trackForDeallocation(_:)` deallocates when the test ends - /// - Parameter timeout: How long to wait for the objects to deallocate. - /// `nil` uses `DeallocationConfiguration.current.timeout`. - static func checksDeallocation(timeout: Duration?) -> Self { - Self(timeout: timeout) + Self() } } diff --git a/Sources/DeallocTestsCore/LeakReport.swift b/Sources/DeallocTestsCore/LeakReport.swift deleted file mode 100644 index bc1bc9b..0000000 --- a/Sources/DeallocTestsCore/LeakReport.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// LeakReport.swift -// DeallocTestsCore -// -// Copyright © 2026 STRV. All rights reserved. -// - -import Foundation - -/// What is known about an object that wasn't deallocated, and how it's described in a failure -package struct LeakReport: Sendable { - package let typeName: String - package let timeout: Duration - package let hints: [String] - - package init(typeName: String, timeout: Duration, hints: [String] = []) { - self.typeName = typeName - self.timeout = timeout - self.hints = hints - } - - package var message: String { - let summary = "\(typeName) was not deallocated within \(timeout.formatted(.units(allowed: [.seconds, .milliseconds])))." - - guard !hints.isEmpty else { - return summary + " Something still holds a strong reference to it: look for closures capturing self, " - + "delegates that aren't weak, timers, notification observers and long-running tasks or subscriptions." - } - - // 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/DeallocTestsCore/TypeNames.swift b/Sources/DeallocTestsCore/TypeNames.swift deleted file mode 100644 index 9f2012d..0000000 --- a/Sources/DeallocTestsCore/TypeNames.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// TypeNames.swift -// DeallocTestsCore -// -// Copyright © 2026 STRV. All rights reserved. -// - -import Foundation - -package enum TypeNames { - /// Module-qualified type name without the `(unknown context at $…)` part - /// that Swift adds for private and local types - package static func readableName(of object: AnyObject) -> String { - readableName(of: type(of: object)) - } - - package static func readableName(of type: Any.Type) -> String { - String(reflecting: type) - .replacingOccurrences(of: #"\(unknown context at \$[0-9a-fA-F]+\)\."#, with: "", options: .regularExpression) - } -} diff --git a/Tests/DeallocTestsTests/ConfigurationTests.swift b/Tests/DeallocTestsTests/ConfigurationTests.swift index 6778bd0..99ab1c6 100644 --- a/Tests/DeallocTestsTests/ConfigurationTests.swift +++ b/Tests/DeallocTestsTests/ConfigurationTests.swift @@ -8,16 +8,13 @@ import DeallocTests import Testing -#if compiler(>=6.1) - -/// Matches a leak report of `RetainCycleObject` that names the expected timeout func isLeakReport(within timeout: String) -> (Issue) -> Bool { { issue in issue.comments.contains { $0.rawValue.hasPrefix("DeallocTestsTests.RetainCycleObject was not deallocated within \(timeout).") } } } -@Suite("Deallocation configuration", .deallocationTimeout(.milliseconds(100))) +@Suite("Deallocation configuration", .deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.zero)) @MainActor struct ConfigurationTests { @Test func suiteTimeoutApplies() async { @@ -60,7 +57,6 @@ struct ConfigurationTests { } } - /// A leak reported as a warning doesn't fail the test @Test(.deallocationIssues(.warning)) func warningSeverityDoesNotFailTheTest() async { await expectDeallocation { RetainCycleObject() } @@ -81,6 +77,28 @@ struct ConfigurationTests { isLeakReport(within: "100 ms")(issue) } } -} -#endif + @Test func trackedObjectsUseTheConfigurationOfANestedTrait() async throws { + let test = try #require(Test.current) + + try await withKnownIssue { + try await DeallocationCheckTrait.checksDeallocation.provideScope( + for: test, + testCase: Test.Case.current, + performing: { + try await DeallocationConfigurationTrait.deallocationTimeout(.milliseconds(130)).provideScope( + for: test, + testCase: Test.Case.current, + performing: { + await MainActor.run { + _ = trackForDeallocation(RetainCycleObject()) + } + } + ) + } + ) + } matching: { issue in + isLeakReport(within: "130 ms")(issue) + } + } +} diff --git a/Tests/DeallocTestsTests/DeallocTesterDIFreeTests.swift b/Tests/DeallocTestsTests/DeallocTesterDIFreeTests.swift deleted file mode 100644 index 7187874..0000000 --- a/Tests/DeallocTestsTests/DeallocTesterDIFreeTests.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// DeallocTesterDIFreeTests.swift -// DeallocTests -// -// Copyright © 2026 STRV. All rights reserved. -// - -#if !DependencyInjection - -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 { - await run([ - DeallocTest(objectCreation: { CleanObject() }) - ]) - } - - @MainActor - func test_retainCycle_fails() async { - XCTExpectFailure("LeakingObject has a retain cycle") - - await run([ - DeallocTest(objectCreation: { LeakingObject() }) - ]) - } -} - -@available(*, deprecated) -private extension DeallocTesterDIFreeTests { - @MainActor - func run(_ deallocTests: [DeallocTest]) async { - let expectation = expectation(description: "dealloc test") - - await performDeallocTest(deallocTests: deallocTests, expectation: expectation) - - await fulfillment(of: [expectation], timeout: 10) - } -} - -#endif diff --git a/Tests/DeallocTestsTests/DeallocTesterTests.swift b/Tests/DeallocTestsTests/DeallocTesterTests.swift deleted file mode 100644 index 5d17dfb..0000000 --- a/Tests/DeallocTestsTests/DeallocTesterTests.swift +++ /dev/null @@ -1,156 +0,0 @@ -// -// DeallocTesterTests.swift -// DeallocTests -// -// Copyright © 2026 STRV. All rights reserved. -// - -#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)? - - init() { - // Strong reference to self creates a retain cycle - closure = { _ = self } - } -} - -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]() -} - -// MARK: - Tests - -#if DependencyInjection - -@available(*, deprecated, message: "Tests the deprecated DeallocTester API") -final class DeallocTesterTests: DeallocTester { - @MainActor - func test_cleanObject_passes() async { - await run([ - DeallocTest(objectCreation: { _ in CleanObject() }) - ]) - } - - @MainActor - func test_retainCycle_fails() async { - XCTExpectFailure("LeakingObject has a retain cycle") - - await run([ - DeallocTest(objectCreation: { _ in LeakingObject() }) - ]) - } - - @MainActor - func test_leakIsDetectedPerInstance() async { - XCTExpectFailure("Second CleanObject instance is retained") - - await run([ - DeallocTest(objectCreation: { _ in - let leaked = CleanObject() - leaked.initializeDeallocTestSupport() - Leaks.retained.append(leaked) - return CleanObject() - }) - ]) - } - - @MainActor - func test_nilObject_failsWithoutHanging() async { - XCTExpectFailure("objectCreation returned nil") - - let start = ContinuousClock.now - await run([ - DeallocTest(objectCreation: { _ in nil }), - DeallocTest(objectCreation: { _ in CleanObject() }) - ]) - - XCTAssertLessThan(ContinuousClock.now - start, .seconds(5)) - } - - @MainActor - func test_notDeallocTestable_failsWithoutHanging() async { - XCTExpectFailure("NotTestableObject is not DeallocTestable") - - let start = ContinuousClock.now - await run([ - DeallocTest(objectCreation: { _ in NotTestableObject() }) - ]) - - XCTAssertLessThan(ContinuousClock.now - start, .seconds(5)) - } - - @MainActor - func test_checkClasses_failsForUntrackedClass() async { - XCTExpectFailure("NotTestableObject is never tracked") - - await run([ - DeallocTest(objectCreation: { _ in CleanObject() }, checkClasses: [CleanObject.self, NotTestableObject.self]) - ]) - } - - @MainActor - func test_checkClasses_passesForDeallocatedClass() async { - await run([ - DeallocTest(objectCreation: { _ in CleanObject() }, checkClasses: [CleanObject.self]) - ]) - } - - @MainActor - func test_sharedInstanceFromContainer_isReleased() async { - await run([ - DeallocTest(objectCreation: { await $0.resolve(type: Service.self) as AnyObject }) - ]) - } - - @MainActor - func test_actionBeforeCheck_isCalled() async { - var called = false - - await run([ - DeallocTest(objectCreation: { _ in CleanObject() }, actionBeforeCheck: { called = true }) - ]) - - XCTAssertTrue(called) - } - - override func registerDependencies() async { - await container.register(type: Service.self, in: .shared) { _ in SharedService() } - } -} - -@available(*, deprecated) -private extension DeallocTesterTests { - @MainActor - func run(_ deallocTests: [DeallocTest]) async { - let expectation = expectation(description: "dealloc test") - - await performDeallocTest(deallocTests: deallocTests, expectation: expectation) - - await fulfillment(of: [expectation], timeout: 10) - } -} - -#endif diff --git a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift index 4916184..2413eb1 100644 --- a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift +++ b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift @@ -18,7 +18,6 @@ import Testing // MARK: - Fixtures -/// No `DeallocTestable` conformance needed final class PlainObject {} final class RetainCycleObject { @@ -31,9 +30,12 @@ final class RetainCycleObject { protocol AnyService: Sendable {} +protocol Service: AnyObject, Sendable {} + +final class ContainerService: Service {} + struct ValueService: AnyService {} -/// Holds strong references. Each test uses its own instance because tests run in parallel. @MainActor final class Cache { var objects = [AnyObject]() @@ -41,7 +43,6 @@ final class Cache { struct LifecycleError: Error {} -/// Swift names private types `Module.(unknown context at $…).Name` private final class PrivateRetainCycle { var closure: (() -> Void)? @@ -50,7 +51,6 @@ private final class PrivateRetainCycle { } } -/// Matches leak reports attributed to this file func isLeakReport(of typeName: String) -> (Issue) -> Bool { { issue in issue.comments.contains { $0.rawValue.contains("\(typeName) was not deallocated") } @@ -63,6 +63,16 @@ func isLeakReport(of typeName: String) -> (Issue) -> Bool { @Suite("expectDeallocation") @MainActor struct ExpectDeallocationTests { + @Test func cancelledCheckReportsNothing() async { + let check = Task { @MainActor in + await expectDeallocation(timeout: .seconds(5)) { RetainCycleObject() } + } + + try? await Task.sleep(for: .milliseconds(100)) + check.cancel() + await check.value + } + @Test func cleanObjectPasses() async { await expectDeallocation { PlainObject() } } @@ -199,8 +209,6 @@ struct ExpectDeallocationDependencyInjectionTests { // MARK: - trackForDeallocation -#if compiler(>=6.1) - @Suite("trackForDeallocation") @MainActor struct TrackForDeallocationTests { @@ -211,12 +219,14 @@ struct TrackForDeallocationTests { @Test func trackedLeakIsReported() async throws { try await withKnownIssue { - try await DeallocationCheckTrait.checksDeallocation(timeout: .milliseconds(100)).provideScope( + try await DeallocationCheckTrait.checksDeallocation.provideScope( for: #require(Test.current), testCase: Test.Case.current, performing: { await MainActor.run { - _ = trackForDeallocation(RetainCycleObject()) + withDeallocationConfiguration({ $0.timeout = .milliseconds(100) }) { + _ = trackForDeallocation(RetainCycleObject()) + } } } ) @@ -237,7 +247,6 @@ struct TrackForDeallocationTests { @Suite("trackForDeallocation on a suite", .checksDeallocation) @MainActor struct TrackForDeallocationSuiteTests { - /// The suite instance is released before the check, so stored properties work too let object = trackForDeallocation(PlainObject()) @Test func storedPropertyIsChecked() { @@ -249,5 +258,3 @@ struct TrackForDeallocationSuiteTests { _ = trackForDeallocation(PlainObject()) } } - -#endif diff --git a/Tests/DeallocTestsTests/GracePeriodTests.swift b/Tests/DeallocTestsTests/GracePeriodTests.swift new file mode 100644 index 0000000..9a13146 --- /dev/null +++ b/Tests/DeallocTestsTests/GracePeriodTests.swift @@ -0,0 +1,61 @@ +// +// GracePeriodTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import DeallocTests +import Foundation +import Testing + +@MainActor +func makeObjectReleasedAfter(_ delay: Duration) -> PlainObject { + let object = PlainObject() + Task { @MainActor in + try? await Task.sleep(for: delay) + withExtendedLifetime(object) {} + } + return object +} + +@Suite("Grace period") +@MainActor +struct GracePeriodTests { + @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.seconds(2))) + func lateReleaseIsAWarning() async { + await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + } + + @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.milliseconds(300))) + func leakIsStillAFailureAfterTheGracePeriod() async { + await withKnownIssue { + await expectDeallocation { RetainCycleObject() } + } matching: { issue in + issue.comments.contains { + $0.rawValue.hasPrefix( + "DeallocTestsTests.RetainCycleObject was not deallocated within 100 ms. It was watched for another 300 ms after that." + ) + } + } + } + + @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.zero)) + func zeroGracePeriodTurnsItOff() async { + await withKnownIssue { + await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + } matching: { issue in + issue.comments.contains { $0.rawValue.hasPrefix("DeallocTestsTests.PlainObject was not deallocated within 100 ms. Something") } + } + } + + @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.seconds(3)), .deallocationIssues(.warning)) + func warningSeveritySkipsTheGracePeriod() async { + let clock = ContinuousClock() + let start = clock.now + + await expectDeallocation { RetainCycleObject() } + + #expect(clock.now - start < .seconds(1)) + } +} diff --git a/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift b/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift index 5e78f23..128c0b4 100644 --- a/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift +++ b/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift @@ -21,7 +21,6 @@ final class ClosureLeak { } } -/// `@Observable` stores the closure as `_onUpdate` @available(macOS 14, iOS 17, *) @Observable final class ObservableClosureLeak { @@ -54,7 +53,6 @@ final class SubscriptionLeak { var value = 0 init() { - // The subscription stays alive and its sink captures self strongly updates.sink { self.value = $0 }.store(in: &cancellables) } } @@ -67,7 +65,6 @@ final class OwnerObject { } } -/// Starts an endless task on appear and never cancels it @MainActor final class TaskLeakModel { var task: Task? @@ -83,7 +80,6 @@ final class TaskLeakModel { } } -/// Runs work in `.task`, which SwiftUI cancels when the view goes away @MainActor final class TaskModifierModel { var ticks = 0 @@ -121,6 +117,14 @@ func isLeakReport(of typeName: String, mentioning hint: String) -> (Issue) -> Bo } } +final class SelfReference { + var me: SelfReference? + + init() { + me = self + } +} + // MARK: - Leak hints @Suite("Leak hints") @@ -152,6 +156,14 @@ struct LeakHintsTests { } } + @Test func directSelfReferenceIsShown() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { SelfReference() } + } matching: { issue in + isLeakReport(of: "SelfReference", mentioning: "`self.me` refers back to the object")(issue) + } + } + @Test func subscriptionIsNamed() async { await withKnownIssue { await expectDeallocation(timeout: .milliseconds(100)) { SubscriptionLeak() } diff --git a/Tests/DeallocTestsCoreTests/LeakReportTests.swift b/Tests/DeallocTestsTests/LeakReportTests.swift similarity index 50% rename from Tests/DeallocTestsCoreTests/LeakReportTests.swift rename to Tests/DeallocTestsTests/LeakReportTests.swift index 507a2d1..520c528 100644 --- a/Tests/DeallocTestsCoreTests/LeakReportTests.swift +++ b/Tests/DeallocTestsTests/LeakReportTests.swift @@ -1,11 +1,13 @@ // // LeakReportTests.swift -// DeallocTestsCoreTests +// DeallocTests // // Copyright © 2026 STRV. All rights reserved. // -@testable import DeallocTestsCore +import Combine +@testable import DeallocTests +import Foundation import Testing private final class PrivateObject {} @@ -14,12 +16,26 @@ final class ClosureHolder { var onUpdate: (() -> Void)? } -@Suite("Core") +final class TimerService {} + +final class UnusedOptionals { + var onUpdate: (() -> Void)? + var timer: Timer? + var task: Task? + var cancellables = Set() + let timerService = TimerService() +} + +final class TimerHolder { + var timer: Timer? = Timer(timeInterval: 60, repeats: false) { _ in } +} + +@Suite("Leak reports") struct LeakReportTests { @Test func readableNameDropsUnknownContext() { let name = TypeNames.readableName(of: PrivateObject()) - #expect(name == "DeallocTestsCoreTests.PrivateObject") + #expect(name == "DeallocTestsTests.PrivateObject") } @Test func messageWithoutHintsGivesGeneralAdvice() { @@ -38,6 +54,28 @@ struct LeakReportTests { """) } + @Test(arguments: [ + (Duration.milliseconds(400), "400 ms"), + (.seconds(2), "2 sec"), + (.milliseconds(3250), "3.2 sec"), + (.milliseconds(1999), "1.9 sec"), + ]) + func durationsReadTheSameInEveryLocale(duration: Duration, text: String) { + #expect(DurationText.describe(duration) == text) + } + + @Test func lateReleaseMessageExplainsBoundedRetention() { + let message = LeakReport.lateReleaseMessage(typeName: "App.Store", releasedAfter: .milliseconds(3250), timeout: .seconds(2)) + + #expect(message.hasPrefix("App.Store was released after 3.2 sec, later than the 2 sec timeout. That's bounded retention, not a leak")) + } + + @Test func messageMentionsTheGracePeriod() { + let report = LeakReport(typeName: "App.Screen", timeout: .seconds(2), gracePeriod: .seconds(3)) + + #expect(report.message.hasPrefix("App.Screen was not deallocated within 2 sec. It was watched for another 3 sec after that.")) + } + @Test func hintsNameClosureProperties() { let holder = ClosureHolder() holder.onUpdate = {} @@ -45,6 +83,14 @@ struct LeakReportTests { #expect(LeakHints.hints(for: holder).contains { $0.hasPrefix("`onUpdate` is a closure") }) } + @Test func hintsSkipEmptyOptionalsAndCollections() { + #expect(LeakHints.hints(for: UnusedOptionals()).isEmpty) + } + + @Test func hintsNameTimersByType() { + #expect(LeakHints.hints(for: TimerHolder()).contains { $0.hasPrefix("`timer` is a timer") }) + } + @Test @MainActor func pollingReturnsAsSoonAsTheConditionHolds() async { let clock = ContinuousClock() let start = clock.now diff --git a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift index 71e843b..30df3a3 100644 --- a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift +++ b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift @@ -9,6 +9,23 @@ import DeallocTests import XCTest final class TrackForDeallocationXCTests: XCTestCase { + override func invokeTest() { + withDeallocationConfiguration({ $0.gracePeriod = .zero }) { + super.invokeTest() + } + } + + @MainActor + func test_invokeTestConfiguration_appliesToTheTest() async { + let options = XCTExpectedFailure.Options() + options.issueMatcher = { issue in + issue.compactDescription.contains("within 100 ms.") && !issue.compactDescription.contains("watched for another") + } + XCTExpectFailure("RetainCycleObject has a retain cycle", options: options) + + await expectDeallocation(timeout: .milliseconds(100)) { RetainCycleObject() } + } + @MainActor func test_trackedObject_passes() { let object = trackForDeallocation(PlainObject()) @@ -19,7 +36,9 @@ final class TrackForDeallocationXCTests: XCTestCase { func test_trackedLeak_fails() { XCTExpectFailure("RetainCycleObject has a retain cycle") - trackForDeallocation(RetainCycleObject(), timeout: .milliseconds(100)) + withDeallocationConfiguration({ $0.timeout = .milliseconds(100) }) { + _ = trackForDeallocation(RetainCycleObject()) + } } @MainActor @@ -57,7 +76,20 @@ final class TrackForDeallocationXCTests: XCTestCase { } } - /// Reported as a non-strict expected failure, so the test passes + @MainActor + func test_trackedObject_usesTheConfigurationItWasTrackedWith() { + let options = XCTExpectedFailure.Options() + options.issueMatcher = { $0.compactDescription.contains("within 130 ms") } + XCTExpectFailure("RetainCycleObject has a retain cycle", options: options) + + withDeallocationConfiguration({ + $0.timeout = .milliseconds(130) + $0.gracePeriod = .zero + }) { + _ = trackForDeallocation(RetainCycleObject()) + } + } + @MainActor func test_warningSeverity_doesNotFailTheTest() async { await withDeallocationConfiguration({ @@ -67,4 +99,14 @@ final class TrackForDeallocationXCTests: XCTestCase { await expectDeallocation { RetainCycleObject() } } } + + @MainActor + func test_lateRelease_isAWarning() async { + await withDeallocationConfiguration({ + $0.timeout = .milliseconds(100) + $0.gracePeriod = .seconds(2) + }) { + await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + } + } } diff --git a/Tests/DeallocTestsTests/UIKitLifecycleTests.swift b/Tests/DeallocTestsTests/UIKitLifecycleTests.swift index 4720c78..e3ff371 100644 --- a/Tests/DeallocTestsTests/UIKitLifecycleTests.swift +++ b/Tests/DeallocTestsTests/UIKitLifecycleTests.swift @@ -13,7 +13,6 @@ import UIKit final class CleanController: UIViewController {} -/// Leaks as soon as the view loads final class LoadLeakingController: UIViewController { var closure: (() -> Void)? @@ -23,7 +22,6 @@ final class LoadLeakingController: UIViewController { } } -/// Leaks only once it appears on screen, so `.loadView` doesn't catch it final class AppearLeakingController: UIViewController { var closure: (() -> Void)? @@ -33,8 +31,6 @@ final class AppearLeakingController: UIViewController { } } -/// Modal presentation needs a window scene, which only exists when the tests run in a host app. -/// The sample apps cover `.present` in that setup. @MainActor func hasWindowScene() -> Bool { UIApplication.shared.connectedScenes.contains { $0 is UIWindowScene }