+ @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: -
+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. -