From b0ad3c90774a1b78a866f0fbed303f8c848c2d01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Mon, 5 Oct 2026 17:55:40 +0200 Subject: [PATCH 1/2] feat!: remove the DeallocTester API, migrate samples, document 4.0 expectDeallocation covers everything DeallocTester did and fixes what it got wrong: hangs on macOS and on nil objects, leaks matched by class, only the root object watched, fixed sleeps, failures inside the library. Keeping both would mean two ways to write the same test, so 4.0 removes the old API. DefaultInitializable goes too. Both sample apps use expectDeallocation and CI builds them. The README documents the 4.0 API with a "Migrating to 4.0" section, and CHANGELOG.md gets the 4.0.0 entry. BREAKING CHANGE: DeallocTester, DeallocTest, DeallocTestable, ClassNameIdentifiable and DefaultInitializable are removed. Each DeallocTest becomes one expectDeallocation call. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 19 + CHANGELOG.md | 34 ++ README.md | 331 +++++++++++------- .../project.pbxproj | 4 - .../DeallocTestConformances.swift | 16 - .../MainCoordinatorDeallocTester.swift | 61 ++-- .../project.pbxproj | 16 +- .../DeallocTestConformances.swift | 17 - .../DependencyGraphDeallocTester.swift | 38 -- .../ExpectDeallocationTests.swift | 49 +++ .../MainCoordinatorDeallocTester.swift | 55 --- .../DeallocTestable+Internals.swift | 61 ---- Sources/DeallocTests/DeallocTestable.swift | 23 -- Sources/DeallocTests/DeallocTester.swift | 216 ------------ Sources/DeallocTests/DefaultValue.swift | 75 ---- .../DeinitializationObserver.swift | 45 --- 16 files changed, 340 insertions(+), 720 deletions(-) create mode 100644 CHANGELOG.md delete mode 100644 SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/DeallocTestConformances.swift delete mode 100644 SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/DeallocTestConformances.swift delete mode 100644 SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/DependencyGraphDeallocTester.swift create mode 100644 SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/ExpectDeallocationTests.swift delete mode 100644 SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift delete mode 100644 Sources/DeallocTests/DeallocTestable+Internals.swift delete mode 100644 Sources/DeallocTests/DeallocTestable.swift delete mode 100644 Sources/DeallocTests/DeallocTester.swift delete mode 100644 Sources/DeallocTests/DefaultValue.swift delete mode 100644 Sources/DeallocTests/DeinitializationObserver.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f32c28..a62d4d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,22 @@ jobs: xcodebuild test \ -scheme DeallocTests \ -destination "id=$UDID" + + sample-apps: + name: Sample apps (iOS) + # Package traits in an Xcode project need Xcode 26.4 or later + runs-on: macos-26 + strategy: + matrix: + sample: [DeallocTestsAppSPM, DeallocTestsAppDIFreeSPM] + steps: + - uses: actions/checkout@v4 + # The samples contain an intentional leak, so their tests are expected to fail. + # CI only verifies that they compile. + - name: Build for testing + working-directory: SampleApps/${{ matrix.sample }} + run: | + xcodebuild build-for-testing \ + -scheme DeallocTestsAppSPM \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ad855aa --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +## 4.0.0 + +See "Migrating to 4.0" in the README. + +### Added +- `expectDeallocation(_:timeout:afterRelease:of:)`: creates an object, runs its lifecycle, releases it and checks that it deallocates. Works in Swift Testing and XCTest, 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, 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(_:)`, `.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. + +### Breaking +- STRV Dependency Injection support is the `DependencyInjection` package trait. It's on by default; with `traits: []` the dependency isn't downloaded. +- Works with STRV Dependency Injection 1.0.4 up to 2.x. +- The `DeallocTestsDIFree` product is removed. Use the `DeallocTests` product and `import DeallocTests`. +- Swift 6.1 (Xcode 16.3) is required. Turning the trait off from an Xcode project needs Xcode 26.4. +- `DefaultInitializable` is removed. +- `DeallocTester`, `DeallocTest`, `DeallocTestable` and `ClassNameIdentifiable` are removed. Each `DeallocTest` becomes one `expectDeallocation` call; see "Migrating to 4.0" in the README. + +### Fixed +- The dependency URL uses https, so the package resolves without SSH access to GitHub. + +### Removed +- Travis CI, Danger, Carthage, jazzy and unused headers. diff --git a/README.md b/README.md index 260431a..00bed5b 100644 --- a/README.md +++ b/README.md @@ -27,195 +27,288 @@ DeallocTests work well with apps that use MVVM-C (MVVM with ViewCoordinators) ar ## STRV Dependency Injection library -The main version of DeallocTests uses [STRV Dependency Injection library](https://github.com/strvcom/ios-dependency-injection) as the only dependency. The support of dependency injection is great benefit but DeallocTests can work also without it, if needed. If you don't use STRV Dependency Injection in your app, please use DeallocTestsDIFree library: -* For installation using SPM: Library `DeallocTestsDIFree` - -## DeallocTests. Easy-to-use framework for custom deallocation tests. - -- [Requirements](#requirements) -- [Installation](#installation) -- [License](#license) +DeallocTests integrates with [STRV Dependency Injection library](https://github.com/strvcom/ios-dependency-injection). The integration is the `DependencyInjection` [package trait](#installation), which is on by default. Projects that don't use STRV Dependency Injection can turn it off, and the library is then not even downloaded. ## Requirements -- iOS 13.0+ / Mac OS X 10.10+ / tvOS 9.0+ / watchOS 2.0+ -- Xcode 12.0+ +- iOS 17.0+ / macOS 13.0+ +- Swift 6.1+ / Xcode 16.3+ +- Swift Testing or XCTest +- Turning the `DependencyInjection` trait off from an Xcode project needs Xcode 26.4 or later ## Installation -### Dependency Managers -
- Swift Package Manager - -To use DeallocTests as a [Swift Package Manager](https://swift.org/package-manager/) package just add the following in your Package.swift file. +DeallocTests is distributed via [Swift Package Manager](https://swift.org/package-manager/). Add it to the **test target** of your app: ``` swift -// swift-tools-version:5.5 +// swift-tools-version:6.1 import PackageDescription let package = Package( name: "HelloDeallocTests", dependencies: [ - .package(url: "https://github.com/strvcom/DeallocTests.git", .upToNextMajor(from: "1.0.0")) + .package(url: "https://github.com/strvcom/DeallocTests.git", .upToNextMajor(from: "4.0.0")) ], targets: [ - .target(name: "HelloDeallocTests", dependencies: ["DeallocTests"]) + .testTarget( + name: "HelloDeallocTestsTests", + dependencies: [ + "HelloDeallocTests", + .product(name: "DeallocTests", package: "DeallocTests") + ] + ) ] ) ``` -__WARNING__: When you install DeallocTests with SPM and you use Xcode 12.5+ you must set `ENABLE_TESTING_SEARCH_PATHS` to `YES` in your app target build settings. -
+This includes the STRV Dependency Injection integration. If your project doesn't use STRV Dependency Injection, turn off the default trait, so the library isn't downloaded: + +``` swift +.package(url: "https://github.com/strvcom/DeallocTests.git", .upToNextMajor(from: "4.0.0"), traits: []) +``` + +In Xcode, add the package via *File › Add Package Dependencies…* and link the `DeallocTests` product to your test target only. To turn the STRV Dependency Injection integration off, disable the package's default traits in Xcode 26.4 or later. The package reference in the project file then has an empty list: + +``` +traits = ( +); +``` + +## Usage + +### `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 conformances or changes to the app target, and a leak is reported at the line of your test. -### Manually +```swift +import DeallocTests +import Testing +@testable import MyApp -If you prefer not to use either of the aforementioned dependency managers, you can integrate DeallocTests into your project manually. +@MainActor +struct LeakTests { + let coordinator = MainCoordinator() -
- Git Submodules

+ @Test func profileScreen() async { + await expectDeallocation(.present) { coordinator.createProfileViewController() } + } -- Open up Terminal, `cd` into your top-level project directory, and run the following command "if" your project is not initialized as a git repository: + @Test func settingsScreen() async { + await expectDeallocation(.push) { coordinator.createSettingsViewController() } + } -```bash -$ git init + @Test func profileViewModel() async { + await expectDeallocation { ProfileViewModel(api: MockAPI()) } + } +} ``` -- Add DeallocTests as a git [submodule](http://git-scm.com/docs/git-submodule) by running the following command: +The same calls work inside an `XCTestCase`. A leak fails at the line of the test, and the message points at the likely cause: -```bash -$ git submodule add https://github.com/DanielCech/DeallocTests.git -$ git submodule update --init --recursive +``` +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 ``` -- Open the new `DeallocTests` folder, and drag the `DeallocTests.xcodeproj` into the Project Navigator of your application's Xcode project. +The hints come from the leaked object's stored properties: closures, `Task`s, Combine subscriptions, timers, and reference cycles through properties. Reflection can't tell weak properties from strong ones or look inside closures, so treat them as suggestions. - > It should appear nested underneath your application's blue project icon. Whether it is above or below all the other Xcode groups does not matter. +Many leaks only appear once a screen loads or appears, so pick the lifecycle that exercises the object: -- Select the `DeallocTests.xcodeproj` in the Project Navigator and verify the deployment target matches that of your application target. -- Next, select your application project in the Project Navigator (blue project icon) to navigate to the target configuration window and select the application target under the "Targets" heading in the sidebar. -- In the tab bar at the top of that window, open the "General" panel. -- Click on the `+` button under the "Embedded Binaries" section. -- You will see two different `DeallocTests.xcodeproj` folders each with two different versions of the `DeallocTests.framework` nested inside a `Products` folder. +| Lifecycle | What happens before release | +|---|---| +| `.none` (default) | Nothing, the object is released right away | +| `.loadView` | The view controller loads its view (`viewDidLoad`). UIKit and AppKit. | +| `.present`, `.present(style:interaction:)` | The view controller is presented in a test window, then dismissed | +| `.push`, `.push(interaction:)` | The view controller is pushed onto a navigation controller in a test window, then popped | +| `.hosting { object in SomeView(model: object) }` | A SwiftUI view built from the object is shown in a test window, then removed. `onAppear` and `.task` run. | +| `.custom { object in … }` | Your code runs with the object, e.g. calls the methods you suspect of leaking | - > It does not matter which `Products` folder you choose from. +`interaction` runs while the controller is on screen: + +```swift +await expectDeallocation(.present(interaction: { controller in + controller.searchBar.text = "query" + await controller.search() +})) { + coordinator.createSearchViewController() +} +``` -- Select the `DeallocTests.framework`. +SwiftUI views are values, so check the object behind them, typically the view model: -- And that's it! +```swift +await expectDeallocation(.hosting { ProfileView(viewModel: $0) }) { + ProfileViewModel(api: MockAPI()) +} +``` -> The `DeallocTests.framework` is automagically added as a target dependency, linked framework and embedded framework in a copy files build phase which is all you need to build on the simulator and a device. +To also check objects the tested one owns, wrap them in `trackForDeallocation` inside the closure. They must deallocate together with it: -

+```swift +await expectDeallocation(.present) { + let viewModel = trackForDeallocation(ProfileViewModel(api: MockAPI())) + return ProfileViewController(viewModel: viewModel) +} +``` -
- Embedded Binaries

+Other parameters: -- Download the latest release from https://github.com/DanielCech/DeallocTests/releases -- Next, select your application project in the Project Navigator (blue project icon) to navigate to the target configuration window and select the application target under the "Targets" heading in the sidebar. -- In the tab bar at the top of that window, open the "General" panel. -- Click on the `+` button under the "Embedded Binaries" section. -- Add the downloaded `DeallocTests.framework`. -- And that's it! +- `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 -## Sample App +`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`: -The folder SampleApps contains a demo project that demonstrates at least some features. The application itself is very simple—there are just three screens in the navigation stack. All screens are handled by `MainCoordinator`. +| Option | Default | Swift Testing trait | +|---|---|---| +| `timeout` | 2 sec | `.deallocationTimeout(_:)` | +| `gracePeriod` | 3 sec | `.deallocationGracePeriod(_:)` | +| `severity` | `.error` | `.deallocationIssues(_:)` | -The Podfile adds DeallocTests support to the app's test target. +- 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. -```ruby -target 'DeallocTestsAppCocoapodsTests' do - pod 'DeallocTests', :path=>'../../' -end +```swift +@Suite(.deallocationTimeout(.seconds(5))) +struct ScreenDeallocTests { … } + +// Adopting dealloc tests in an existing project: report leaks as warnings for now +@Suite(.deallocationIssues(.warning)) +struct LegacyDeallocTests { … } ``` -The file `DeallocTestsConformances.swift` contains the DeallocTestable protocol conformances to all tested classes. +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 -import DeallocTests -@testable import DeallocTestsAppCocoapods +await withDeallocationConfiguration({ $0.timeout = .seconds(5) }) { + await expectDeallocation(.present) { makeProfileViewController() } +} -extension MainCoordinator: DeallocTestable {} -extension FirstViewController: DeallocTestable {} -extension SecondViewController: DeallocTestable {} -extension ThirdViewController: DeallocTestable {} +final class LegacyDeallocTests: XCTestCase { + override func invokeTest() { + withDeallocationConfiguration({ $0.severity = .warning }) { + super.invokeTest() + } + } +} ``` -The file `MainCoordinatorDeallocTester.swift` is also very simple. It defines the testing scenario for MainCoordinator. +`.present` needs a test target with a host app, because modal presentation needs a window scene. The other lifecycles also work in package tests. + +### Checking objects used in ordinary unit tests + +`trackForDeallocation` checks that an object deallocates when the test ends, so any unit test can catch leaks of its system under test. ```swift -var mainCoordinator: MainCoordinator? { - applyAssembliesToContainer() - return MainCoordinator() +// Swift Testing: add the trait to a test or a whole suite +@Test(.checksDeallocation) @MainActor func loadsProfile() async { + let viewModel = trackForDeallocation(ProfileViewModel(api: MockAPI())) + await viewModel.load() + #expect(viewModel.name == "Daniel") +} + +// XCTest +@MainActor +func test_loadsProfile() async { + let viewModel = trackForDeallocation(ProfileViewModel(api: MockAPI())) + await viewModel.load() + XCTAssertEqual(viewModel.name, "Daniel") } ``` -This will initialize a dependency injection container and instantiate the main coordinator. The method `applyAssembliesToContainer` is defined in the main target. The scenario for the main coordinator test looks like this: (Don't be scared, it is almost boilerplate code which is common for every test scenario.) +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. -```swift -func test_mainCoordinatorDealloc() { - presentingController = showPresentingController() - - 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() - } - ) - ] +### STRV Dependency Injection - let expectation = self.expectation(description: "deallocTest test_mainCoordinatorDealloc") +With the `DependencyInjection` trait, a dependency can be resolved from an `AsyncContainer`, released together with the container's shared instances and checked: - performDeallocTest( - deallocTests: deallocTests, - expectation: expectation - ) +```swift +@Test func apiManager() async { + let container = AsyncContainer() + await container.register(type: APIManaging.self, in: .shared) { _ in APIManager() } - waitForExpectations(timeout: 200, handler: nil) + await expectDeallocation(of: APIManaging.self, resolvedFrom: container) } ``` -The variable `presentingController` is just simple empty controller on top of everything - it is handled by DeallocTests framework. The array `deallocTests` consists of four items. The first three are for view controllers and the last one is for the view coordinator itself. The `objectCreation` closure initializes the particular view controller from the view coordinator. The `expectation` is a standard `XCTestExpectation` used to wait for the result of the test. The main test processing is hidden in the `performDeallocTest` call. +Following the dependency graph, check the simplest dependencies first, then the ones that use them. + +## Migrating to 4.0 -The sample app intentionally contains a memory leak in SecondViewController.swift. This class contains the closure with a strong reference to `self`. The DeallocTests console output looks like this: +**Dependency Injection.** The `DeallocTestsDIFree` product is gone. Everyone uses the `DeallocTests` product and `import DeallocTests`: +- If you used `DeallocTests` with STRV Dependency Injection, nothing changes. The `DependencyInjection` trait is on by default. +- If you used `DeallocTestsDIFree`, link the `DeallocTests` product instead, replace `import DeallocTestsDIFree` with `import DeallocTests`, and turn the default trait off (see [Installation](#installation)) so STRV Dependency Injection isn't downloaded. + +**`DeallocTester` is removed**, together with `DeallocTest`, `DeallocTestable` and `ClassNameIdentifiable`. Each `DeallocTest` becomes one `expectDeallocation` call, and the `DeallocTestable` conformances are deleted: + +```swift +// Before +final class MainCoordinatorDeallocTester: DeallocTester { + @MainActor + func test_mainCoordinatorDealloc() async { + let coordinator = MainCoordinator() + let expectation = expectation(description: "dealloc test") + + await performDeallocTest( + deallocTests: [ + DeallocTest(objectCreation: { _ in coordinator.createFirstViewController() }), + DeallocTest(objectCreation: { _ in MainCoordinator() }) + ], + expectation: expectation + ) + + await fulfillment(of: [expectation], timeout: 60) + } +} + +// After (XCTest; in Swift Testing the calls are the same) +final class MainCoordinatorDeallocTests: XCTestCase { + @MainActor + func test_firstScreen() async { + let coordinator = MainCoordinator() + await expectDeallocation(.present) { coordinator.createFirstViewController() } + } + + @MainActor + func test_coordinator() async { + await expectDeallocation { MainCoordinator() } + } +} ``` -Checking: -Alloc FirstViewController -Dealloc FirstViewController -Checking: -Alloc SecondViewController -/Users/danielcech/Documents/[Development]/[Projects]/ios-research-dealloc-tests/Sources/Core/DeallocTester.swift:175: error: -[DeallocTestsAppCocoapodsTests.MainCoordinatorDeallocTester test_mainCoordinatorDealloc] : failed - Failed: dealloc test failed on classes: [DeallocTestsAppCocoapods.SecondViewController] +| `DeallocTester` | `expectDeallocation` | +|---|---| +| View controllers are always presented | Choose `.present`, `.push`, `.loadView` or `.hosting` | +| `registerDependencies()` + `objectCreation: { $0.resolve(...) }` | `expectDeallocation(of:resolvedFrom:)` with your own `AsyncContainer` | +| `checkClasses` | `trackForDeallocation(_:)` inside the closure | +| `actionBeforeCheck` | `afterRelease` | +| `deallocationTimeout` | `timeout` | +| `isLoggingEnabled` (`Alloc`/`Dealloc` output) | Leak messages name the leaked object and its likely causes | + +**`DefaultInitializable`** is removed. It wasn't related to dealloc testing. + +## Sample Apps -Checking: -Alloc ThirdViewController -Dealloc ThirdViewController +The folder `SampleApps` contains two demo projects. The application itself is very simple: there are just three screens in the navigation stack, all handled by `MainCoordinator`. -Checking: -Alloc MainCoordinator -Dealloc MainCoordinator +- `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 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. 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 ``` -If you comment out the first line and uncomment the second one, you will see that the retain cycle disappears and the test will succeed. +If you comment out the first line and uncomment the second one, the retain cycle disappears and the test will succeed. ```swift someClosure = { number in self.view(number) } @@ -233,4 +326,4 @@ Issues and pull requests are welcome! ## License -DeallocTests is released under the MIT license. See [LICENSE](https://github.com/DanielCech/DeallocTests/blob/master/LICENSE) for details. +DeallocTests is released under the MIT license. See [LICENSE](https://github.com/strvcom/DeallocTests/blob/master/LICENSE) for details. diff --git a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj index 3f8a716..1f0efc9 100644 --- a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj +++ b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj @@ -19,7 +19,6 @@ 27A0804E245C879C0037F1DB /* ThirdViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A0804D245C879C0037F1DB /* ThirdViewController.swift */; }; 27A08051245C87C40037F1DB /* MainCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A08050245C87C40037F1DB /* MainCoordinator.swift */; }; 27A08053245CBA7A0037F1DB /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A08052245CBA790037F1DB /* SceneDelegate.swift */; }; - 27A08055245CBBD10037F1DB /* DeallocTestConformances.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A08054245CBBD10037F1DB /* DeallocTestConformances.swift */; }; 27A08057245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A08056245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift */; }; ADCB58E527FD840C009E0DEB /* DeallocTests in Frameworks */ = {isa = PBXBuildFile; productRef = ADCB58E427FD840C009E0DEB /* DeallocTests */; }; /* End PBXBuildFile section */ @@ -51,7 +50,6 @@ 27A0804D245C879C0037F1DB /* ThirdViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThirdViewController.swift; sourceTree = ""; }; 27A08050245C87C40037F1DB /* MainCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainCoordinator.swift; sourceTree = ""; }; 27A08052245CBA790037F1DB /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - 27A08054245CBBD10037F1DB /* DeallocTestConformances.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeallocTestConformances.swift; sourceTree = ""; }; 27A08056245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainCoordinatorDeallocTester.swift; sourceTree = ""; }; 7E877B2A5D7E5C94D45AE696 /* Pods_DeallocTestsAppCocoapodsTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_DeallocTestsAppCocoapodsTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -125,7 +123,6 @@ isa = PBXGroup; children = ( 27A0803F245C859E0037F1DB /* Info.plist */, - 27A08054245CBBD10037F1DB /* DeallocTestConformances.swift */, 27A08056245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift */, ); path = DeallocTestsAppSPMTests; @@ -279,7 +276,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 27A08055245CBBD10037F1DB /* DeallocTestConformances.swift in Sources */, 27A08057245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift in Sources */, 275ACCAC246D4C2E00FEE52F /* TestAppDelegate.swift in Sources */, 275ACCAD246D4C2E00FEE52F /* TestSceneDelegate.swift in Sources */, diff --git a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/DeallocTestConformances.swift b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/DeallocTestConformances.swift deleted file mode 100644 index 85ff594..0000000 --- a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/DeallocTestConformances.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// DeallocTestConformances.swift -// DeallocTestsAppTests -// -// Created by Daniel Cech on 01/05/2020. -// Copyright © 2020 STRV. All rights reserved. -// - -import Foundation -import DeallocTests -@testable import DeallocTestsAppSPM - -extension MainCoordinator: DeallocTestable {} -extension FirstViewController: DeallocTestable {} -extension SecondViewController: DeallocTestable {} -extension ThirdViewController: DeallocTestable {} diff --git a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift index c83baea..a98600c 100644 --- a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift +++ b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift @@ -6,52 +6,35 @@ // Copyright © 2020 STRV. All rights reserved. // -import Foundation import DeallocTests +import XCTest @testable import DeallocTestsAppSPM - -class MainCoordinatorDeallocTester: DeallocTester { - var mainCoordinator: MainCoordinator? { - return MainCoordinator() +final class MainCoordinatorDeallocTester: XCTestCase { + @MainActor + func test_firstScreen() async { + let coordinator = MainCoordinator() + await expectDeallocation(.present) { coordinator.createFirstViewController() } } @MainActor - func test_mainCoordinatorDealloc() async { - presentingController = await showPresentingController() - - try? await Task.sleep(for: .milliseconds(200)) - - deallocTests = [ - DeallocTest( - objectCreation: { [weak self] in - return self?.mainCoordinator?.createFirstViewController() - } - ), - DeallocTest( - objectCreation: { [weak self] in - return self?.mainCoordinator?.createSecondViewController() - } - ), - DeallocTest( - objectCreation: { [weak self] in - return self?.mainCoordinator?.createThirdViewController() - } - ), - DeallocTest( - objectCreation: { - return MainCoordinator() - } - ) - ] - - let expectation = self.expectation(description: "deallocTest test_mainCoordinatorDealloc") + func test_secondScreen() async { + let coordinator = MainCoordinator() + await expectDeallocation(.present) { coordinator.createSecondViewController() } + } - await performDeallocTest( - deallocTests: deallocTests, - expectation: expectation - ) + @MainActor + func test_thirdScreen() async { + let coordinator = MainCoordinator() + await expectDeallocation(.push) { coordinator.createThirdViewController() } + } - await fulfillment(of: [expectation], timeout: 200) + @MainActor + func test_coordinator() async { + await expectDeallocation { + let coordinator = MainCoordinator() + _ = coordinator.initialViewController() + return coordinator + } } } diff --git a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj index c6c7b87..7222538 100644 --- a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj +++ b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj @@ -19,10 +19,8 @@ 27A0804E245C879C0037F1DB /* ThirdViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A0804D245C879C0037F1DB /* ThirdViewController.swift */; }; 27A08051245C87C40037F1DB /* MainCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A08050245C87C40037F1DB /* MainCoordinator.swift */; }; 27A08053245CBA7A0037F1DB /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A08052245CBA790037F1DB /* SceneDelegate.swift */; }; - 27A08055245CBBD10037F1DB /* DeallocTestConformances.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A08054245CBBD10037F1DB /* DeallocTestConformances.swift */; }; - 27A08057245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A08056245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift */; }; 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 */ @@ -53,10 +51,8 @@ 27A0804D245C879C0037F1DB /* ThirdViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThirdViewController.swift; sourceTree = ""; }; 27A08050245C87C40037F1DB /* MainCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainCoordinator.swift; sourceTree = ""; }; 27A08052245CBA790037F1DB /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - 27A08054245CBBD10037F1DB /* DeallocTestConformances.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeallocTestConformances.swift; sourceTree = ""; }; - 27A08056245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainCoordinatorDeallocTester.swift; sourceTree = ""; }; 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 */ /* Begin PBXFrameworksBuildPhase section */ @@ -128,9 +124,7 @@ isa = PBXGroup; children = ( 27A0803F245C859E0037F1DB /* Info.plist */, - 27A08054245CBBD10037F1DB /* DeallocTestConformances.swift */, - 27A08056245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift */, - ADCB58E027FD7CF3009E0DEB /* DependencyGraphDeallocTester.swift */, + AD0E0E0E2F00000100000002 /* ExpectDeallocationTests.swift */, ); path = DeallocTestsAppSPMTests; sourceTree = ""; @@ -292,9 +286,7 @@ 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 565b52c..0000000 --- a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/DeallocTestConformances.swift +++ /dev/null @@ -1,17 +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 - -extension MainCoordinator: @retroactive DeallocTestable {} -extension FirstViewController: @retroactive DeallocTestable {} -extension SecondViewController: @retroactive DeallocTestable {} -extension ThirdViewController: @retroactive DeallocTestable {} -extension APIManager: @retroactive DeallocTestable {} diff --git a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/DependencyGraphDeallocTester.swift b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/DependencyGraphDeallocTester.swift deleted file mode 100644 index 2840fd9..0000000 --- a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/DependencyGraphDeallocTester.swift +++ /dev/null @@ -1,38 +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 - -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 new file mode 100644 index 0000000..decc55b --- /dev/null +++ b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/ExpectDeallocationTests.swift @@ -0,0 +1,49 @@ +// +// ExpectDeallocationTests.swift +// DeallocTestsAppSPMTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import DeallocTests +import DependencyInjection +import Testing +@testable import DeallocTestsAppSPM + +@Suite("Dealloc tests") +@MainActor +struct ExpectDeallocationTests { + let coordinator = MainCoordinator() + + @Test func firstScreen() async { + await expectDeallocation(.present) { coordinator.createFirstViewController() } + } + + @Test func secondScreen() async { + await expectDeallocation(.push) { coordinator.createSecondViewController() } + } + + @Test func thirdScreen() async { + await expectDeallocation(.present) { coordinator.createThirdViewController() } + } + + @Test func coordinator() async { + await expectDeallocation { + let coordinator = MainCoordinator() + _ = coordinator.initialViewController() + return coordinator + } + } + + @Test func apiManager() async { + let container = AsyncContainer() + await container.register(type: APIManaging.self, in: .shared) { _ in APIManager() } + + await expectDeallocation(of: APIManaging.self, resolvedFrom: container) + } + + @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 e99fe88..0000000 --- a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift +++ /dev/null @@ -1,55 +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 - - -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 d432890..0000000 --- a/Sources/DeallocTests/DeallocTestable+Internals.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// DeallocTestable+Internals.swift -// DeallocTests -// -// Created by Dan Cech on 08.04.2019. -// Copyright © 2019 STRV. All rights reserved. -// - -import Foundation - -extension DeallocTestable { - /// This stores the `DeinitializationObserver`. It's fileprivate 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 { - // swiftlint:disable:next force_cast - 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 - ) - } - } - - /// This is what you call to add a block that should execute on `deinit` - public func initializeDeallocTestSupport() { - if deallocTestSupportInstalled != nil { - return - } - - deinitializationObserver = DeinitializationObserver( - execute: { className in - print("Dealloc \(className)") - deallocatedClasses.append(className) - }, - myClass: myClass - ) - - deallocTestSupportInstalled = true - } -} diff --git a/Sources/DeallocTests/DeallocTestable.swift b/Sources/DeallocTests/DeallocTestable.swift deleted file mode 100644 index 62de748..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 - -enum AssociatedKeys { - @MainActor static var DeinitializationObserver = "DeinitializationObserver" - @MainActor static var DeallocTestSupportInstalled = "DeallocTestSupportInstalled" -} - -/// Protocol for any object that implements this logic -@MainActor -public protocol DeallocTestable: ClassNameIdentifiable, Sendable { - func initializeDeallocTestSupport() -} diff --git a/Sources/DeallocTests/DeallocTester.swift b/Sources/DeallocTests/DeallocTester.swift deleted file mode 100644 index a1538dd..0000000 --- a/Sources/DeallocTests/DeallocTester.swift +++ /dev/null @@ -1,216 +0,0 @@ -// -// DeallocTester.swift -// DeallocTests -// -// Created by Dan Cech on 17.01.2019. -// Copyright © 2019 STRV. All rights reserved. -// - -import Foundation - -let delayTime: Double = 0.1 -let presentationAnimated = false - -#if DependencyInjection - import DependencyInjection -#endif - -import XCTest - -#if canImport(UIKit) - import UIKit -#endif - -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 - } -} - -open class DeallocTester: XCTestCase { - // MARK: - Properties - - public var deallocTests = [DeallocTest]() - -#if canImport(UIKit) - // swiftlint:disable:next implicitly_unwrapped_optional - var window: UIWindow! - - // swiftlint:disable:next implicitly_unwrapped_optional - public var presentingController: UIViewController! -#endif - - /// Initialize DI container with shared dependency registrations - @MainActor - open func registerDependencies() async { - // TODO: Override in descendants. Initialize assembler from main project - - } - -#if canImport(UIKit) - /// Controller for presenting tested controllers - @MainActor - public func showPresentingController() async -> UIViewController { - if #available(iOS 13.0, *), - 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 - -#if DependencyInjection - /// Dependency Injection container - // swiftlint:disable:next implicitly_unwrapped_optional - public var container: AsyncContainer! -#endif - - public override func setUp() async throws { - try await super.setUp() - - #if DependencyInjection - container = AsyncContainer() - #endif - - await MainActor.run { - allocatedClasses = [] - deallocatedClasses = [] - } - } - - public override func tearDown() { - super.tearDown() - } - - /// Instantiate and release tested item - @MainActor - public func performDeallocTest( - deallocTests: [DeallocTest], - expectation: XCTestExpectation - ) async { - await performDeallocTest(index: 0, deallocTests: deallocTests, expectation: expectation) - } - - /// Instantiate and release tested item - @MainActor - private func performDeallocTest( - index: Int, - deallocTests: [DeallocTest], - expectation: XCTestExpectation - ) async { - // Last item in sequence - if index == deallocTests.count { - print("") - expectation.fulfill() - return - } - - allocatedClasses = [] - deallocatedClasses = [] - - #if DependencyInjection - await container.clean() - await registerDependencies() - #endif - - try? await Task.sleep(for: .seconds(delayTime)) - - print("\nChecking:") - - let dependencyDeallocTest = deallocTests[index] - - #if DependencyInjection - var instance: AnyObject? = await dependencyDeallocTest.objectCreation(self.container) - #else - var instance: AnyObject? = await dependencyDeallocTest.objectCreation() - #endif - - guard instance is DeallocTestable else { - // swiftlint:disable:next force_unwrapping - let className = NSStringFromClass(type(of: instance!)) - XCTFail("Failed: class \(className) is not DeallocTestable") - return - } - - (instance as? DeallocTestable)?.initializeDeallocTestSupport() - - #if canImport(UIKit) - try? await Task.sleep(for: .seconds(delayTime)) - - if let controller = instance as? UIViewController { - controller.modalPresentationStyle = .fullScreen - presentingController.present(controller, animated: presentationAnimated) { [weak self] in - Task { - try? await Task.sleep(for: .seconds(delayTime)) - self?.presentingController.dismiss(animated: presentationAnimated, completion: { [weak self] in - Task { - try? await Task.sleep(for: .seconds(delayTime)) - - instance = nil - - await self?.continueWithNextStep(deallocTests: deallocTests, index: index, expectation: expectation) - } - }) - } - } - } else { - instance = nil - await continueWithNextStep(deallocTests: deallocTests, index: index, expectation: expectation) - } - #endif - } - - /// Start testing of next item - @MainActor - private func continueWithNextStep(deallocTests: [DeallocTest], index: Int, expectation: XCTestExpectation) async { - #if DependencyInjection - await container.releaseSharedInstances() - #endif - - try? await Task.sleep(for: .seconds(delayTime)) - - let dependencyDeallocTest = deallocTests[index] - dependencyDeallocTest.actionBeforeCheck?() - - try? await Task.sleep(for: .seconds(delayTime)) - - await checkTestResult(checkedClasses: dependencyDeallocTest.checkClasses ?? allocatedClasses) - - await performDeallocTest(index: index + 1, deallocTests: deallocTests, expectation: expectation) - } - - /// Check proper deallocation - @MainActor - private func checkTestResult(checkedClasses: [AnyClass]) async { - let notFoundClassNames = checkedClasses.filter { testedClass in deallocatedClasses.first(where: { $0 == testedClass }) == nil } - - if !notFoundClassNames.isEmpty { - XCTFail("Failed: dealloc test failed on classes: \(notFoundClassNames)") - } - } -} diff --git a/Sources/DeallocTests/DefaultValue.swift b/Sources/DeallocTests/DefaultValue.swift deleted file mode 100644 index 4772c69..0000000 --- a/Sources/DeallocTests/DefaultValue.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// DefaultValue.swift -// DeallocTests-iOS -// -// Created by Daniel Cech on 15/05/2020. -// Copyright © 2020 DanielCech. All rights reserved. -// - -import Foundation - -public protocol DefaultInitializable { - static var defaultValue: Self { get } -} - -extension Int: DefaultInitializable { - public static var defaultValue: Int { - return Int.random(in: 0 ... 100) - } -} - -extension Float: DefaultInitializable { - public static var defaultValue: Float { - return Float.random(in: 0 ... 100) - } -} - -extension Double: DefaultInitializable { - public static var defaultValue: Double { - return Double.random(in: 0 ... 100) - } -} - -extension Bool: DefaultInitializable { - public static var defaultValue: Bool { - return Bool.random() - } -} - -extension String: DefaultInitializable { - public static var defaultValue: String { - let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - return String((0 ..< 10).map{ _ in letters.randomElement()! }) - } -} - -extension URL: DefaultInitializable { - public static var defaultValue: URL { - return URL(string: "http://google.com")! - } -} - -extension Array: DefaultInitializable { - public static var defaultValue: Array { - return [] - } -} - -extension Dictionary: DefaultInitializable { - public static var defaultValue: Dictionary { - return [:] - } -} - -extension Set: DefaultInitializable { - public static var defaultValue: Set { - return Set() - } -} - -extension Optional: DefaultInitializable { - public static var defaultValue: Optional { - return nil - } -} - diff --git a/Sources/DeallocTests/DeinitializationObserver.swift b/Sources/DeallocTests/DeinitializationObserver.swift deleted file mode 100644 index 266345e..0000000 --- a/Sources/DeallocTests/DeinitializationObserver.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// DeinitializationObserver.swift -// DeallocTests -// -// Created by Dan Cech on 16.03.2019. -// Copyright © 2019 STRV. All rights reserved. -// - -import Foundation - -typealias ClassClosureType = (AnyClass) -> Void - -@MainActor var allocatedClasses = [AnyClass]() -@MainActor var deallocatedClasses = [AnyClass]() - -public protocol ClassNameIdentifiable: AnyObject { - var myClass: AnyClass { get } -} - -public extension ClassNameIdentifiable { - var myClass: AnyClass { - return type(of: self) - } -} - -/// This is a simple object whose job is to execute -/// some closure when it deinitializes -class DeinitializationObserver { - let execute: (AnyClass) -> Void - var myClass: AnyClass - - init(execute: @escaping (AnyClass) -> Void, myClass: AnyClass) { - self.execute = execute - self.myClass = myClass - - print("Alloc \(myClass)") - DispatchQueue.main.async { - allocatedClasses.append(myClass) - } - } - - deinit { - execute(myClass) - } -} From 3d4e613204fc7ca09a498847ca3c85826e2c94d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Tue, 6 Oct 2026 10:25:49 +0200 Subject: [PATCH 2/2] docs: rename the DIFree sample test case and refresh its README output The migrated XCTest case was still called MainCoordinatorDeallocTester, after the removed API. It's MainCoordinatorDeallocTests now (class, file and Xcode project). The README's sample failure points at the right line again (23), and its text is checked against a real run. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 4 ++-- .../DeallocTestsAppSPM.xcodeproj/project.pbxproj | 8 ++++---- ...llocTester.swift => MainCoordinatorDeallocTests.swift} | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) rename SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/{MainCoordinatorDeallocTester.swift => MainCoordinatorDeallocTests.swift} (91%) diff --git a/README.md b/README.md index 00bed5b..5c04ff7 100644 --- a/README.md +++ b/README.md @@ -296,14 +296,14 @@ final class MainCoordinatorDeallocTests: XCTestCase { The folder `SampleApps` contains two demo projects. The application itself is very simple: there are just three screens in the navigation stack, all handled by `MainCoordinator`. -- `DeallocTestsAppDIFreeSPM` checks the screens and the coordinator with `expectDeallocation` in **XCTest** (`MainCoordinatorDeallocTester.swift`). +- `DeallocTestsAppDIFreeSPM` checks the screens and the coordinator with `expectDeallocation` in **XCTest** (`MainCoordinatorDeallocTests.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 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. It was watched for another 3 sec after that. Possible causes: +MainCoordinatorDeallocTests.swift:23: error: -[DeallocTestsAppSPMTests.MainCoordinatorDeallocTests 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/DeallocTestsAppSPM.xcodeproj/project.pbxproj b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj index 1f0efc9..6ff1473 100644 --- a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj +++ b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj @@ -19,7 +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 */; }; - 27A08057245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A08056245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift */; }; + 27A08057245CBBFA0037F1DB /* MainCoordinatorDeallocTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A08056245CBBFA0037F1DB /* MainCoordinatorDeallocTests.swift */; }; ADCB58E527FD840C009E0DEB /* DeallocTests in Frameworks */ = {isa = PBXBuildFile; productRef = ADCB58E427FD840C009E0DEB /* DeallocTests */; }; /* End PBXBuildFile section */ @@ -50,7 +50,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 = ""; }; - 27A08056245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainCoordinatorDeallocTester.swift; sourceTree = ""; }; + 27A08056245CBBFA0037F1DB /* MainCoordinatorDeallocTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainCoordinatorDeallocTests.swift; sourceTree = ""; }; 7E877B2A5D7E5C94D45AE696 /* Pods_DeallocTestsAppCocoapodsTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_DeallocTestsAppCocoapodsTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -123,7 +123,7 @@ isa = PBXGroup; children = ( 27A0803F245C859E0037F1DB /* Info.plist */, - 27A08056245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift */, + 27A08056245CBBFA0037F1DB /* MainCoordinatorDeallocTests.swift */, ); path = DeallocTestsAppSPMTests; sourceTree = ""; @@ -276,7 +276,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 27A08057245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift in Sources */, + 27A08057245CBBFA0037F1DB /* MainCoordinatorDeallocTests.swift in Sources */, 275ACCAC246D4C2E00FEE52F /* TestAppDelegate.swift in Sources */, 275ACCAD246D4C2E00FEE52F /* TestSceneDelegate.swift in Sources */, ); diff --git a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTests.swift similarity index 91% rename from SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift rename to SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTests.swift index a98600c..e758c31 100644 --- a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift +++ b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTests.swift @@ -1,5 +1,5 @@ // -// MainCoordinatorDeallocTester.swift +// MainCoordinatorDeallocTests.swift // DeallocTestsAppTests // // Created by Daniel Cech on 01/05/2020. @@ -10,7 +10,7 @@ import DeallocTests import XCTest @testable import DeallocTestsAppSPM -final class MainCoordinatorDeallocTester: XCTestCase { +final class MainCoordinatorDeallocTests: XCTestCase { @MainActor func test_firstScreen() async { let coordinator = MainCoordinator()