diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3b3e903..3ef633b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -61,6 +61,25 @@ jobs:
-scheme DeallocTests \
-destination 'generic/platform=visionOS Simulator'
+ documentation:
+ name: Documentation (iOS)
+ runs-on: macos-15
+ steps:
+ - uses: actions/checkout@v4
+ # Fails on unresolved symbol links and undocumented parameters in this package; the
+ # dependency's own documentation warnings are ignored
+ - name: Build documentation
+ run: |
+ set -o pipefail
+ xcodebuild docbuild \
+ -scheme DeallocTests \
+ -destination 'generic/platform=iOS Simulator' \
+ -derivedDataPath .build/docbuild 2>&1 | tee docbuild.log
+ if grep "warning:" docbuild.log | grep -v "/checkouts/" | grep -q .; then
+ grep "warning:" docbuild.log | grep -v "/checkouts/"
+ exit 1
+ fi
+
sample-apps:
name: Sample apps (iOS)
# Package traits in an Xcode project need Xcode 26.4 or later
diff --git a/.spi.yml b/.spi.yml
new file mode 100644
index 0000000..fe0f392
--- /dev/null
+++ b/.spi.yml
@@ -0,0 +1,6 @@
+version: 1
+builder:
+ configs:
+ # The UIKit lifecycles (.present, .push) only exist in the iOS variant
+ - documentation_targets: [DeallocTests]
+ platform: ios
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 892439c..f2f8aed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,7 @@
## 4.0.0
-See "Migrating to 4.0" in the README.
+See [Migrating to 4.0](https://swiftpackageindex.com/strvcom/DeallocTests/documentation/dealloctests/migratingto4) in the documentation.
### 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.
@@ -17,6 +17,7 @@ See "Migrating to 4.0" in the README.
- `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.
+- DocC documentation with Getting Started, Configuration and Grace Period, and Migrating to 4.0 articles, hosted on Swift Package Index.
- Swift Testing and XCTest tests of the library on macOS and the iOS simulator, and GitHub Actions CI.
### Breaking
@@ -25,7 +26,7 @@ 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.
-- `DeallocTester`, `DeallocTest`, `DeallocTestable` and `ClassNameIdentifiable` are removed. Each `DeallocTest` becomes one `expectDeallocation` call; see "Migrating to 4.0" in the README.
+- `DeallocTester`, `DeallocTest`, `DeallocTestable` and `ClassNameIdentifiable` are removed. Each `DeallocTest` becomes one `expectDeallocation` call; see "Migrating to 4.0" in the documentation.
### Fixed
- The dependency URL uses https, so the package resolves without SSH access to GitHub.
diff --git a/README.md b/README.md
index 5c04ff7..dd4fe4d 100644
--- a/README.md
+++ b/README.md
@@ -9,22 +9,6 @@ Of course, there are also other tools for memory leak detection, namely Instrume
It is very easy to create a memory leak by mistake. Memory leaks have various forms; it’s not always about just forgetting to add `[weak self]` to closures. That's why it is very important to prevent them from happening. DeallocTests can help with their detection.
-## When can I use DeallocTests?
-
-DeallocTests work well with apps that use MVVM-C (MVVM with ViewCoordinators) architecture. Using coordinators helps to make the ViewControllers independent and easily constructible. DeallocTests work great with [STRV Dependency Injection library](https://github.com/strvcom/ios-dependency-injection). And best of all—DeallocTests don’t need any modifications of the main target of your app.
-
-#### Does it sound too good to be true :-)? Hold on…
-
-## Testing
-
-1) We can focus on view controller testing. Apps written in MVVM-C architecture often have many screens (view controllers) that are grouped with view coordinators. The recommended approach is to create a separate deallocation test scenario for each view coordinator. DeallocTests present the view controllers in view coordinator one-by-one (the method of presentation is not important). If a memory leak is found, the test fails with an error that can help you find the leak. After successful tests of all of the coordinator's controllers, the coordinator itself is checked for memory leaks.
-
-2) Testing of "invisible" objects. Apps often have plenty of classes that encapsulate business logic: Managers, Services, Models, ViewModels, etc. These objects can be checked for deallocation, too. The recommended approach here is to create the testing scenario in order of simplicity. The most simple classes with no dependencies should be checked first, followed by the classes that use the already-tested classes as their dependencies, etc. On the following figure you are supposed to test the `APIManager` first, then `WeatherService` and only then `ForecastViewModel`:
-
-
-
-
-
## STRV Dependency Injection library
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.
@@ -77,8 +61,6 @@ 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.
```swift
@@ -94,17 +76,13 @@ struct LeakTests {
await expectDeallocation(.present) { coordinator.createProfileViewController() }
}
- @Test func settingsScreen() async {
- await expectDeallocation(.push) { coordinator.createSettingsViewController() }
- }
-
@Test func profileViewModel() async {
await expectDeallocation { ProfileViewModel(api: MockAPI()) }
}
}
```
-The same calls work inside an `XCTestCase`. A leak fails at the line of the test, and the message points at the likely cause:
+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. It was watched for another 3 sec after that. Possible causes:
@@ -113,184 +91,17 @@ LeakTests.swift:12: MyApp.ProfileViewController was not deallocated within 2 sec
• Or something outside still holds it: a parent's list of children, a cache or a singleton
```
-The hints come from the leaked object's stored properties: closures, `Task`s, Combine subscriptions, timers, and reference cycles through properties. Reflection can't tell weak properties from strong ones or look inside closures, so treat them as suggestions.
-
-Many leaks only appear once a screen loads or appears, so pick the lifecycle that exercises the object:
-
-| 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 |
-
-`interaction` runs while the controller is on screen:
-
-```swift
-await expectDeallocation(.present(interaction: { controller in
- controller.searchBar.text = "query"
- await controller.search()
-})) {
- coordinator.createSearchViewController()
-}
-```
-
-SwiftUI views are values, so check the object behind them, typically the view model:
-
-```swift
-await expectDeallocation(.hosting { ProfileView(viewModel: $0) }) {
- ProfileViewModel(api: MockAPI())
-}
-```
-
-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)
-}
-```
-
-Other parameters:
-
-- `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
-
-`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`:
-
-| 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)))
-struct ScreenDeallocTests { … }
-
-// Adopting dealloc tests in an existing project: report leaks as warnings for now
-@Suite(.deallocationIssues(.warning))
-struct LegacyDeallocTests { … }
-```
-
-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:
+Many leaks only appear once a screen loads or appears, so pick the lifecycle that exercises the object: `.loadView`, `.present`, `.push`, `.hosting` for SwiftUI views or `.custom`. Ordinary unit tests can check their system under test with `trackForDeallocation`, and a dependency can be resolved from an STRV Dependency Injection `AsyncContainer` and checked.
-```swift
-await withDeallocationConfiguration({ $0.timeout = .seconds(5) }) {
- await expectDeallocation(.present) { makeProfileViewController() }
-}
-
-final class LegacyDeallocTests: XCTestCase {
- override func invokeTest() {
- withDeallocationConfiguration({ $0.severity = .warning }) {
- super.invokeTest()
- }
- }
-}
-```
-
-`.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
-// 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")
-}
-```
-
-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
-
-With the `DependencyInjection` trait, a dependency can be resolved from an `AsyncContainer`, released together with the container's shared instances and checked:
-
-```swift
-@Test func apiManager() async {
- let container = AsyncContainer()
- await container.register(type: APIManaging.self, in: .shared) { _ in APIManager() }
-
- await expectDeallocation(of: APIManaging.self, resolvedFrom: container)
-}
-```
-
-Following the dependency graph, check the simplest dependencies first, then the ones that use them.
-
-## Migrating to 4.0
-
-**Dependency Injection.** The `DeallocTestsDIFree` product is gone. Everyone uses the `DeallocTests` product and `import DeallocTests`:
-
-- If you used `DeallocTests` with STRV Dependency Injection, nothing changes. The `DependencyInjection` trait is on by default.
-- If you used `DeallocTestsDIFree`, link the `DeallocTests` product instead, replace `import DeallocTestsDIFree` with `import DeallocTests`, and turn the default trait off (see [Installation](#installation)) so STRV Dependency Injection isn't downloaded.
-
-**`DeallocTester` 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)
- }
-}
+## Documentation
-// 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() }
- }
-}
-```
+The [documentation](https://swiftpackageindex.com/strvcom/DeallocTests/documentation/dealloctests) covers the whole API and has these articles:
-| `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 |
+- [Getting Started](https://swiftpackageindex.com/strvcom/DeallocTests/documentation/dealloctests/gettingstarted): lifecycles, checking owned objects, checks in ordinary unit tests and STRV Dependency Injection
+- [Configuration and Grace Period](https://swiftpackageindex.com/strvcom/DeallocTests/documentation/dealloctests/configurationandgraceperiod): timeouts, the grace period for late releases and reporting leaks as warnings
+- [Migrating to 4.0](https://swiftpackageindex.com/strvcom/DeallocTests/documentation/dealloctests/migratingto4): replacing `DeallocTester` and `DeallocTestsDIFree`
-**`DefaultInitializable`** is removed. It wasn't related to dealloc testing.
+In Xcode, *Product › Build Documentation* builds it locally.
## Sample Apps
diff --git a/Sources/DeallocTests/Documentation.docc/ConfigurationAndGracePeriod.md b/Sources/DeallocTests/Documentation.docc/ConfigurationAndGracePeriod.md
new file mode 100644
index 0000000..e84f302
--- /dev/null
+++ b/Sources/DeallocTests/Documentation.docc/ConfigurationAndGracePeriod.md
@@ -0,0 +1,79 @@
+# Configuration and Grace Period
+
+Set the timeout, the grace period and the severity of leaks for a test, a suite or a test case class.
+
+## Overview
+
+`timeout` is the only option ``expectDeallocation(_:timeout:afterRelease:fileID:filePath:line:column:of:)``
+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``:
+
+| Option | Default | Swift Testing trait |
+|---|---|---|
+| ``DeallocationConfiguration/timeout`` | 2 sec | ``Testing/Trait/deallocationTimeout(_:)`` |
+| ``DeallocationConfiguration/gracePeriod`` | 3 sec | ``Testing/Trait/deallocationGracePeriod(_:)`` |
+| ``DeallocationConfiguration/severity`` | `.error` | ``Testing/Trait/deallocationIssues(_:)`` |
+
+A passing check ends as soon as the object is gone, so a longer timeout only slows down tests
+that find a leak.
+
+### Grace period
+
+An object still alive at the timeout is watched for the grace period. If it goes away then, the
+check reports a warning instead of failing:
+
+```
+MyApp.SearchViewModel was released after 3.2 sec, later than the 2 sec 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.
+```
+
+Only real leaks take the timeout plus the grace period to report. `.zero` turns the grace period
+off, and an object still alive at the timeout is then a leak.
+
+The check judges by when the object was actually released, not by when the test got to look, so
+a busy main thread doesn't turn a leak into a warning.
+
+### Severity
+
+With ``DeallocationIssueSeverity/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 Testing
+
+Add the traits to a suite or a test. A test's own trait wins over its suite's, and a `timeout`
+passed to the call wins over both.
+
+```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 { … }
+```
+
+### XCTest
+
+Use ``withDeallocationConfiguration(_:isolation:operation:)`` around a test's code, or the
+synchronous ``withDeallocationConfiguration(_:operation:)`` 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()
+ }
+ }
+}
+```
+
+### Which configuration applies
+
+Each object is checked with the configuration in effect where it's tracked: where
+`expectDeallocation` is called, or where `trackForDeallocation(_:)` is called. Objects tracked in
+one test can therefore have different timeouts.
diff --git a/Sources/DeallocTests/Documentation.docc/DeallocTests.md b/Sources/DeallocTests/Documentation.docc/DeallocTests.md
new file mode 100644
index 0000000..99d819a
--- /dev/null
+++ b/Sources/DeallocTests/Documentation.docc/DeallocTests.md
@@ -0,0 +1,70 @@
+# ``DeallocTests``
+
+Catch memory leaks in unit tests: check that view controllers, view models and services deallocate.
+
+## Overview
+
+DeallocTests creates an object, runs its lifecycle, releases it and checks that its `deinit` was called.
+If something still holds the object, typically a retain cycle, the test fails at your line and the
+message points at the likely cause:
+
+```swift
+import DeallocTests
+import Testing
+@testable import MyApp
+
+@MainActor
+struct LeakTests {
+ let coordinator = MainCoordinator()
+
+ @Test func profileScreen() async {
+ await expectDeallocation(.present) { coordinator.createProfileViewController() }
+ }
+
+ @Test func profileViewModel() async {
+ await expectDeallocation { ProfileViewModel(api: MockAPI()) }
+ }
+}
+```
+
+```
+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
+```
+
+It works in Swift Testing and XCTest, any class can be checked without conformances, and the app
+target doesn't change. Unlike Instruments or the Memory Graph Debugger, the checks run on every
+test run, including CI.
+
+## Topics
+
+### Essentials
+
+-
+-
+
+### Checking an object
+
+- ``expectDeallocation(_:timeout:afterRelease:fileID:filePath:line:column:of:)``
+- ``Lifecycle``
+
+### Checking objects used in ordinary unit tests
+
+- ``trackForDeallocation(_:fileID:filePath:line:column:)``
+- ``Testing/Trait/checksDeallocation``
+- ``DeallocationCheckTrait``
+
+### Configuration
+
+-
+- ``DeallocationConfiguration``
+- ``DeallocationIssueSeverity``
+- ``withDeallocationConfiguration(_:isolation:operation:)``
+- ``withDeallocationConfiguration(_:operation:)``
+- ``DeallocationConfigurationTrait``
+
+### STRV Dependency Injection
+
+- ``expectDeallocation(of:resolvedFrom:timeout:fileID:filePath:line:column:)``
diff --git a/Sources/DeallocTests/Documentation.docc/GettingStarted.md b/Sources/DeallocTests/Documentation.docc/GettingStarted.md
new file mode 100644
index 0000000..5edecce
--- /dev/null
+++ b/Sources/DeallocTests/Documentation.docc/GettingStarted.md
@@ -0,0 +1,161 @@
+# Getting Started
+
+Add DeallocTests to your test target and write your first deallocation checks.
+
+## Overview
+
+A deallocation check creates an object, uses it the way the app does, releases it and waits
+for its `deinit`. Leaks often appear only once a screen loads or appears, so the check runs the
+object through a ``Lifecycle`` first.
+
+### Add the package
+
+Add DeallocTests to the **test target** of your app:
+
+```swift
+.package(url: "https://github.com/strvcom/DeallocTests.git", .upToNextMajor(from: "4.0.0"))
+```
+
+```swift
+.testTarget(
+ name: "MyAppTests",
+ dependencies: [
+ "MyApp",
+ .product(name: "DeallocTests", package: "DeallocTests")
+ ]
+)
+```
+
+In Xcode, add the package via *File › Add Package Dependencies…* and link the `DeallocTests`
+product to your test target only.
+
+The integration with [STRV Dependency Injection](https://github.com/strvcom/ios-dependency-injection)
+is the `DependencyInjection` package trait, which is on by default. If your project doesn't use it,
+turn the default traits off, so the library isn't downloaded:
+
+```swift
+.package(url: "https://github.com/strvcom/DeallocTests.git", .upToNextMajor(from: "4.0.0"), traits: [])
+```
+
+In an Xcode project, disable the package's default traits in Xcode 26.4 or later.
+
+### Check a screen
+
+``expectDeallocation(_:timeout:afterRelease:fileID:filePath:line:column:of:)`` takes the lifecycle
+and a closure that creates the object. Don't keep any other reference to the object, or the check
+reports it as a leak.
+
+```swift
+@MainActor
+struct ProfileLeakTests {
+ let coordinator = MainCoordinator()
+
+ @Test func profileScreen() async {
+ await expectDeallocation(.present) { coordinator.createProfileViewController() }
+ }
+}
+```
+
+The same call works inside an `XCTestCase`.
+
+### Choose a lifecycle
+
+| 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 |
+
+`interaction` runs while the controller is on screen:
+
+```swift
+await expectDeallocation(.present(interaction: { controller in
+ controller.searchBar.text = "query"
+ await controller.search()
+})) {
+ coordinator.createSearchViewController()
+}
+```
+
+SwiftUI views are values, so check the object behind them, typically the view model:
+
+```swift
+await expectDeallocation(.hosting { ProfileView(viewModel: $0) }) {
+ ProfileViewModel(api: MockAPI())
+}
+```
+
+`.present` needs a test target with a host app, because modal presentation needs a window scene.
+The other lifecycles also work in package tests.
+
+### Check the objects it owns
+
+To also check objects the tested one owns, wrap them in
+``trackForDeallocation(_:fileID:filePath:line:column:)`` inside the closure. They must deallocate
+together with it:
+
+```swift
+await expectDeallocation(.present) {
+ let viewModel = trackForDeallocation(ProfileViewModel(api: MockAPI()))
+ return ProfileViewController(viewModel: viewModel)
+}
+```
+
+`afterRelease` runs after the object is released and before the check, e.g. to clear a cache that
+legitimately holds it.
+
+### Check objects in ordinary unit tests
+
+Any unit test can catch leaks of its system under test. In Swift Testing, add the
+``Testing/Trait/checksDeallocation`` trait to a test or a whole suite. In XCTest, call
+`trackForDeallocation(_:)` on the test case. Each tracked object is checked when the test ends.
+
+```swift
+// Swift Testing
+@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")
+}
+```
+
+In XCTest, keep the object in a local variable. A property of the test case lives until the test
+case is released.
+
+### Check a dependency from STRV Dependency Injection
+
+With the `DependencyInjection` trait, a dependency can be resolved from an `AsyncContainer`,
+released together with the container's shared instances and checked:
+
+```swift
+@Test func apiManager() async {
+ let container = AsyncContainer()
+ await container.register(type: APIManaging.self, in: .shared) { _ in APIManager() }
+
+ await expectDeallocation(of: APIManaging.self, resolvedFrom: container)
+}
+```
+
+### Decide what to check
+
+- **Screens.** Create a check for each screen a coordinator creates, then for the coordinator itself.
+- **Everything else.** Managers, services and view models can be checked too. Start with the
+ classes without dependencies, then check the ones that use them. A leak in a dependency then
+ fails its own test first, instead of every test that uses it.
+
+### Read the hints
+
+The hints in a leak message 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.
diff --git a/Sources/DeallocTests/Documentation.docc/Lifecycle.md b/Sources/DeallocTests/Documentation.docc/Lifecycle.md
new file mode 100644
index 0000000..f08a08a
--- /dev/null
+++ b/Sources/DeallocTests/Documentation.docc/Lifecycle.md
@@ -0,0 +1,21 @@
+# ``DeallocTests/Lifecycle``
+
+## Topics
+
+### Any object
+
+- ``none``
+- ``custom(_:)``
+- ``Interaction``
+
+### View controllers
+
+- ``loadView``
+- ``present``
+- ``present(style:interaction:)``
+- ``push``
+- ``push(interaction:)``
+
+### SwiftUI
+
+- ``hosting(interaction:_:)``
diff --git a/Sources/DeallocTests/Documentation.docc/MigratingTo4.md b/Sources/DeallocTests/Documentation.docc/MigratingTo4.md
new file mode 100644
index 0000000..044703a
--- /dev/null
+++ b/Sources/DeallocTests/Documentation.docc/MigratingTo4.md
@@ -0,0 +1,74 @@
+# Migrating to 4.0
+
+Replace `DeallocTester` with `expectDeallocation` and move from `DeallocTestsDIFree` to the package trait.
+
+## Overview
+
+DeallocTests 4.0 replaces the `DeallocTester` base class with
+``expectDeallocation(_:timeout:afterRelease:fileID:filePath:line:column:of:)``, a single call that
+works in Swift Testing and XCTest and needs no changes to the app target.
+
+### 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 traits off
+ (see ) so STRV Dependency Injection isn't downloaded.
+
+### DeallocTester
+
+`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() }
+ }
+}
+```
+
+| `DeallocTester` | `expectDeallocation` |
+|---|---|
+| View controllers are always presented | Choose `.present`, `.push`, `.loadView` or `.hosting` |
+| `registerDependencies()` + `objectCreation: { $0.resolve(...) }` | ``expectDeallocation(of:resolvedFrom:timeout:fileID:filePath:line:column:)`` with your own `AsyncContainer` |
+| `checkClasses` | ``trackForDeallocation(_:fileID:filePath:line:column:)`` inside the closure |
+| `actionBeforeCheck` | `afterRelease` |
+| `deallocationTimeout` | `timeout` |
+| `isLoggingEnabled` (`Alloc`/`Dealloc` output) | Leak messages name the leaked object and its likely causes |
+
+### DefaultInitializable
+
+`DefaultInitializable` is removed. It wasn't related to dealloc testing.
diff --git a/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift b/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift
index 1698d5c..596869d 100644
--- a/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift
+++ b/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift
@@ -25,6 +25,10 @@ import DependencyInjection
/// - type: Registered type to resolve. The resolved instance must be a class instance.
/// - container: Container with the registration
/// - timeout: How long to wait for the object to deallocate. Defaults to `DeallocationConfiguration.current.timeout`
+/// - fileID: Where a leak is reported. Defaults to the caller.
+/// - filePath: Where a leak is reported. Defaults to the caller.
+/// - line: Where a leak is reported. Defaults to the caller.
+/// - column: Where a leak is reported. Defaults to the caller.
@MainActor
public func expectDeallocation(
of type: Dependency.Type,
diff --git a/Sources/DeallocTests/Expectation/ExpectDeallocation.swift b/Sources/DeallocTests/Expectation/ExpectDeallocation.swift
index ca78a62..f1d8e61 100644
--- a/Sources/DeallocTests/Expectation/ExpectDeallocation.swift
+++ b/Sources/DeallocTests/Expectation/ExpectDeallocation.swift
@@ -22,6 +22,10 @@
/// - lifecycle: What happens with the object before it's released, e.g. `.present` for a view controller
/// - timeout: How long to wait for the object to deallocate. Defaults to `DeallocationConfiguration.current.timeout`
/// - afterRelease: Runs after the object is released and before the check, e.g. to release cached instances
+/// - fileID: Where a leak is reported. Defaults to the caller.
+/// - filePath: Where a leak is reported. Defaults to the caller.
+/// - line: Where a leak is reported. Defaults to the caller.
+/// - column: Where a leak is reported. Defaults to the caller.
/// - makeObject: Creates the tested object. Don't keep any other reference to it.
/// Objects passed to `trackForDeallocation(_:)` inside it are checked too.
@MainActor