From e0ba8cdc12ae7a54ed20b4c17174395e7412f5b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Fri, 2 Oct 2026 13:17:34 +0200 Subject: [PATCH 1/2] feat: expectDeallocation API for Swift Testing and XCTest (3.2) - expectDeallocation(_:timeout:afterRelease:of:) checks any class instance with weak references, no DeallocTestable conformance needed - Leaks are reported at the test's call site in both frameworks - Lifecycles: none, loadView, present, push, custom; interaction while on screen - trackForDeallocation for XCTestCase and the .checksDeallocation trait - expectDeallocation(of:resolvedFrom:) for AsyncContainer dependencies - Tests for macOS and the iOS simulator, Swift Testing sample in the DI sample app - iOS package tests in CI, README and CHANGELOG Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 11 + CHANGELOG.md | 32 +++ README.md | 110 +++++++- .../project.pbxproj | 4 + .../ExpectDeallocationTests.swift | 54 ++++ .../Expectation/DeallocationTracker.swift | 64 +++++ ...pectDeallocation+DependencyInjection.swift | 70 +++++ .../Expectation/ExpectDeallocation.swift | 61 +++++ .../Expectation/IssueReporting.swift | 47 ++++ .../DeallocTests/Expectation/Lifecycle.swift | 241 ++++++++++++++++++ .../Expectation/TrackForDeallocation.swift | 125 +++++++++ .../ExpectDeallocationDIFreeTests.swift | 27 ++ .../ExpectDeallocationTests.swift | 227 +++++++++++++++++ .../TrackForDeallocationXCTests.swift | 36 +++ .../UIKitLifecycleTests.swift | 103 ++++++++ 15 files changed, 1209 insertions(+), 3 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/ExpectDeallocationTests.swift create mode 100644 Sources/DeallocTests/Expectation/DeallocationTracker.swift create mode 100644 Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift create mode 100644 Sources/DeallocTests/Expectation/ExpectDeallocation.swift create mode 100644 Sources/DeallocTests/Expectation/IssueReporting.swift create mode 100644 Sources/DeallocTests/Expectation/Lifecycle.swift create mode 100644 Sources/DeallocTests/Expectation/TrackForDeallocation.swift create mode 100644 Tests/DeallocTestsDIFreeTests/ExpectDeallocationDIFreeTests.swift create mode 100644 Tests/DeallocTestsTests/ExpectDeallocationTests.swift create mode 100644 Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift create mode 100644 Tests/DeallocTestsTests/UIKitLifecycleTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e1470e..cc66df9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,17 @@ 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: | + xcodebuild test \ + -scheme DeallocTests-Package \ + -destination 'platform=iOS Simulator,name=iPhone 16' + sample-apps: name: Sample apps (iOS) runs-on: macos-15 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1942f63 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index ace60a4..28a713d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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( @@ -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 @@ -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: @@ -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: diff --git a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj index 6f90aed..a4b3e8a 100644 --- a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj +++ b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPM.xcodeproj/project.pbxproj @@ -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 */ @@ -58,6 +59,7 @@ ADCB58D827FD7B01009E0DEB /* DeallocTests */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = DeallocTests; path = ../..; sourceTree = ""; }; ADCB58DE27FD7CC9009E0DEB /* APIManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIManager.swift; sourceTree = ""; }; ADCB58E027FD7CF3009E0DEB /* DependencyGraphDeallocTester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependencyGraphDeallocTester.swift; sourceTree = ""; }; + AD0E0E0E2F00000100000002 /* ExpectDeallocationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpectDeallocationTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -132,6 +134,7 @@ 27A08054245CBBD10037F1DB /* DeallocTestConformances.swift */, 27A08056245CBBFA0037F1DB /* MainCoordinatorDeallocTester.swift */, ADCB58E027FD7CF3009E0DEB /* DependencyGraphDeallocTester.swift */, + AD0E0E0E2F00000100000002 /* ExpectDeallocationTests.swift */, ); path = DeallocTestsAppSPMTests; sourceTree = ""; @@ -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 */, ); diff --git a/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/ExpectDeallocationTests.swift b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/ExpectDeallocationTests.swift new file mode 100644 index 0000000..0a40ada --- /dev/null +++ b/SampleApps/DeallocTestsAppSPM/DeallocTestsAppSPMTests/ExpectDeallocationTests.swift @@ -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 +} diff --git a/Sources/DeallocTests/Expectation/DeallocationTracker.swift b/Sources/DeallocTests/Expectation/DeallocationTracker.swift new file mode 100644 index 0000000..57dbbc8 --- /dev/null +++ b/Sources/DeallocTests/Expectation/DeallocationTracker.swift @@ -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." + } +} diff --git a/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift b/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift new file mode 100644 index 0000000..255ed5f --- /dev/null +++ b/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift @@ -0,0 +1,70 @@ +// +// ExpectDeallocation+DependencyInjection.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +#if DEALLOC_TESTS_DI + +import DependencyInjection +import Foundation + +/// Resolves a dependency, releases it together with the container's shared instances +/// and checks that it deallocates. +/// +/// ```swift +/// @Test func apiManagerDoesNotLeak() async { +/// let container = AsyncContainer() +/// await container.register(type: APIManaging.self, in: .shared) { _ in APIManager() } +/// +/// await expectDeallocation(of: APIManaging.self, resolvedFrom: container) +/// } +/// ``` +/// +/// - Parameters: +/// - 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 +@MainActor +public func expectDeallocation( + of type: Dependency.Type, + resolvedFrom container: AsyncContainer, + timeout: Duration = .seconds(2), + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column +) async { + let location = TestSourceLocation(fileID: fileID, filePath: filePath, line: line, column: column) + let tracker = DeallocationTracker() + + guard await resolveAndTrack(type, from: container, tracker: tracker, location: location) else { + return + } + + await container.releaseSharedInstances() + await tracker.verifyDeallocation(timeout: timeout) +} + +/// The dependency only lives inside this call, so it's released when it returns +@MainActor +private func resolveAndTrack( + _ type: Dependency.Type, + from container: AsyncContainer, + tracker: DeallocationTracker, + location: TestSourceLocation +) async -> Bool { + let dependency = await container.resolve(type: type) + + // A value type would be boxed into a temporary object that deallocates immediately + guard Mirror(reflecting: dependency).displayStyle == .class else { + reportIssue("\(Swift.type(of: dependency)) resolved for \(type) is not a class instance", at: location) + return false + } + + tracker.track(dependency as AnyObject, at: location) + return true +} + +#endif diff --git a/Sources/DeallocTests/Expectation/ExpectDeallocation.swift b/Sources/DeallocTests/Expectation/ExpectDeallocation.swift new file mode 100644 index 0000000..7f659a7 --- /dev/null +++ b/Sources/DeallocTests/Expectation/ExpectDeallocation.swift @@ -0,0 +1,61 @@ +// +// ExpectDeallocation.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import Foundation + +/// Creates an object, runs its lifecycle, releases it and checks that it deallocates. +/// +/// Works in Swift Testing and XCTest. A leak is reported at the line that calls this function. +/// +/// ```swift +/// @Test func secondScreenDoesNotLeak() async { +/// await expectDeallocation(.present) { +/// coordinator.createSecondViewController() +/// } +/// } +/// ``` +/// +/// - Parameters: +/// - 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 +/// - afterRelease: Runs after the object is released and before the check, e.g. to release cached instances +/// - makeObject: Creates the tested object. Don't keep any other reference to it. +@MainActor +public func expectDeallocation( + _ lifecycle: Lifecycle = .none, + timeout: Duration = .seconds(2), + afterRelease: @MainActor () async -> Void = {}, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column, + of makeObject: @MainActor () async throws -> Object +) async rethrows { + let location = TestSourceLocation(fileID: fileID, filePath: filePath, line: line, column: column) + let tracker = DeallocationTracker() + + guard try await createAndRun(makeObject, lifecycle: lifecycle, tracker: tracker, location: location) else { + return + } + + await afterRelease() + await tracker.verifyDeallocation(timeout: timeout) +} + +/// The object only lives inside this call, so it's released when it returns. +/// Returns `false` when the lifecycle couldn't run. +@MainActor +private func createAndRun( + _ makeObject: @MainActor () async throws -> Object, + lifecycle: Lifecycle, + tracker: DeallocationTracker, + location: TestSourceLocation +) async rethrows -> Bool { + let object = try await makeObject() + tracker.track(object, at: location) + return await lifecycle.run(object, location) +} diff --git a/Sources/DeallocTests/Expectation/IssueReporting.swift b/Sources/DeallocTests/Expectation/IssueReporting.swift new file mode 100644 index 0000000..00919c3 --- /dev/null +++ b/Sources/DeallocTests/Expectation/IssueReporting.swift @@ -0,0 +1,47 @@ +// +// IssueReporting.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import XCTest + +#if canImport(Testing) + import Testing +#endif + +/// Place in the test source where a failure is reported +struct TestSourceLocation: Sendable { + let fileID: StaticString + let filePath: StaticString + let line: UInt + let column: UInt + + init(fileID: StaticString, filePath: StaticString, line: UInt, column: UInt) { + self.fileID = fileID + self.filePath = filePath + self.line = line + self.column = column + } +} + +/// Reports a failure to Swift Testing when running inside a Swift Testing test, otherwise to XCTest +func reportIssue(_ message: String, at location: TestSourceLocation) { + #if canImport(Testing) + if Test.current != nil { + Issue.record( + Comment(rawValue: message), + sourceLocation: SourceLocation( + fileID: String(describing: location.fileID), + filePath: String(describing: location.filePath), + line: Int(location.line), + column: Int(location.column) + ) + ) + return + } + #endif + + XCTFail(message, file: location.filePath, line: location.line) +} diff --git a/Sources/DeallocTests/Expectation/Lifecycle.swift b/Sources/DeallocTests/Expectation/Lifecycle.swift new file mode 100644 index 0000000..120da6c --- /dev/null +++ b/Sources/DeallocTests/Expectation/Lifecycle.swift @@ -0,0 +1,241 @@ +// +// Lifecycle.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import Foundation + +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif + +/// What happens with the tested object between its creation and release. +/// +/// Many leaks only appear once a view controller loads its view or appears on screen, +/// so view controllers should usually be checked with `.present` or `.push`. +public struct Lifecycle: Sendable { + public typealias Interaction = @MainActor (Object) async throws -> Void + + /// Returns `false` when the lifecycle couldn't run. The failure is already reported then, + /// and the deallocation check is skipped because UIKit may still hold the object. + let run: @MainActor (Object, TestSourceLocation) async -> Bool + + init(run: @escaping @MainActor (Object, TestSourceLocation) async -> Bool) { + self.run = run + } + + /// The object is released right after it's created + public static var none: Self { + Self { _, _ in true } + } + + /// Runs custom code with the object before it's released, e.g. calls the methods you suspect of leaking + public static func custom(_ body: @escaping Interaction) -> Self { + Self { object, location in + await perform(body, with: object, at: location) + return true + } + } +} + +extension Lifecycle { + @MainActor + static func perform(_ interaction: Interaction?, with object: Object, at location: TestSourceLocation) async { + guard let interaction else { + return + } + + do { + try await interaction(object) + } catch { + reportIssue("Lifecycle interaction with \(type(of: object)) threw an error: \(error)", at: location) + } + } +} + +/// Polls the condition until it holds or the timeout elapses +@MainActor +func waitUntil(timeout: Duration = .seconds(2), _ condition: @MainActor () -> Bool) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now + timeout + + while !condition() { + guard clock.now < deadline else { + return false + } + + do { + try await Task.sleep(for: .milliseconds(5)) + } catch { + return condition() + } + } + + return true +} + +#if canImport(UIKit) + +// MARK: - UIKit + +public extension Lifecycle where Object: UIViewController { + /// Loads the view, so `viewDidLoad` runs + static var loadView: Self { + Self { controller, _ in + controller.loadViewIfNeeded() + return true + } + } + + /// Presents the controller modally in a test window, then dismisses it + static var present: Self { + present() + } + + /// Presents the controller modally in a test window, then dismisses it + /// - Parameters: + /// - style: Modal presentation style + /// - interaction: Runs while the controller is on screen + static func present(style: UIModalPresentationStyle = .fullScreen, interaction: Interaction? = nil) -> Self { + Self { controller, location in + let hostController = HostViewController() + let host = TestWindow(rootViewController: hostController) + defer { host.close() } + + // UIKit postpones presentations from a controller that hasn't appeared yet + guard await host.waitUntilVisible(hostController, at: location) else { + return false + } + + controller.modalPresentationStyle = style + hostController.present(controller, animated: false) + + guard await waitUntil({ controller.viewIfLoaded?.window != nil }) else { + let reason = host.hasWindowScene ? "" : ". Modal presentation needs a window scene, so the test target needs a host app." + reportIssue("\(type(of: controller)) could not be presented\(reason)", at: location) + return false + } + + await perform(interaction, with: controller, at: location) + + hostController.dismiss(animated: false) + + guard await waitUntil({ hostController.presentedViewController == nil }) else { + reportIssue("\(type(of: controller)) could not be dismissed", at: location) + return false + } + + return true + } + } + + /// Pushes the controller onto a navigation controller in a test window, then pops it + static var push: Self { + push() + } + + /// Pushes the controller onto a navigation controller in a test window, then pops it + /// - Parameter interaction: Runs while the controller is on screen + static func push(interaction: Interaction? = nil) -> Self { + Self { controller, location in + let hostController = HostViewController() + let navigationController = UINavigationController(rootViewController: hostController) + let host = TestWindow(rootViewController: navigationController) + defer { host.close() } + + guard await host.waitUntilVisible(hostController, at: location) else { + return false + } + + navigationController.pushViewController(controller, animated: false) + + guard await waitUntil({ controller.viewIfLoaded?.window != nil }) else { + reportIssue("\(type(of: controller)) could not be pushed", at: location) + return false + } + + await perform(interaction, with: controller, at: location) + + navigationController.popViewController(animated: false) + + guard await waitUntil({ controller.navigationController == nil }) else { + reportIssue("\(type(of: controller)) could not be popped", at: location) + return false + } + + return true + } + } +} + +/// Window on top of everything that hosts the tested controllers +@MainActor +final class TestWindow { + let rootViewController: UIViewController + private let window: UIWindow + + init(rootViewController: UIViewController) { + self.rootViewController = rootViewController + + // `UIApplication.shared` is accessed via KVC so the library stays app-extension safe + if let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication, + let windowScene = application.connectedScenes.first as? UIWindowScene { + window = UIWindow(windowScene: windowScene) + } else { + window = UIWindow(frame: UIScreen.main.bounds) + } + + window.rootViewController = rootViewController + window.windowLevel = UIWindow.Level.alert + 1 + window.makeKeyAndVisible() + } + + var hasWindowScene: Bool { + window.windowScene != nil + } + + func waitUntilVisible(_ hostController: HostViewController, at location: TestSourceLocation) async -> Bool { + guard await waitUntil({ hostController.hasAppeared }) else { + reportIssue("The test window could not be shown. Run the tests in a host app.", at: location) + return false + } + + return true + } + + func close() { + window.isHidden = true + window.rootViewController = nil + } +} + +/// Empty controller that presents or pushes the tested controller once it has appeared +@MainActor +final class HostViewController: UIViewController { + private(set) var hasAppeared = false + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + hasAppeared = true + } +} + +#elseif canImport(AppKit) + +// MARK: - AppKit + +public extension Lifecycle where Object: NSViewController { + /// Loads the view, so `viewDidLoad` runs + static var loadView: Self { + Self { controller, _ in + _ = controller.view + return true + } + } +} + +#endif diff --git a/Sources/DeallocTests/Expectation/TrackForDeallocation.swift b/Sources/DeallocTests/Expectation/TrackForDeallocation.swift new file mode 100644 index 0000000..ea403d5 --- /dev/null +++ b/Sources/DeallocTests/Expectation/TrackForDeallocation.swift @@ -0,0 +1,125 @@ +// +// TrackForDeallocation.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import XCTest + +#if canImport(Testing) + import Testing +#endif + +// MARK: - XCTest + +public extension XCTestCase { + /// Checks that the object deallocates when the test ends. + /// + /// Keep the object in a local variable. A property of the test case lives until the test case is released. + /// + /// ```swift + /// func test_viewModel() { + /// let viewModel = trackForDeallocation(ProfileViewModel()) + /// ... + /// } + /// ``` + @MainActor + @discardableResult + func trackForDeallocation( + _ object: Object, + timeout: Duration = .seconds(2), + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) -> Object { + let tracker = DeallocationTracker() + tracker.track(object, at: TestSourceLocation(fileID: fileID, filePath: filePath, line: line, column: column)) + + addTeardownBlock { @MainActor in + await tracker.verifyDeallocation(timeout: timeout) + } + + return object + } +} + +// MARK: - Swift Testing + +/// Checks that the object deallocates when the test ends. Requires the `.checksDeallocation` trait. +/// +/// ```swift +/// @Test(.checksDeallocation) func viewModel() async { +/// let viewModel = trackForDeallocation(ProfileViewModel()) +/// ... +/// } +/// ``` +@MainActor +@discardableResult +public func trackForDeallocation( + _ object: Object, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column +) -> Object { + let location = TestSourceLocation(fileID: fileID, filePath: filePath, line: line, column: column) + + guard let tracker = DeallocationTracker.current else { + reportIssue( + "trackForDeallocation(_:) needs the .checksDeallocation trait on the test or its suite. " + + "In XCTest, call it on the test case.", + at: location + ) + return object + } + + tracker.track(object, at: location) + return object +} + +#if canImport(Testing) && compiler(>=6.1) + +/// Checks that every object passed to `trackForDeallocation(_:)` deallocates when the test ends +public struct DeallocationCheckTrait: TestTrait, SuiteTrait, TestScoping { + let timeout: Duration + + public var isRecursive: Bool { + true + } + + public func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: @Sendable () async throws -> Void + ) async throws { + guard !test.isSuite else { + try await function() + return + } + + let tracker = await DeallocationTracker() + + try await DeallocationTracker.$current.withValue(tracker) { + try await function() + } + + await tracker.verifyDeallocation(timeout: timeout) + } +} + +public extension Trait where Self == DeallocationCheckTrait { + /// Checks that every object passed to `trackForDeallocation(_:)` deallocates when the test ends + static var checksDeallocation: Self { + checksDeallocation(timeout: .seconds(2)) + } + + /// Checks that every object passed to `trackForDeallocation(_:)` deallocates when the test ends + /// - Parameter timeout: How long to wait for the objects to deallocate + static func checksDeallocation(timeout: Duration) -> Self { + Self(timeout: timeout) + } +} + +#endif diff --git a/Tests/DeallocTestsDIFreeTests/ExpectDeallocationDIFreeTests.swift b/Tests/DeallocTestsDIFreeTests/ExpectDeallocationDIFreeTests.swift new file mode 100644 index 0000000..6a97b8c --- /dev/null +++ b/Tests/DeallocTestsDIFreeTests/ExpectDeallocationDIFreeTests.swift @@ -0,0 +1,27 @@ +// +// ExpectDeallocationDIFreeTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import DeallocTestsDIFree +import Testing + +final class PlainObject {} + +@Suite("expectDeallocation without Dependency Injection") +@MainActor +struct ExpectDeallocationDIFreeTests { + @Test func cleanObjectPasses() async { + await expectDeallocation { PlainObject() } + } + + @Test func retainCycleIsReported() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { LeakingObject() } + } matching: { issue in + issue.comments.contains { $0.rawValue.contains("LeakingObject was not deallocated") } + } + } +} diff --git a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift new file mode 100644 index 0000000..f0d0885 --- /dev/null +++ b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift @@ -0,0 +1,227 @@ +// +// ExpectDeallocationTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import DeallocTests +import DependencyInjection +import Testing + +#if canImport(AppKit) + import AppKit +#endif + +// MARK: - Fixtures + +/// No `DeallocTestable` conformance needed +final class PlainObject {} + +final class RetainCycleObject { + var closure: (() -> Void)? + + init() { + closure = { _ = self } + } +} + +protocol AnyService: Sendable {} + +struct ValueService: AnyService {} + +/// Holds strong references. Each test uses its own instance because tests run in parallel. +@MainActor +final class Cache { + var objects = [AnyObject]() +} + +struct LifecycleError: Error {} + +/// Matches leak reports attributed to this file +func isLeakReport(of typeName: String) -> (Issue) -> Bool { + { issue in + issue.comments.contains { $0.rawValue.contains("\(typeName) was not deallocated") } + && issue.sourceLocation?.fileName.hasSuffix("Tests.swift") == true + } +} + +// MARK: - expectDeallocation + +@Suite("expectDeallocation") +@MainActor +struct ExpectDeallocationTests { + @Test func cleanObjectPasses() async { + await expectDeallocation { PlainObject() } + } + + @Test func retainCycleIsReportedAtCallSite() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { RetainCycleObject() } + } matching: { issue in + isLeakReport(of: "RetainCycleObject")(issue) && issue.sourceLocation?.line == #line - 2 + } + } + + @Test func customLifecycleRunsBeforeRelease() async { + var exercisedObject: ObjectIdentifier? + + await expectDeallocation(.custom { exercisedObject = ObjectIdentifier($0) }) { PlainObject() } + + #expect(exercisedObject != nil) + } + + @Test func customLifecycleLeakIsReported() async { + let cache = Cache() + + await withKnownIssue { + await expectDeallocation(.custom { cache.objects.append($0) }, timeout: .milliseconds(100)) { PlainObject() } + } matching: { issue in + isLeakReport(of: "PlainObject")(issue) + } + } + + @Test func throwingLifecycleIsReported() async { + await withKnownIssue { + await expectDeallocation(.custom { _ in throw LifecycleError() }) { PlainObject() } + } matching: { issue in + issue.comments.contains { $0.rawValue.contains("threw an error") } + } + } + + @Test func throwingFactoryRethrows() async { + await #expect(throws: LifecycleError.self) { + try await expectDeallocation { () throws -> PlainObject in throw LifecycleError() } + } + } + + @Test func afterReleaseRunsBeforeCheck() async { + let cache = Cache() + + await expectDeallocation(afterRelease: { cache.objects.removeAll() }) { + let object = PlainObject() + cache.objects.append(object) + return object + } + } + + #if canImport(AppKit) + @Test func appKitLoadViewRunsViewDidLoad() async { + await withKnownIssue { + await expectDeallocation(.loadView, timeout: .milliseconds(100)) { LeakingViewController() } + } matching: { issue in + isLeakReport(of: "LeakingViewController")(issue) + } + } + + @Test func appKitCleanControllerPasses() async { + await expectDeallocation(.loadView) { CleanViewController() } + } + #endif +} + +#if canImport(AppKit) + final class CleanViewController: NSViewController { + override func loadView() { + view = NSView() + } + } + + final class LeakingViewController: NSViewController { + var closure: (() -> Void)? + + override func loadView() { + view = NSView() + } + + override func viewDidLoad() { + super.viewDidLoad() + closure = { _ = self } + } + } +#endif + +// MARK: - Dependency Injection + +@Suite("expectDeallocation with AsyncContainer") +@MainActor +struct ExpectDeallocationDependencyInjectionTests { + let container = AsyncContainer() + + @Test func sharedInstanceIsReleasedWithContainer() async { + await container.register(type: Service.self, in: .shared) { _ in SharedService() } + + await expectDeallocation(of: Service.self, resolvedFrom: container) + } + + @Test func newInstanceIsChecked() async { + await container.register(type: Service.self, in: .new) { _ in SharedService() } + + await expectDeallocation(of: Service.self, resolvedFrom: container) + } + + @Test func valueTypeIsReported() async { + await container.register(type: AnyService.self, in: .new) { _ in ValueService() } + + await withKnownIssue { + await expectDeallocation(of: AnyService.self, resolvedFrom: container) + } matching: { issue in + issue.comments.contains { $0.rawValue.contains("is not a class instance") } + } + } +} + +// MARK: - trackForDeallocation + +#if compiler(>=6.1) + +@Suite("trackForDeallocation") +@MainActor +struct TrackForDeallocationTests { + @Test(.checksDeallocation) func trackedObjectPasses() { + let object = trackForDeallocation(PlainObject()) + _ = object + } + + @Test func trackedLeakIsReported() async throws { + try await withKnownIssue { + try await DeallocationCheckTrait.checksDeallocation(timeout: .milliseconds(100)).provideScope( + for: #require(Test.current), + testCase: Test.Case.current, + performing: { + await MainActor.run { + _ = trackForDeallocation(RetainCycleObject()) + } + } + ) + } matching: { issue in + isLeakReport(of: "RetainCycleObject")(issue) + } + } + + @Test func missingTraitIsReported() { + withKnownIssue { + _ = trackForDeallocation(PlainObject()) + } matching: { issue in + issue.comments.contains { $0.rawValue.contains("needs the .checksDeallocation trait") } + } + } +} + +@Suite("trackForDeallocation on a suite", .checksDeallocation) +@MainActor +struct TrackForDeallocationSuiteTests { + /// The suite instance is released before the check, so stored properties work too + let object = trackForDeallocation(PlainObject()) + + @Test func storedPropertyIsChecked() { + _ = object + } + + @Test(arguments: [1, 2, 3]) + func parameterizedTestIsChecked(value: Int) { + _ = trackForDeallocation(PlainObject()) + } +} + +#endif diff --git a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift new file mode 100644 index 0000000..f73dbc0 --- /dev/null +++ b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift @@ -0,0 +1,36 @@ +// +// TrackForDeallocationXCTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import DeallocTests +import XCTest + +final class TrackForDeallocationXCTests: XCTestCase { + @MainActor + func test_trackedObject_passes() { + let object = trackForDeallocation(PlainObject()) + _ = object + } + + @MainActor + func test_trackedLeak_fails() { + XCTExpectFailure("RetainCycleObject has a retain cycle") + + trackForDeallocation(RetainCycleObject(), timeout: .milliseconds(100)) + } + + @MainActor + func test_expectDeallocation_cleanObject_passes() async { + await expectDeallocation { PlainObject() } + } + + @MainActor + func test_expectDeallocation_leak_fails() async { + XCTExpectFailure("RetainCycleObject has a retain cycle") + + await expectDeallocation(timeout: .milliseconds(100)) { RetainCycleObject() } + } +} diff --git a/Tests/DeallocTestsTests/UIKitLifecycleTests.swift b/Tests/DeallocTestsTests/UIKitLifecycleTests.swift new file mode 100644 index 0000000..4720c78 --- /dev/null +++ b/Tests/DeallocTestsTests/UIKitLifecycleTests.swift @@ -0,0 +1,103 @@ +// +// UIKitLifecycleTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +#if canImport(UIKit) + +import DeallocTests +import Testing +import UIKit + +final class CleanController: UIViewController {} + +/// Leaks as soon as the view loads +final class LoadLeakingController: UIViewController { + var closure: (() -> Void)? + + override func viewDidLoad() { + super.viewDidLoad() + closure = { _ = self } + } +} + +/// Leaks only once it appears on screen, so `.loadView` doesn't catch it +final class AppearLeakingController: UIViewController { + var closure: (() -> Void)? + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + closure = { _ = self } + } +} + +/// Modal presentation needs a window scene, which only exists when the tests run in a host app. +/// The sample apps cover `.present` in that setup. +@MainActor +func hasWindowScene() -> Bool { + UIApplication.shared.connectedScenes.contains { $0 is UIWindowScene } +} + +@Suite("UIKit lifecycles", .serialized) +@MainActor +struct UIKitLifecycleTests { + @Test func loadViewCatchesViewDidLoadLeak() async { + await withKnownIssue { + await expectDeallocation(.loadView, timeout: .milliseconds(200)) { LoadLeakingController() } + } matching: { issue in + isLeakReport(of: "LoadLeakingController")(issue) + } + } + + @Test func loadViewMissesAppearLeak() async { + await expectDeallocation(.loadView) { AppearLeakingController() } + } + + @Test(.enabled("Modal presentation needs a host app") { await hasWindowScene() }) func presentPassesForCleanController() async { + await expectDeallocation(.present) { CleanController() } + } + + @Test(.enabled("Modal presentation needs a host app") { await hasWindowScene() }) func presentCatchesAppearLeak() async { + await withKnownIssue { + await expectDeallocation(.present, timeout: .milliseconds(200)) { AppearLeakingController() } + } matching: { issue in + isLeakReport(of: "AppearLeakingController")(issue) + } + } + + @Test(.enabled("Modal presentation needs a host app") { await hasWindowScene() }) func presentInteractionRunsOnScreen() async { + var wasOnScreen = false + + await expectDeallocation(.present(style: .pageSheet, interaction: { wasOnScreen = $0.view.window != nil })) { + CleanController() + } + + #expect(wasOnScreen) + } + + @Test func pushPassesForCleanController() async { + await expectDeallocation(.push) { CleanController() } + } + + @Test func pushCatchesAppearLeak() async { + await withKnownIssue { + await expectDeallocation(.push, timeout: .milliseconds(200)) { AppearLeakingController() } + } matching: { issue in + isLeakReport(of: "AppearLeakingController")(issue) + } + } + + @Test func pushInteractionRunsInNavigationController() async { + var wasPushed = false + + await expectDeallocation(.push(interaction: { wasPushed = $0.navigationController != nil })) { + CleanController() + } + + #expect(wasPushed) + } +} + +#endif From 7f2a692207063839e6a6caee8db121b52c5b0a50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Fri, 2 Oct 2026 13:20:57 +0200 Subject: [PATCH 2/2] ci: pick an available iPhone simulator Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc66df9..4e9af6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,9 +25,13 @@ jobs: - 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 'platform=iOS Simulator,name=iPhone 16' + -destination "id=$UDID" sample-apps: name: Sample apps (iOS)