From 3a4b5f21aa55f2cc9066eeade2c1301d790de793 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Mon, 5 Oct 2026 17:55:40 +0200 Subject: [PATCH 1/3] feat: expectDeallocation with UIKit, AppKit and SwiftUI lifecycles `expectDeallocation { makeObject() }` creates an object, runs its lifecycle, releases it and checks that it deallocates. It holds a weak reference, so any class can be checked without conformances, and it polls with a deadline instead of sleeping. A leak is reported at the line of the test, to exactly one framework (Swift Testing or XCTest). Lifecycles run the code paths where leaks appear: `.custom`, `.loadView` (UIKit, AppKit), `.present` and `.push` with an optional interaction, and `.hosting` for SwiftUI. UIKit steps wait for UIKit's state instead of completion handlers, so a test can't hang. The package has tests again; CI runs them on macOS with and without the trait and on the iOS Simulator. DeallocTester keeps working. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 18 +- Package.swift | 6 + .../Diagnostics/DurationText.swift | 20 ++ .../DeallocTests/Diagnostics/LeakReport.swift | 18 ++ .../DeallocTests/Diagnostics/TypeNames.swift | 19 ++ .../Expectation/DeallocationTracker.swift | 43 ++++ .../Expectation/ExpectDeallocation.swift | 57 +++++ .../Expectation/IssueReporting.swift | 45 ++++ .../Expectation/Lifecycle+SwiftUI.swift | 117 +++++++++ .../DeallocTests/Expectation/Lifecycle.swift | 223 ++++++++++++++++++ .../DeallocTests/Expectation/Polling.swift | 32 +++ .../ExpectDeallocationTests.swift | 162 +++++++++++++ .../LeakHintsAndSwiftUITests.swift | 89 +++++++ Tests/DeallocTestsTests/LeakReportTests.swift | 52 ++++ .../TrackForDeallocationXCTests.swift | 23 ++ .../UIKitLifecycleTests.swift | 99 ++++++++ 16 files changed, 1016 insertions(+), 7 deletions(-) create mode 100644 Sources/DeallocTests/Diagnostics/DurationText.swift create mode 100644 Sources/DeallocTests/Diagnostics/LeakReport.swift create mode 100644 Sources/DeallocTests/Diagnostics/TypeNames.swift create mode 100644 Sources/DeallocTests/Expectation/DeallocationTracker.swift create mode 100644 Sources/DeallocTests/Expectation/ExpectDeallocation.swift create mode 100644 Sources/DeallocTests/Expectation/IssueReporting.swift create mode 100644 Sources/DeallocTests/Expectation/Lifecycle+SwiftUI.swift create mode 100644 Sources/DeallocTests/Expectation/Lifecycle.swift create mode 100644 Sources/DeallocTests/Expectation/Polling.swift create mode 100644 Tests/DeallocTestsTests/ExpectDeallocationTests.swift create mode 100644 Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift create mode 100644 Tests/DeallocTestsTests/LeakReportTests.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 8bfc16f..1f32c28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ concurrency: jobs: package: - name: Package build (macOS, ${{ matrix.traits.name }}) + name: Package tests (macOS, ${{ matrix.traits.name }}) runs-on: macos-15 strategy: matrix: @@ -20,16 +20,20 @@ jobs: - { name: "without DependencyInjection", flags: "--disable-default-traits" } steps: - uses: actions/checkout@v4 - - name: Build - run: swift build ${{ matrix.traits.flags }} + - name: Run tests + run: swift test ${{ matrix.traits.flags }} package-ios: - name: Package build (iOS Simulator) + name: Package tests (iOS Simulator) runs-on: macos-15 steps: - uses: actions/checkout@v4 - - name: Build + - name: Run tests run: | - xcodebuild build \ + # 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 \ - -destination 'generic/platform=iOS Simulator' + -destination "id=$UDID" diff --git a/Package.swift b/Package.swift index 90619ab..a5ceeb4 100644 --- a/Package.swift +++ b/Package.swift @@ -42,6 +42,12 @@ let package = Package( ) ] ), + .testTarget( + name: "DeallocTestsTests", + dependencies: [ + "DeallocTests" + ] + ), ], swiftLanguageModes: [.v6] ) diff --git a/Sources/DeallocTests/Diagnostics/DurationText.swift b/Sources/DeallocTests/Diagnostics/DurationText.swift new file mode 100644 index 0000000..b70ca39 --- /dev/null +++ b/Sources/DeallocTests/Diagnostics/DurationText.swift @@ -0,0 +1,20 @@ +// +// DurationText.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +enum DurationText { + static func describe(_ duration: Duration) -> String { + let (seconds, attoseconds) = duration.components + let milliseconds = seconds * 1000 + attoseconds / 1_000_000_000_000_000 + + guard milliseconds >= 1000 else { + return "\(milliseconds) ms" + } + + let tenths = milliseconds / 100 + return tenths % 10 == 0 ? "\(tenths / 10) sec" : "\(tenths / 10).\(tenths % 10) sec" + } +} diff --git a/Sources/DeallocTests/Diagnostics/LeakReport.swift b/Sources/DeallocTests/Diagnostics/LeakReport.swift new file mode 100644 index 0000000..3c29935 --- /dev/null +++ b/Sources/DeallocTests/Diagnostics/LeakReport.swift @@ -0,0 +1,18 @@ +// +// LeakReport.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +/// What is known about an object that wasn't deallocated, and how it's described in a failure +struct LeakReport: Sendable { + let typeName: String + let timeout: Duration + + var message: String { + "\(typeName) was not deallocated within \(DurationText.describe(timeout))." + + " 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/Diagnostics/TypeNames.swift b/Sources/DeallocTests/Diagnostics/TypeNames.swift new file mode 100644 index 0000000..fd621ee --- /dev/null +++ b/Sources/DeallocTests/Diagnostics/TypeNames.swift @@ -0,0 +1,19 @@ +// +// TypeNames.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import Foundation + +enum TypeNames { + static func readableName(of object: AnyObject) -> String { + readableName(of: type(of: object)) + } + + static func readableName(of type: Any.Type) -> String { + String(reflecting: type) + .replacingOccurrences(of: #"\(unknown context at \$[0-9a-fA-F]+\)\."#, with: "", options: .regularExpression) + } +} diff --git a/Sources/DeallocTests/Expectation/DeallocationTracker.swift b/Sources/DeallocTests/Expectation/DeallocationTracker.swift new file mode 100644 index 0000000..8cc980a --- /dev/null +++ b/Sources/DeallocTests/Expectation/DeallocationTracker.swift @@ -0,0 +1,43 @@ +// +// DeallocationTracker.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +@MainActor +final class DeallocationTracker { + private struct TrackedObject { + weak var object: AnyObject? + let typeName: String + let location: TestSourceLocation + } + + private var trackedObjects = [TrackedObject]() + + func track(_ object: AnyObject, at location: TestSourceLocation) { + trackedObjects.append( + TrackedObject(object: object, typeName: TypeNames.readableName(of: object), location: location) + ) + } + + func verifyDeallocation(timeout: Duration) async { + let objects = trackedObjects + trackedObjects.removeAll() + + _ = await Polling.waitUntil(timeout: timeout) { + !objects.contains { $0.object != nil } + } + + guard !Task.isCancelled else { + return + } + + for trackedObject in objects where trackedObject.object != nil { + reportIssue( + LeakReport(typeName: trackedObject.typeName, timeout: timeout).message, + at: trackedObject.location + ) + } + } +} diff --git a/Sources/DeallocTests/Expectation/ExpectDeallocation.swift b/Sources/DeallocTests/Expectation/ExpectDeallocation.swift new file mode 100644 index 0000000..0e1c4c4 --- /dev/null +++ b/Sources/DeallocTests/Expectation/ExpectDeallocation.swift @@ -0,0 +1,57 @@ +// +// ExpectDeallocation.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +/// 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) +} + +@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..239664c --- /dev/null +++ b/Sources/DeallocTests/Expectation/IssueReporting.swift @@ -0,0 +1,45 @@ +// +// IssueReporting.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import XCTest + +#if canImport(Testing) + import Testing +#endif + +struct TestSourceLocation: Sendable { + let fileID: StaticString + let filePath: StaticString + let line: UInt + let column: UInt +} + +func reportIssue(_ message: String, at location: TestSourceLocation) { + #if canImport(Testing) + if Test.current != nil { + recordSwiftTestingIssue(message, at: location) + return + } + #endif + + XCTFail(message, file: location.filePath, line: location.line) +} + +#if canImport(Testing) + +private func recordSwiftTestingIssue(_ message: String, at location: TestSourceLocation) { + let sourceLocation = SourceLocation( + fileID: String(describing: location.fileID), + filePath: String(describing: location.filePath), + line: Int(location.line), + column: Int(location.column) + ) + + Issue.record(Comment(rawValue: message), sourceLocation: sourceLocation) +} + +#endif diff --git a/Sources/DeallocTests/Expectation/Lifecycle+SwiftUI.swift b/Sources/DeallocTests/Expectation/Lifecycle+SwiftUI.swift new file mode 100644 index 0000000..25f9f1d --- /dev/null +++ b/Sources/DeallocTests/Expectation/Lifecycle+SwiftUI.swift @@ -0,0 +1,117 @@ +// +// Lifecycle+SwiftUI.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +#if canImport(SwiftUI) + +import SwiftUI + +public extension Lifecycle { + /// Shows a SwiftUI view built from the object, typically its view model, in a test window, then removes it. + /// + /// `onAppear` and `.task` run while the view is shown. When it's removed, SwiftUI cancels its tasks. + /// + /// ```swift + /// await expectDeallocation(.hosting { ProfileView(viewModel: $0) }) { + /// ProfileViewModel() + /// } + /// ``` + /// + /// - Parameters: + /// - interaction: Runs while the view is on screen + /// - content: Builds the view from the object + static func hosting( + interaction: Interaction? = nil, + @ViewBuilder _ content: @escaping @MainActor (Object) -> Content + ) -> Self { + Self { object, location in + guard let host = await SwiftUIHost(rootView: content(object), location: location) else { + return false + } + + await settle() + await perform(interaction, with: object, at: location) + + host.remove() + + await settle() + return true + } + } +} + +@MainActor +private func settle() async { + try? await Task.sleep(for: .milliseconds(50)) +} + +#if canImport(UIKit) + +@MainActor +private final class SwiftUIHost { + private let window: TestWindow + private let hostController: HostViewController + private let hostingController: UIHostingController + + init?(rootView: Content, location: TestSourceLocation) async { + hostController = HostViewController() + window = TestWindow(rootViewController: hostController) + hostingController = UIHostingController(rootView: rootView) + + guard await window.waitUntilVisible(hostController, at: location) else { + window.close() + return nil + } + + hostController.addChild(hostingController) + hostingController.view.frame = hostController.view.bounds + hostController.view.addSubview(hostingController.view) + hostingController.didMove(toParent: hostController) + + guard await waitUntil({ [hostingController] in hostingController.viewIfLoaded?.window != nil }) else { + reportIssue("The SwiftUI view could not be shown", at: location) + remove() + return nil + } + } + + func remove() { + hostingController.willMove(toParent: nil) + hostingController.view.removeFromSuperview() + hostingController.removeFromParent() + window.close() + } +} + +#elseif canImport(AppKit) + +@MainActor +private final class SwiftUIHost { + private let window: NSWindow + + init?(rootView: Content, location: TestSourceLocation) async { + let hostingController = NSHostingController(rootView: rootView) + window = NSWindow(contentViewController: hostingController) + window.isReleasedWhenClosed = false + window.orderFront(nil) + + guard await waitUntil({ [window] in hostingController.view.window === window && window.isVisible }) else { + reportIssue("The SwiftUI view could not be shown", at: location) + remove() + return nil + } + } + + func remove() { + window.orderOut(nil) + window.contentViewController = nil + window.close() + } +} + +#endif + +#endif diff --git a/Sources/DeallocTests/Expectation/Lifecycle.swift b/Sources/DeallocTests/Expectation/Lifecycle.swift new file mode 100644 index 0000000..0c99c29 --- /dev/null +++ b/Sources/DeallocTests/Expectation/Lifecycle.swift @@ -0,0 +1,223 @@ +// +// Lifecycle.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + + +#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 + + 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) + } + } +} + +@MainActor +func waitUntil(timeout: Duration = .seconds(10), _ condition: @MainActor () -> Bool) async -> Bool { + await Polling.waitUntil(timeout: timeout, interval: .milliseconds(5), condition) +} + +#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() } + + 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 + } + } +} + +@MainActor +private func foregroundWindowScene() -> UIWindowScene? { + guard let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication else { + return nil + } + + let windowScenes = application.connectedScenes.compactMap { $0 as? UIWindowScene } + return windowScenes.first { $0.activationState == .foregroundActive } ?? windowScenes.first +} + +@MainActor +final class TestWindow { + private let window: UIWindow + + init(rootViewController: UIViewController) { + if let windowScene = foregroundWindowScene() { + 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 + } +} + +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/Polling.swift b/Sources/DeallocTests/Expectation/Polling.swift new file mode 100644 index 0000000..790efbe --- /dev/null +++ b/Sources/DeallocTests/Expectation/Polling.swift @@ -0,0 +1,32 @@ +// +// Polling.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +enum Polling { + @MainActor + static func waitUntil( + timeout: Duration, + interval: Duration = .milliseconds(10), + _ 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: interval) + } catch { + return condition() + } + } + + return true + } +} diff --git a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift new file mode 100644 index 0000000..2e88888 --- /dev/null +++ b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift @@ -0,0 +1,162 @@ +// +// ExpectDeallocationTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import DeallocTests +import Testing + +#if canImport(AppKit) + import AppKit +#endif + +// MARK: - Fixtures + +final class PlainObject {} + +final class RetainCycleObject { + var closure: (() -> Void)? + + init() { + closure = { _ = self } + } +} + +@MainActor +final class Cache { + var objects = [AnyObject]() +} + +struct LifecycleError: Error {} + +private final class PrivateRetainCycle { + var closure: (() -> Void)? + + init() { + closure = { _ = self } + } +} + +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 cancelledCheckReportsNothing() async { + let check = Task { @MainActor in + await expectDeallocation(timeout: .seconds(5)) { RetainCycleObject() } + } + + try? await Task.sleep(for: .milliseconds(100)) + check.cancel() + await check.value + } + + @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 privateTypeNameIsReadable() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { PrivateRetainCycle() } + } matching: { issue in + issue.comments.contains { comment in + comment.rawValue.hasPrefix("DeallocTestsTests.PrivateRetainCycle was not deallocated") + } + } + } + + @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 diff --git a/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift b/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift new file mode 100644 index 0000000..042fcdd --- /dev/null +++ b/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift @@ -0,0 +1,89 @@ +// +// LeakHintsAndSwiftUITests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import DeallocTests +import SwiftUI +import Testing + +// MARK: - Fixtures + +@MainActor +final class TaskLeakModel { + var task: Task? + var ticks = 0 + + func start() { + task = Task { + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(10)) + self.ticks += 1 + } + } + } +} + +@MainActor +final class TaskModifierModel { + var ticks = 0 + var appeared = false + + func run() async { + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(10)) + ticks += 1 + } + } +} + +struct TaskLeakView: View { + let model: TaskLeakModel + + var body: some View { + Text("Leaking").onAppear { model.start() } + } +} + +struct TaskModifierView: View { + let model: TaskModifierModel + + var body: some View { + Text("Clean") + .onAppear { model.appeared = true } + .task { await model.run() } + } +} + +// MARK: - SwiftUI + +@Suite("SwiftUI hosting", .serialized) +@MainActor +struct SwiftUIHostingTests { + @Test func taskModifierIsCancelledWithView() async { + var appeared = false + + await expectDeallocation(.hosting { TaskModifierView(model: $0) }) { + let model = TaskModifierModel() + return model + } + + await expectDeallocation(.hosting(interaction: { appeared = $0.appeared }) { TaskModifierView(model: $0) }) { + TaskModifierModel() + } + + #expect(appeared) + } + + @Test func taskStartedOnAppearLeaks() async { + await withKnownIssue { + await expectDeallocation(.hosting { TaskLeakView(model: $0) }, timeout: .milliseconds(200)) { + TaskLeakModel() + } + } matching: { issue in + isLeakReport(of: "TaskLeakModel")(issue) + } + } +} diff --git a/Tests/DeallocTestsTests/LeakReportTests.swift b/Tests/DeallocTestsTests/LeakReportTests.swift new file mode 100644 index 0000000..0cec0c6 --- /dev/null +++ b/Tests/DeallocTestsTests/LeakReportTests.swift @@ -0,0 +1,52 @@ +// +// LeakReportTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +@testable import DeallocTests +import Testing + +private final class PrivateObject {} + +@Suite("Leak reports") +struct LeakReportTests { + @Test func readableNameDropsUnknownContext() { + let name = TypeNames.readableName(of: PrivateObject()) + + #expect(name == "DeallocTestsTests.PrivateObject") + } + + @Test func messageWithoutHintsGivesGeneralAdvice() { + let report = LeakReport(typeName: "App.Screen", timeout: .seconds(2)) + + #expect(report.message.hasPrefix("App.Screen was not deallocated within 2 sec. Something still holds")) + } + + @Test(arguments: [ + (Duration.milliseconds(400), "400 ms"), + (.seconds(2), "2 sec"), + (.milliseconds(3250), "3.2 sec"), + (.milliseconds(1999), "1.9 sec"), + ]) + func durationsReadTheSameInEveryLocale(duration: Duration, text: String) { + #expect(DurationText.describe(duration) == text) + } + + @Test @MainActor func pollingReturnsAsSoonAsTheConditionHolds() async { + let clock = ContinuousClock() + let start = clock.now + + let result = await Polling.waitUntil(timeout: .seconds(5)) { true } + + #expect(result) + #expect(clock.now - start < .seconds(1)) + } + + @Test @MainActor func pollingGivesUpAfterTheTimeout() async { + let result = await Polling.waitUntil(timeout: .milliseconds(50)) { false } + + #expect(!result) + } +} diff --git a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift new file mode 100644 index 0000000..7973507 --- /dev/null +++ b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift @@ -0,0 +1,23 @@ +// +// TrackForDeallocationXCTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import DeallocTests +import XCTest + +final class TrackForDeallocationXCTests: XCTestCase { + @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..e3ff371 --- /dev/null +++ b/Tests/DeallocTestsTests/UIKitLifecycleTests.swift @@ -0,0 +1,99 @@ +// +// UIKitLifecycleTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +#if canImport(UIKit) + +import DeallocTests +import Testing +import UIKit + +final class CleanController: UIViewController {} + +final class LoadLeakingController: UIViewController { + var closure: (() -> Void)? + + override func viewDidLoad() { + super.viewDidLoad() + closure = { _ = self } + } +} + +final class AppearLeakingController: UIViewController { + var closure: (() -> Void)? + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + closure = { _ = self } + } +} + +@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 e2f77509d9e0da7c56f22c1e2b6f3885cb6fc6ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Mon, 5 Oct 2026 18:13:29 +0200 Subject: [PATCH 2/3] style: no doc comment on the internal LeakReport Doc comments are kept for public API only. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/DeallocTests/Diagnostics/LeakReport.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/DeallocTests/Diagnostics/LeakReport.swift b/Sources/DeallocTests/Diagnostics/LeakReport.swift index 3c29935..ea58f9d 100644 --- a/Sources/DeallocTests/Diagnostics/LeakReport.swift +++ b/Sources/DeallocTests/Diagnostics/LeakReport.swift @@ -5,7 +5,6 @@ // Copyright © 2026 STRV. All rights reserved. // -/// What is known about an object that wasn't deallocated, and how it's described in a failure struct LeakReport: Sendable { let typeName: String let timeout: Duration From b6ceb747090f8532ae3cf3633e7581024b4ffdb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Tue, 6 Oct 2026 10:16:52 +0200 Subject: [PATCH 3/3] fix: review feedback on expectDeallocation and lifecycles - `.present(style: .popover)` anchors the popover to the host view; UIKit throws for a popover without a source view on iPad. - `afterRelease` also runs when the lifecycle can't run, so a cache filled by the factory is still cleared. Only the check is skipped. - `.hosting` waits until the view has appeared and started its tasks (with the usual 10 s deadline) instead of sleeping a fixed 50 ms, so a loaded runner can't remove the view before onAppear and .task run. - A cancelled test reports nothing: neither a CancellationError thrown by an interaction nor a UIKit wait cut short by cancellation. - The expected-leak SwiftUI test cancels the leaked model's task, so it doesn't keep running for the rest of the test process. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Expectation/ExpectDeallocation.swift | 6 +++-- .../Expectation/IssueReporting.swift | 4 +++ .../Expectation/Lifecycle+SwiftUI.swift | 25 ++++++++++++++++++- .../DeallocTests/Expectation/Lifecycle.swift | 7 ++++++ .../ExpectDeallocationTests.swift | 20 ++++++++++++++- .../LeakHintsAndSwiftUITests.swift | 8 +++++- .../UIKitLifecycleTests.swift | 4 +++ 7 files changed, 69 insertions(+), 5 deletions(-) diff --git a/Sources/DeallocTests/Expectation/ExpectDeallocation.swift b/Sources/DeallocTests/Expectation/ExpectDeallocation.swift index 0e1c4c4..00a0744 100644 --- a/Sources/DeallocTests/Expectation/ExpectDeallocation.swift +++ b/Sources/DeallocTests/Expectation/ExpectDeallocation.swift @@ -36,11 +36,13 @@ public func expectDeallocation( 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 { + let didRunLifecycle = try await createAndRun(makeObject, lifecycle: lifecycle, tracker: tracker, location: location) + await afterRelease() + + guard didRunLifecycle else { return } - await afterRelease() await tracker.verifyDeallocation(timeout: timeout) } diff --git a/Sources/DeallocTests/Expectation/IssueReporting.swift b/Sources/DeallocTests/Expectation/IssueReporting.swift index 239664c..8d05970 100644 --- a/Sources/DeallocTests/Expectation/IssueReporting.swift +++ b/Sources/DeallocTests/Expectation/IssueReporting.swift @@ -19,6 +19,10 @@ struct TestSourceLocation: Sendable { } func reportIssue(_ message: String, at location: TestSourceLocation) { + guard !Task.isCancelled else { + return + } + #if canImport(Testing) if Test.current != nil { recordSwiftTestingIssue(message, at: location) diff --git a/Sources/DeallocTests/Expectation/Lifecycle+SwiftUI.swift b/Sources/DeallocTests/Expectation/Lifecycle+SwiftUI.swift index 25f9f1d..703c7b9 100644 --- a/Sources/DeallocTests/Expectation/Lifecycle+SwiftUI.swift +++ b/Sources/DeallocTests/Expectation/Lifecycle+SwiftUI.swift @@ -28,7 +28,16 @@ public extension Lifecycle { @ViewBuilder _ content: @escaping @MainActor (Object) -> Content ) -> Self { Self { object, location in - guard let host = await SwiftUIHost(rootView: content(object), location: location) else { + let presence = ViewPresence() + let rootView = PresenceReporting(content: content(object), presence: presence) + + guard let host = await SwiftUIHost(rootView: rootView, location: location) else { + return false + } + + guard await waitUntil({ presence.hasStartedTasks }) else { + reportIssue("The SwiftUI view did not appear", at: location) + host.remove() return false } @@ -43,6 +52,20 @@ public extension Lifecycle { } } +@MainActor +private final class ViewPresence { + var hasStartedTasks = false +} + +private struct PresenceReporting: View { + let content: Content + let presence: ViewPresence + + var body: some View { + content.task { presence.hasStartedTasks = true } + } +} + @MainActor private func settle() async { try? await Task.sleep(for: .milliseconds(50)) diff --git a/Sources/DeallocTests/Expectation/Lifecycle.swift b/Sources/DeallocTests/Expectation/Lifecycle.swift index 0c99c29..99c1240 100644 --- a/Sources/DeallocTests/Expectation/Lifecycle.swift +++ b/Sources/DeallocTests/Expectation/Lifecycle.swift @@ -48,6 +48,8 @@ extension Lifecycle { do { try await interaction(object) + } catch is CancellationError { + return } catch { reportIssue("Lifecycle interaction with \(type(of: object)) threw an error: \(error)", at: location) } @@ -92,6 +94,11 @@ public extension Lifecycle where Object: UIViewController { } controller.modalPresentationStyle = style + if let popover = controller.popoverPresentationController { + let bounds = hostController.view.bounds + popover.sourceView = hostController.view + popover.sourceRect = CGRect(x: bounds.midX, y: bounds.midY, width: 0, height: 0) + } hostController.present(controller, animated: false) guard await waitUntil({ controller.viewIfLoaded?.window != nil }) else { diff --git a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift index 2e88888..bc28546 100644 --- a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift +++ b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift @@ -5,7 +5,7 @@ // Copyright © 2026 STRV. All rights reserved. // -import DeallocTests +@testable import DeallocTests import Testing #if canImport(AppKit) @@ -61,6 +61,24 @@ struct ExpectDeallocationTests { await check.value } + @Test func cancelledInteractionReportsNothing() async { + let check = Task { @MainActor in + await expectDeallocation(.custom { _ in try await Task.sleep(for: .seconds(5)) }) { PlainObject() } + } + + try? await Task.sleep(for: .milliseconds(100)) + check.cancel() + await check.value + } + + @Test func afterReleaseRunsWhenTheLifecycleCannotRun() async { + var didRunAfterRelease = false + + await expectDeallocation(Lifecycle { _, _ in false }, afterRelease: { didRunAfterRelease = true }) { PlainObject() } + + #expect(didRunAfterRelease) + } + @Test func cleanObjectPasses() async { await expectDeallocation { PlainObject() } } diff --git a/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift b/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift index 042fcdd..191517c 100644 --- a/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift +++ b/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift @@ -78,12 +78,18 @@ struct SwiftUIHostingTests { } @Test func taskStartedOnAppearLeaks() async { + weak var leakedModel: TaskLeakModel? + await withKnownIssue { await expectDeallocation(.hosting { TaskLeakView(model: $0) }, timeout: .milliseconds(200)) { - TaskLeakModel() + let model = TaskLeakModel() + leakedModel = model + return model } } matching: { issue in isLeakReport(of: "TaskLeakModel")(issue) } + + leakedModel?.task?.cancel() } } diff --git a/Tests/DeallocTestsTests/UIKitLifecycleTests.swift b/Tests/DeallocTestsTests/UIKitLifecycleTests.swift index e3ff371..90047ef 100644 --- a/Tests/DeallocTestsTests/UIKitLifecycleTests.swift +++ b/Tests/DeallocTestsTests/UIKitLifecycleTests.swift @@ -63,6 +63,10 @@ struct UIKitLifecycleTests { } } + @Test(.enabled("Modal presentation needs a host app") { await hasWindowScene() }) func presentAsPopoverIsAnchored() async { + await expectDeallocation(.present(style: .popover)) { CleanController() } + } + @Test(.enabled("Modal presentation needs a host app") { await hasWindowScene() }) func presentInteractionRunsOnScreen() async { var wasOnScreen = false