Skip to content
Open
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
14 changes: 10 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,17 @@ concurrency:

jobs:
package:
name: Package tests (macOS)
name: Package tests (macOS, ${{ matrix.traits.name }})
runs-on: macos-15
strategy:
matrix:
traits:
- { name: "DependencyInjection (default)", flags: "" }
- { name: "without DependencyInjection", flags: "--disable-default-traits" }
steps:
- uses: actions/checkout@v4
- name: Run tests
run: swift test
run: swift test ${{ matrix.traits.flags }}

package-ios:
name: Package tests (iOS Simulator)
Expand All @@ -30,12 +35,13 @@ jobs:
| jq -r '[.devices | to_entries[] | select(.key | contains("iOS")) | .value[] | select(.name | startswith("iPhone"))][0].udid')
echo "Simulator: $UDID"
xcodebuild test \
-scheme DeallocTests-Package \
-scheme DeallocTests \
-destination "id=$UDID"

sample-apps:
name: Sample apps (iOS)
runs-on: macos-15
# Package traits in an Xcode project need Xcode 26.4 or later
runs-on: macos-26
strategy:
matrix:
sample: [DeallocTestsAppSPM, DeallocTestsAppDIFreeSPM]
Expand Down
50 changes: 26 additions & 24 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,39 +1,41 @@
# Changelog

## 3.3.0
## 4.0.0

### Added
- Leak messages list likely causes found in the leaked object's stored properties: closures, `Task`s, Combine subscriptions, timers and reference cycles through properties.
- `.hosting { … }` lifecycle that shows a SwiftUI view built from the object in a test window (UIKit and AppKit), so `onAppear` and `.task` run.
- `trackForDeallocation(_:)` inside an `expectDeallocation` closure checks the object together with the tested one, in Swift Testing and XCTest.

## 3.2.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, needs no `DeallocTestable` conformance and reports leaks at the line of the test.
- Lifecycles: `.none`, `.loadView`, `.present`, `.push` (with an optional interaction while on screen) and `.custom`.
- `trackForDeallocation(_:)` for checking objects at the end of ordinary unit tests: an `XCTestCase` method, and the `.checksDeallocation` Swift Testing trait (Swift 6.1+).
- `expectDeallocation(of:resolvedFrom:)` for dependencies resolved from an `AsyncContainer` (`DeallocTests` product only).
- Swift Testing sample tests in `DeallocTestsAppSPM`.
- 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 show readable names for private and local types, without Swift's `(unknown context at $…)`.
- 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.
- `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.
- `DeallocTestable` no longer requires `Sendable`.
- `Alloc`/`Dealloc` logging is off by default (`DeallocTester.isLoggingEnabled`).

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

### Fixed
### 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.
- The dependency URL uses https, so the package resolves without SSH access to GitHub.

### Changed
- Polling with `deallocationTimeout` (2 s) replaces the fixed delays.
- The presenting controller is created automatically; the test window is cleaned up in `tearDown`.
- `DeallocTestable` no longer requires `Sendable`.
- `Alloc`/`Dealloc` logging is off by default (`DeallocTester.isLoggingEnabled`).
- `setUp()` is `open`.

### Deprecated
- `DefaultInitializable`, to be removed in 4.0.
- The presenting controller is created automatically; the test window is cleaned up in `tearDown`. `setUp()` is `open`.
- The dependency URL uses https, so the package resolves without SSH access to GitHub.

### Removed
- Travis CI, Danger, Carthage, jazzy and unused headers. CI runs on GitHub Actions.
- Travis CI, Danger, Carthage, jazzy and unused headers.
15 changes: 0 additions & 15 deletions Package.resolved

This file was deleted.

47 changes: 27 additions & 20 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// swift-tools-version:6.0.0
// swift-tools-version:6.1
//
// DeallocTests.swift
// Package.swift
// DeallocTests
//
// Created by Daniel Cech on 01/04/19.
Expand All @@ -20,34 +20,41 @@ let package = Package(
name: "DeallocTests",
targets: ["DeallocTests"]
),
.library(
name: "DeallocTestsDIFree",
targets: ["DeallocTestsDIFree"]
],
traits: [
.trait(
name: "DependencyInjection",
description: "Integration with STRV Dependency Injection: expectDeallocation(of:resolvedFrom:) and the AsyncContainer in DeallocTester"
),
// Most projects use STRV Dependency Injection. Projects that don't can opt out with `traits: []`.
.default(enabledTraits: ["DependencyInjection"]),
],
dependencies: [
.package(url: "https://github.com/strvcom/ios-dependency-injection.git", .upToNextMajor(from: "2.0.0"))
// 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: [
.target(
name: "DeallocTests",
dependencies: [.product(name: "DependencyInjection", package: "ios-dependency-injection")],
path: "Sources/DeallocTests",
swiftSettings: [.define("DEALLOC_TESTS_DI")]
),
.target(
name: "DeallocTestsDIFree",
path: "Sources/DeallocTestsDIFree"
dependencies: [
.product(
name: "DependencyInjection",
package: "ios-dependency-injection",
condition: .when(traits: ["DependencyInjection"])
)
]
),
.testTarget(
name: "DeallocTestsTests",
dependencies: ["DeallocTests"],
path: "Tests/DeallocTestsTests"
),
.testTarget(
name: "DeallocTestsDIFreeTests",
dependencies: ["DeallocTestsDIFree"],
path: "Tests/DeallocTestsDIFreeTests"
dependencies: [
"DeallocTests",
.product(
name: "DependencyInjection",
package: "ios-dependency-injection",
condition: .when(traits: ["DependencyInjection"])
)
]
),
],
swiftLanguageModes: [.v6]
Expand Down
108 changes: 90 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,42 +27,53 @@ 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 a great benefit, but DeallocTests also work without it. If you don't use STRV Dependency Injection in your app, use the `DeallocTestsDIFree` product instead.
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 17.0+ / macOS 13.0+
- Swift 6.0+ / Xcode 16.0+
- Swift Testing or XCTest. `.checksDeallocation` needs Swift 6.1 (Xcode 16.3) or later.
- 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

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:6.0
// swift-tools-version:6.1

import PackageDescription

let package = Package(
name: "HelloDeallocTests",
dependencies: [
.package(url: "https://github.com/strvcom/DeallocTests.git", .upToNextMajor(from: "3.3.0"))
.package(url: "https://github.com/strvcom/DeallocTests.git", .upToNextMajor(from: "4.0.0"))
],
targets: [
.testTarget(
name: "HelloDeallocTestsTests",
dependencies: [
"HelloDeallocTests",
// or "DeallocTestsDIFree" if you don't use STRV Dependency Injection
.product(name: "DeallocTests", package: "DeallocTests")
]
)
]
)
```

In Xcode, add the package via *File › Add Package Dependencies…* and link the `DeallocTests` (or `DeallocTestsDIFree`) product to your test target only.
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

Expand Down Expand Up @@ -99,6 +110,7 @@ The same calls work inside an `XCTestCase`. A leak fails at the line of the test
LeakTests.swift:12: MyApp.ProfileViewController was not deallocated within 2 sec. 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
```

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.
Expand Down Expand Up @@ -174,7 +186,7 @@ In XCTest, keep the object in a local variable. A property of the test case live

### STRV Dependency Injection

With the `DeallocTests` product, a dependency can be resolved from an `AsyncContainer`, released together with the container's shared instances and checked:
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 {
Expand All @@ -187,9 +199,12 @@ With the `DeallocTests` product, a dependency can be resolved from an `AsyncCont

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

### Scenario API: `DeallocTester`
### 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).

`DeallocTester` is the original XCTest API. It goes through a list of objects one by one, typically all screens of a coordinator and then the coordinator itself. It is still supported, but new tests should use `expectDeallocation`.
<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:

Expand Down Expand Up @@ -240,7 +255,7 @@ Set `DeallocTester.isLoggingEnabled = true` to print `Alloc`/`Dealloc` messages

#### Dependency Injection in `DeallocTester`

With the `DeallocTests` product, `objectCreation` receives an `AsyncContainer`. Before every step the container is cleaned and `registerDependencies()` is called. Shared instances are released before the check:
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 {
Expand All @@ -264,21 +279,78 @@ final class DependencyGraphDeallocTester: DeallocTester {
}
```

With `DeallocTestsDIFree`, `objectCreation` takes no parameter: `DeallocTest(objectCreation: { MyObject() })`.
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:

```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:)` with your own `AsyncContainer` |
| `checkClasses` | `trackForDeallocation(_:)` inside the closure |
| `actionBeforeCheck` | `afterRelease` |
| `deallocationTimeout` | `timeout` |

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

## Sample Apps

The folder `SampleApps` contains two demo projects, `DeallocTestsAppSPM` (with STRV Dependency Injection) and `DeallocTestsAppDIFreeSPM`. The application itself is very simple: there are just three screens in the navigation stack, all handled by `MainCoordinator`.
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`.

- `DeallocTestConformances.swift` adds the `DeallocTestable` conformances to all tested classes.
- `MainCoordinatorDeallocTester.swift` defines the testing scenario for `MainCoordinator`: the three view controllers one by one, then the coordinator itself.
- `DependencyGraphDeallocTester.swift` (DI sample only) checks a service resolved from the container.
- `ExpectDeallocationTests.swift` (DI sample only) does the same checks with `expectDeallocation` and Swift Testing.
- `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.

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:

```
DeallocTester.swift:233: error: -[DeallocTestsAppSPMTests.MainCoordinatorDeallocTester test_mainCoordinatorDealloc] : failed - Failed: dealloc test #1 failed on classes: [DeallocTestsAppSPM.SecondViewController] (1 tracked instance(s) still alive)
MainCoordinatorDeallocTester.swift:25: error: -[DeallocTestsAppSPMTests.MainCoordinatorDeallocTester test_secondScreen] : failed - DeallocTestsAppSPM.SecondViewController was not deallocated within 2 sec. 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, the retain cycle disappears and the test will succeed.
Expand Down
Loading
Loading