Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,6 @@ jobs:
- { name: "without DependencyInjection", flags: "--disable-default-traits" }
steps:
- uses: actions/checkout@v4
- name: Check DeallocTestsCore rules
# Core is shared with DeallocWatcher, which apps link: no test frameworks, no classes or actors
run: |
! grep -rnE "^\s*(@testable )?import (XCTest|Testing)\b" Sources/DeallocTestsCore
! grep -rnE "^\s*(public |package |internal |private |fileprivate |final |open )*(class|actor) [A-Z]" Sources/DeallocTestsCore
- name: Run tests
run: swift test ${{ matrix.traits.flags }}

Expand Down
22 changes: 7 additions & 15 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@
See "Migrating to 4.0" in the README.

### Added
- `expectDeallocation(_:timeout:afterRelease:of:)`: creates an object, runs its lifecycle, releases it and checks that it deallocates. Works in Swift Testing and XCTest, needs no `DeallocTestable` conformance and reports leaks at the line of the test.
- `expectDeallocation(_:timeout:afterRelease:of:)`: creates an object, runs its lifecycle, releases it and checks that it deallocates. Works in Swift Testing and XCTest, checks any class without conformances and reports leaks at the line of the test.
- Lifecycles: `.none`, `.loadView`, `.present`, `.push` (with an optional interaction while on screen), `.hosting` for SwiftUI views (UIKit and AppKit) and `.custom`.
- Leak messages list likely causes found in the leaked object's stored properties: closures, `Task`s, Combine subscriptions, timers and reference cycles through properties.
- Leak messages list likely causes found in the leaked object's stored properties: closures, `Task`s, Combine subscriptions, timers and reference cycles through properties, including a property that holds the object itself. Properties that are `nil` or empty are skipped.
- Leak messages show readable names for private and local types, without Swift's `(unknown context at $…)`.
- Durations in messages read the same in every locale ("400 ms", "3.2 sec").
- With hints, the leak message also reminds that the object may be held from outside (a parent's list of children, a cache, a singleton), which hints can't see.
- Hints show `@Observable` properties by their declared names, without the macro's `_` prefix and registrar.
- UIKit lifecycles wait up to 10 s for a screen to appear, be dismissed or popped, instead of 2 s, so they stay reliable on a loaded simulator.
- `trackForDeallocation(_:)`: an `XCTestCase` method and the `.checksDeallocation` Swift Testing trait for checking objects at the end of ordinary unit tests. Inside an `expectDeallocation` closure, it checks the object together with the tested one.
- `DeallocationConfiguration` with the `.deallocationTimeout(_:)` and `.deallocationIssues(_:)` Swift Testing traits for a test or a whole suite, and `withDeallocationConfiguration(_:operation:)` for XCTest. Leaks can be reported as warnings that don't fail the test.
- `DeallocationConfiguration` with the `.deallocationTimeout(_:)`, `.deallocationGracePeriod(_:)` and `.deallocationIssues(_:)` Swift Testing traits for a test or a whole suite, and `withDeallocationConfiguration(_:operation:)` for XCTest (also around `invokeTest()` for a whole class). Leaks can be reported as warnings that don't fail the test.
- Grace period: an object still alive at the timeout is watched a little longer (3 s by default). If it goes away then, it's reported as a warning, "released after 3.2 sec … bounded retention, not a leak", instead of a failure. It's skipped when leaks are reported as warnings anyway.
- `expectDeallocation(of:resolvedFrom:)` for dependencies resolved from an `AsyncContainer`. A dependency that turns out to be a value type is reported with its concrete type, since it can't leak.
- Swift Testing and XCTest tests of the library on macOS and the iOS simulator, and GitHub Actions CI.

Expand All @@ -23,19 +25,9 @@ See "Migrating to 4.0" in the README.
- The `DeallocTestsDIFree` product is removed. Use the `DeallocTests` product and `import DeallocTests`.
- Swift 6.1 (Xcode 16.3) is required. Turning the trait off from an Xcode project needs Xcode 26.4.
- `DefaultInitializable` is removed.
- `DeallocTestable` no longer requires `Sendable`.
- `Alloc`/`Dealloc` logging is off by default (`DeallocTester.isLoggingEnabled`).
- `DeallocTester`, `DeallocTest`, `DeallocTestable` and `ClassNameIdentifiable` are removed. Each `DeallocTest` becomes one `expectDeallocation` call; see "Migrating to 4.0" in the README.

### Deprecated
- `DeallocTester`, `DeallocTest`, `DeallocTestable` and `ClassNameIdentifiable`. Use `expectDeallocation`. They will be removed in 5.0.

### Fixed (`DeallocTester`)
- Dealloc tests no longer hang on macOS.
- A `nil` or non-`DeallocTestable` object no longer crashes or hangs the test.
- Leaks are detected per instance instead of per class.
- Thread-safe dealloc tracking; no more associated-object key warnings.
- Polling with `deallocationTimeout` (2 s) replaces the fixed delays.
- The presenting controller is created automatically; the test window is cleaned up in `tearDown`. `setUp()` is `open`.
### Fixed
- The dependency URL uses https, so the package resolves without SSH access to GitHub.

### Removed
Expand Down
15 changes: 1 addition & 14 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,37 +24,24 @@ let package = Package(
traits: [
.trait(
name: "DependencyInjection",
description: "Integration with STRV Dependency Injection: expectDeallocation(of:resolvedFrom:) and the AsyncContainer in DeallocTester"
description: "Integration with STRV Dependency Injection: expectDeallocation(of:resolvedFrom:)"
),
// Most projects use STRV Dependency Injection. Projects that don't can opt out with `traits: []`.
.default(enabledTraits: ["DependencyInjection"]),
],
dependencies: [
// DeallocTests only uses AsyncContainer's init, clean(), releaseSharedInstances() and
// resolve(type:), which DI 1.x and 2.x both have.
.package(url: "https://github.com/strvcom/ios-dependency-injection.git", "1.0.4" ..< "3.0.0")
],
targets: [
// Shared by DeallocTests and the upcoming DeallocWatcher: no XCTest, no Swift Testing,
// no classes or actors (it may be linked into both an app and its test bundle)
.target(
name: "DeallocTestsCore"
),
.target(
name: "DeallocTests",
dependencies: [
"DeallocTestsCore",
.product(
name: "DependencyInjection",
package: "ios-dependency-injection",
condition: .when(traits: ["DependencyInjection"])
)
]
),
.testTarget(
name: "DeallocTestsCoreTests",
dependencies: ["DeallocTestsCore"]
),
.testTarget(
name: "DeallocTestsTests",
dependencies: [
Expand Down
126 changes: 30 additions & 96 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,9 @@ traits = (

## Usage

### `expectDeallocation` (recommended)
### `expectDeallocation`

`expectDeallocation` creates an object, runs its lifecycle, releases it and checks that it deallocates. It works in **Swift Testing and XCTest**, any class can be checked without a `DeallocTestable` conformance, and a leak is reported at the line of your test.
`expectDeallocation` creates an object, runs its lifecycle, releases it and checks that it deallocates. It works in **Swift Testing and XCTest**, any class can be checked without conformances or changes to the app target, and a leak is reported at the line of your test.

```swift
import DeallocTests
Expand Down Expand Up @@ -107,7 +107,7 @@ struct LeakTests {
The same calls work inside an `XCTestCase`. A leak fails at the line of the test, and the message points at the likely cause:

```
LeakTests.swift:12: MyApp.ProfileViewController was not deallocated within 2 sec. Possible causes:
LeakTests.swift:12: MyApp.ProfileViewController was not deallocated within 2 sec. It was watched for another 3 sec after that. Possible causes:
• `onUpdate` is a closure. Make sure it captures self weakly
• `self.viewModel.owner` refers back to the object. That's a retain cycle unless one of the references is weak
• Or something outside still holds it: a parent's list of children, a cache or a singleton
Expand Down Expand Up @@ -156,11 +156,21 @@ await expectDeallocation(.present) {

Other parameters:

- `timeout` sets how long to wait for the deallocation (2 seconds by default). The check passes as soon as the object is gone.
- `timeout` sets how long to wait for this object to deallocate (2 seconds by default). The check passes as soon as the object is gone.
- `afterRelease` runs after the object is released and before the check, e.g. to clear a cache that legitimately holds it.

#### Configuring a test, a suite or a test case class

#### Configuring a suite
`timeout` is the only option a call takes, because it describes the object: some screens take longer to go away. Everything else is a policy for a whole test or suite, set through `DeallocationConfiguration`:

Instead of passing the same values to every call, configure a test or a whole suite with traits:
| Option | Default | Swift Testing trait |
|---|---|---|
| `timeout` | 2 sec | `.deallocationTimeout(_:)` |
| `gracePeriod` | 3 sec | `.deallocationGracePeriod(_:)` |
| `severity` | `.error` | `.deallocationIssues(_:)` |

- An object still alive at the timeout is watched for the **grace period**. If it goes away then, the check reports a warning, "released after 3.2 sec … bounded retention, not a leak", instead of failing. Passing checks never wait for it; only real leaks take the timeout plus the grace period to report. `.zero` turns it off.
- With `severity` `.warning`, leaks are reported as warnings that don't fail the test, e.g. while adopting dealloc tests in an existing project. The grace period is skipped then, since it can't change the outcome.

```swift
@Suite(.deallocationTimeout(.seconds(5)))
Expand All @@ -171,14 +181,21 @@ struct ScreenDeallocTests { … }
struct LegacyDeallocTests { … }
```

A test's own trait wins over its suite's, and a value passed to the call wins over both. In XCTest, use `withDeallocationConfiguration`:
A test's own trait wins over its suite's, and a `timeout` passed to the call wins over both. In XCTest, use `withDeallocationConfiguration` around a test's code, or around `invokeTest()` for a whole test case class:

```swift
await withDeallocationConfiguration({ $0.timeout = .seconds(5) }) {
await expectDeallocation(.present) { makeProfileViewController() }
}

final class LegacyDeallocTests: XCTestCase {
override func invokeTest() {
withDeallocationConfiguration({ $0.severity = .warning }) {
super.invokeTest()
}
}
}
```
- `afterRelease` runs after the object is released and before the check, e.g. to clear a cache that legitimately holds it.

`.present` needs a test target with a host app, because modal presentation needs a window scene. The other lifecycles also work in package tests.

Expand All @@ -203,7 +220,7 @@ func test_loadsProfile() async {
}
```

In XCTest, keep the object in a local variable. A property of the test case lives until the test case is released.
In XCTest, keep the object in a local variable. A property of the test case lives until the test case is released. The check uses the configuration in effect where `trackForDeallocation` is called.

### STRV Dependency Injection

Expand All @@ -220,98 +237,14 @@ With the `DependencyInjection` trait, a dependency can be resolved from an `Asyn

Following the dependency graph, check the simplest dependencies first, then the ones that use them.

### Deprecated: `DeallocTester`

`DeallocTester`, `DeallocTest` and `DeallocTestable` are deprecated in 4.0 and will be removed in 5.0. They still work. See [Migrating to 4.0](#migrating-to-40).

<details>
<summary>Documentation of the deprecated API</summary>

1. Conform the tested classes to `DeallocTestable` in your test target. No changes to the main target are needed:

```swift
import DeallocTests
@testable import MyApp

extension MainCoordinator: @retroactive DeallocTestable {}
extension FirstViewController: @retroactive DeallocTestable {}
```

2. Subclass `DeallocTester` and describe the scenario:

```swift
import DeallocTests
@testable import MyApp

final class MainCoordinatorDeallocTester: DeallocTester {
@MainActor
func test_mainCoordinatorDealloc() async {
let mainCoordinator = MainCoordinator()
let expectation = expectation(description: "dealloc test")

await performDeallocTest(
deallocTests: [
DeallocTest(objectCreation: { [mainCoordinator] _ in mainCoordinator.createFirstViewController() }),
DeallocTest(objectCreation: { [mainCoordinator] _ in mainCoordinator.createSecondViewController() }),
DeallocTest(objectCreation: { _ in MainCoordinator() })
],
expectation: expectation
)

await fulfillment(of: [expectation], timeout: 60)
}
}
```

Each `DeallocTest` creates an object, releases it and checks that it was deallocated:

- A `UIViewController` is presented full screen and dismissed first, so its whole lifecycle runs. The presenting controller is created automatically. You can still call `showPresentingController()` and assign `presentingController` yourself.
- Any other object is released right away.
- After release, DeallocTests waits up to `deallocationTimeout` (2 seconds by default) for every tracked instance to deallocate. Leaks are detected per instance, so a second leaked instance of the same class is caught.
- `checkClasses` restricts the check to the listed classes. Each listed class must have been tracked (it is `DeallocTestable` and `initializeDeallocTestSupport()` was called on it).
- `actionBeforeCheck` runs after the object is released and before the check.
- A failing step is reported with `XCTFail` and the scenario continues with the next step. The expectation is always fulfilled.

Set `DeallocTester.isLoggingEnabled = true` to print `Alloc`/`Dealloc` messages for every tracked object.

#### Dependency Injection in `DeallocTester`

With the `DependencyInjection` trait, `objectCreation` receives an `AsyncContainer`. Before every step the container is cleaned and `registerDependencies()` is called. Shared instances are released before the check:

```swift
final class DependencyGraphDeallocTester: DeallocTester {
override func registerDependencies() async {
await container.register(type: APIManaging.self, in: .shared) { _ in APIManager() }
}

@MainActor
func test_dependencyGraphDealloc() async {
let expectation = expectation(description: "dealloc test")

await performDeallocTest(
deallocTests: [
DeallocTest(objectCreation: { await $0.resolve(type: APIManaging.self) as AnyObject })
],
expectation: expectation
)

await fulfillment(of: [expectation], timeout: 60)
}
}
```

Without the trait, `objectCreation` takes no parameter: `DeallocTest(objectCreation: { MyObject() })`.

</details>

## Migrating to 4.0

**Dependency Injection.** The `DeallocTestsDIFree` product is gone. Everyone uses the `DeallocTests` product and `import DeallocTests`:

- If you used `DeallocTests` with STRV Dependency Injection, nothing changes. The `DependencyInjection` trait is on by default.
- If you used `DeallocTestsDIFree`, link the `DeallocTests` product instead, replace `import DeallocTestsDIFree` with `import DeallocTests`, and turn the default trait off (see [Installation](#installation)) so STRV Dependency Injection isn't downloaded.

**`DeallocTester`.** Existing tests keep working but produce deprecation warnings. Each `DeallocTest` becomes one `expectDeallocation` call, and the `DeallocTestable` conformances can be deleted:
**`DeallocTester` is removed**, together with `DeallocTest`, `DeallocTestable` and `ClassNameIdentifiable`. Each `DeallocTest` becomes one `expectDeallocation` call, and the `DeallocTestable` conformances are deleted:

```swift
// Before
Expand Down Expand Up @@ -355,6 +288,7 @@ final class MainCoordinatorDeallocTests: XCTestCase {
| `checkClasses` | `trackForDeallocation(_:)` inside the closure |
| `actionBeforeCheck` | `afterRelease` |
| `deallocationTimeout` | `timeout` |
| `isLoggingEnabled` (`Alloc`/`Dealloc` output) | Leak messages name the leaked object and its likely causes |

**`DefaultInitializable`** is removed. It wasn't related to dealloc testing.

Expand All @@ -364,12 +298,12 @@ The folder `SampleApps` contains two demo projects. The application itself is ve

- `DeallocTestsAppDIFreeSPM` checks the screens and the coordinator with `expectDeallocation` in **XCTest** (`MainCoordinatorDeallocTester.swift`).
- `DeallocTestsAppDIFreeSPM` turns the `DependencyInjection` trait off in its Xcode project, so STRV Dependency Injection isn't downloaded.
- `DeallocTestsAppSPM` uses the default `DependencyInjection` trait. `ExpectDeallocationTests.swift` does the checks with `expectDeallocation` in **Swift Testing**, including a service resolved from an `AsyncContainer`. The other test files show the deprecated `DeallocTester` API.
- `DeallocTestsAppSPM` uses the default `DependencyInjection` trait. `ExpectDeallocationTests.swift` does the checks with `expectDeallocation` in **Swift Testing**, including a service resolved from an `AsyncContainer`.

The sample app intentionally contains a memory leak in `SecondViewController.swift`. This class contains a closure with a strong reference to `self`. The test fails with:

```
MainCoordinatorDeallocTester.swift:25: error: -[DeallocTestsAppSPMTests.MainCoordinatorDeallocTester test_secondScreen] : failed - DeallocTestsAppSPM.SecondViewController was not deallocated within 2 sec. Possible causes:
MainCoordinatorDeallocTester.swift:25: error: -[DeallocTestsAppSPMTests.MainCoordinatorDeallocTester test_secondScreen] : failed - DeallocTestsAppSPM.SecondViewController was not deallocated within 2 sec. It was watched for another 3 sec after that. Possible causes:
• `someClosure` is a closure. Make sure it captures self weakly
• Or something outside still holds it: a parent's list of children, a cache or a singleton
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,13 @@ import DeallocTests
import XCTest
@testable import DeallocTestsAppSPM

/// Dealloc tests with XCTest. No `DeallocTestable` conformances are needed.
final class MainCoordinatorDeallocTester: XCTestCase {
@MainActor
func test_firstScreen() async {
let coordinator = MainCoordinator()
await expectDeallocation(.present) { coordinator.createFirstViewController() }
}

/// Fails on purpose: `SecondViewController` captures `self` strongly in `viewDidLoad`
@MainActor
func test_secondScreen() async {
let coordinator = MainCoordinator()
Expand Down
Loading
Loading