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
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@ jobs:
- name: Run tests
run: swift test

package-ios:
name: Package tests (iOS Simulator)
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Run tests
run: |
# Use whichever iPhone simulator the runner's Xcode provides
UDID=$(xcrun simctl list devices available -j \
| jq -r '[.devices | to_entries[] | select(.key | contains("iOS")) | .value[] | select(.name | startswith("iPhone"))][0].udid')
echo "Simulator: $UDID"
xcodebuild test \
-scheme DeallocTests-Package \
-destination "id=$UDID"

sample-apps:
name: Sample apps (iOS)
runs-on: macos-15
Expand Down
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Changelog

## 3.2.0

### 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`.

## 3.1.0

### Fixed
- 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.

### Removed
- Travis CI, Danger, Carthage, jazzy and unused headers. CI runs on GitHub Actions.
110 changes: 107 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ The main version of DeallocTests uses [STRV Dependency Injection library](https:

- iOS 17.0+ / macOS 13.0+
- Swift 6.0+ / Xcode 16.0+
- XCTest
- Swift Testing or XCTest. `.checksDeallocation` needs Swift 6.1 (Xcode 16.3) or later.

## Installation

Expand All @@ -47,7 +47,7 @@ import PackageDescription
let package = Package(
name: "HelloDeallocTests",
dependencies: [
.package(url: "https://github.com/strvcom/DeallocTests.git", .upToNextMajor(from: "3.1.0"))
.package(url: "https://github.com/strvcom/DeallocTests.git", .upToNextMajor(from: "3.2.0"))
],
targets: [
.testTarget(
Expand All @@ -66,6 +66,109 @@ In Xcode, add the package via *File › Add Package Dependencies…* and link th

## Usage

### `expectDeallocation` (recommended)

`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.

```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 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 with:

```
LeakTests.swift:12: MyApp.ProfileViewController was not deallocated within 2 sec. Something still holds a strong reference to it: look for closures capturing self, delegates that aren't weak, timers, notification observers and long-running tasks or subscriptions.
```

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 |
| `.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()
}
```

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.
- `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.

### 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.

### 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:

```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.

### Scenario API: `DeallocTester`

`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`.

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

```swift
Expand Down Expand Up @@ -113,7 +216,7 @@ Each `DeallocTest` creates an object, releases it and checks that it was dealloc

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

### STRV Dependency Injection
#### 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:

Expand Down Expand Up @@ -148,6 +251,7 @@ The folder `SampleApps` contains two demo projects, `DeallocTestsAppSPM` (with S
- `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.

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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
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 */

Expand Down Expand Up @@ -58,6 +59,7 @@
ADCB58D827FD7B01009E0DEB /* DeallocTests */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = DeallocTests; path = ../..; sourceTree = "<group>"; };
ADCB58DE27FD7CC9009E0DEB /* APIManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIManager.swift; sourceTree = "<group>"; };
ADCB58E027FD7CF3009E0DEB /* DependencyGraphDeallocTester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependencyGraphDeallocTester.swift; sourceTree = "<group>"; };
AD0E0E0E2F00000100000002 /* ExpectDeallocationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpectDeallocationTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -132,6 +134,7 @@
27A08054245CBBD10037F1DB /* DeallocTestConformances.swift */,
27A08056245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift */,
ADCB58E027FD7CF3009E0DEB /* DependencyGraphDeallocTester.swift */,
AD0E0E0E2F00000100000002 /* ExpectDeallocationTests.swift */,
);
path = DeallocTestsAppSPMTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -296,6 +299,7 @@
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 */,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//
// ExpectDeallocationTests.swift
// DeallocTestsAppSPMTests
//
// Copyright © 2026 STRV. All rights reserved.
//

import DeallocTests
import DependencyInjection
import Testing
@testable import DeallocTestsAppSPM

/// The same checks as `MainCoordinatorDeallocTester` and `DependencyGraphDeallocTester`,
/// written with `expectDeallocation`. No `DeallocTestable` conformances are needed.
@Suite("Dealloc tests")
@MainActor
struct ExpectDeallocationTests {
let coordinator = MainCoordinator()

@Test func firstScreen() async {
await expectDeallocation(.present) { coordinator.createFirstViewController() }
}

/// Fails on purpose: `SecondViewController` captures `self` strongly in `viewDidLoad`
@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)
}

#if compiler(>=6.1)
@Test(.checksDeallocation) func trackedController() {
let controller = trackForDeallocation(coordinator.createThirdViewController())
controller.loadViewIfNeeded()
}
#endif
}
64 changes: 64 additions & 0 deletions Sources/DeallocTests/Expectation/DeallocationTracker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//
// DeallocationTracker.swift
// DeallocTests
//
// Copyright © 2026 STRV. All rights reserved.
//

import Foundation

/// Keeps weak references to objects and checks that all of them deallocate.
/// No conformance or associated objects are needed, so any class instance can be tracked.
@MainActor
final class DeallocationTracker {
private struct TrackedObject {
weak var object: AnyObject?
let typeName: String
let location: TestSourceLocation
}

/// Tracker installed by the `.checksDeallocation` Swift Testing trait
@TaskLocal static var current: DeallocationTracker?

private var trackedObjects = [TrackedObject]()

var isEmpty: Bool {
trackedObjects.isEmpty
}

func track(_ object: AnyObject, at location: TestSourceLocation) {
trackedObjects.append(
TrackedObject(object: object, typeName: String(reflecting: type(of: object)), location: location)
)
}

/// Waits until all tracked objects deallocate and reports the ones that didn't within the timeout
func verifyDeallocation(timeout: Duration) async {
let clock = ContinuousClock()
let deadline = clock.now + timeout

// Polling also lets the run loop drain autorelease pools and finish UIKit transitions
while trackedObjects.contains(where: { $0.object != nil }), clock.now < deadline {
do {
try await Task.sleep(for: .milliseconds(10))
} catch {
break
}
}

for trackedObject in trackedObjects where trackedObject.object != nil {
reportIssue(
Self.leakMessage(typeName: trackedObject.typeName, timeout: timeout),
at: trackedObject.location
)
}

trackedObjects.removeAll()
}

static func leakMessage(typeName: String, timeout: Duration) -> String {
"\(typeName) was not deallocated within \(timeout.formatted(.units(allowed: [.seconds, .milliseconds]))). "
+ "Something still holds a strong reference to it: look for closures capturing self, "
+ "delegates that aren't weak, timers, notification observers and long-running tasks or subscriptions."
}
}
Loading
Loading