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 01/12] 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 63683fe75328d00449ffb262b8304261cb6a98a5 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 02/12] feat: trackForDeallocation, leak hints and Dependency Injection support - `trackForDeallocation` checks objects owned by the tested one inside `expectDeallocation`, and the system under test of any unit test: an XCTestCase method, or the `.checksDeallocation` Swift Testing trait. - Failure messages list likely causes found in the leaked object's stored properties: closures, Tasks, Combine subscriptions, timers and reference cycles. Only for confirmed leaks, phrased as suggestions, because Mirror can't tell weak properties from strong ones. - `expectDeallocation(of:resolvedFrom:)` resolves a dependency from an AsyncContainer, releases the shared instances and checks it; a value type is reported with its concrete type. Co-Authored-By: Claude Opus 5.5 (1M context) --- Package.swift | 9 +- .../DeallocTests/Diagnostics/LeakHints.swift | 166 ++++++++++++++++++ .../DeallocTests/Diagnostics/LeakReport.swift | 19 +- .../Expectation/DeallocationTracker.swift | 16 +- ...pectDeallocation+DependencyInjection.swift | 71 ++++++++ .../Expectation/ExpectDeallocation.swift | 12 +- .../Expectation/TrackForDeallocation.swift | 124 +++++++++++++ .../ExpectDeallocationTests.swift | 96 ++++++++++ .../LeakHintsAndSwiftUITests.swift | 162 ++++++++++++++++- Tests/DeallocTestsTests/LeakReportTests.swift | 45 +++++ .../TrackForDeallocationXCTests.swift | 25 +++ 11 files changed, 732 insertions(+), 13 deletions(-) create mode 100644 Sources/DeallocTests/Diagnostics/LeakHints.swift create mode 100644 Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift create mode 100644 Sources/DeallocTests/Expectation/TrackForDeallocation.swift diff --git a/Package.swift b/Package.swift index a5ceeb4..8c8fd35 100644 --- a/Package.swift +++ b/Package.swift @@ -24,7 +24,7 @@ let package = Package( traits: [ .trait( name: "DependencyInjection", - description: "Integration with STRV Dependency Injection: the AsyncContainer in DeallocTester" + description: "Integration with STRV Dependency Injection: expectDeallocation(of:resolvedFrom:)" ), .default(enabledTraits: ["DependencyInjection"]), ], @@ -45,7 +45,12 @@ let package = Package( .testTarget( name: "DeallocTestsTests", dependencies: [ - "DeallocTests" + "DeallocTests", + .product( + name: "DependencyInjection", + package: "ios-dependency-injection", + condition: .when(traits: ["DependencyInjection"]) + ) ] ), ], diff --git a/Sources/DeallocTests/Diagnostics/LeakHints.swift b/Sources/DeallocTests/Diagnostics/LeakHints.swift new file mode 100644 index 0000000..4347eb2 --- /dev/null +++ b/Sources/DeallocTests/Diagnostics/LeakHints.swift @@ -0,0 +1,166 @@ +// +// LeakHints.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import Foundation + +/// Looks at the stored properties of a leaked object and points at the usual suspects. +enum LeakHints { + static let maximumCycleDepth = 4 + static let maximumVisitedObjects = 300 + + static func hints(for object: AnyObject) -> [String] { + var hints = [String]() + + for property in storedProperties(of: object) { + if let kind = suspiciousKind(of: property.value) { + hints.append("`\(property.label)` is \(kind)") + } + } + + hints += cycles(from: object).map { path in + "`\(path)` refers back to the object. That's a retain cycle unless one of the references is weak" + } + + return hints + } + + // MARK: - Suspicious properties + + private static func suspiciousKind(of value: Any) -> String? { + guard let value = unwrapped(value), !isEmptyCollection(value) else { + return nil + } + + let typeName = String(describing: type(of: value)) + + if typeName.contains("->") { + return "a closure. Make sure it captures self weakly" + } + + if typeName.contains("AnyCancellable") { + return "a Combine subscription. Make sure its sink captures self weakly" + } + + if typeName.hasPrefix("Task<") { + return "a task. Make sure it's cancelled or captures self weakly" + } + + if value is Timer { + return "a timer. A scheduled timer keeps its target until it's invalidated" + } + + return nil + } + + private static func unwrapped(_ value: Any) -> Any? { + let mirror = Mirror(reflecting: value) + + guard mirror.displayStyle == .optional else { + return value + } + + return mirror.children.first.flatMap { unwrapped($0.value) } + } + + private static func isEmptyCollection(_ value: Any) -> Bool { + let mirror = Mirror(reflecting: value) + + switch mirror.displayStyle { + case .collection, .set, .dictionary: + return mirror.children.isEmpty + default: + return false + } + } + + // MARK: - Cycles + + private static func cycles(from root: AnyObject) -> [String] { + let rootIdentifier = ObjectIdentifier(root) + var visited: Set = [rootIdentifier] + var queue: [(object: AnyObject, path: String, depth: Int)] = [(root, "self", 0)] + var found = [String]() + + while !queue.isEmpty, visited.count < maximumVisitedObjects { + let (object, path, depth) = queue.removeFirst() + + guard depth < maximumCycleDepth else { + continue + } + + for property in storedProperties(of: object) { + for child in referencedObjects(in: property.value) { + let childPath = "\(path).\(property.label)" + let childIdentifier = ObjectIdentifier(child) + + if childIdentifier == rootIdentifier { + found.append(childPath) + } else if isUserDefined(type(of: child)), visited.insert(childIdentifier).inserted { + queue.append((child, childPath, depth + 1)) + } + } + } + } + + return found + } + + // MARK: - Reflection + + private struct Property { + let label: String + let value: Any + } + + private static func storedProperties(of object: AnyObject) -> [Property] { + var properties = [Property]() + var mirror: Mirror? = Mirror(reflecting: object) + + while let currentMirror = mirror { + if let subjectType = currentMirror.subjectType as? AnyClass, !isUserDefined(subjectType) { + break + } + + for child in currentMirror.children { + guard let label = child.label, !label.hasPrefix("_$") else { + continue + } + properties.append(Property(label: cleaned(label), value: child.value)) + } + + mirror = currentMirror.superclassMirror + } + + return properties + } + + private static func referencedObjects(in value: Any, depth: Int = 0) -> [AnyObject] { + let mirror = Mirror(reflecting: value) + + if mirror.displayStyle == .class { + return [value as AnyObject] + } + + guard depth < 3 else { + return [] + } + + return mirror.children.prefix(50).flatMap { referencedObjects(in: $0.value, depth: depth + 1) } + } + + private static func isUserDefined(_ objectClass: AnyClass) -> Bool { + guard let bundleIdentifier = Bundle(for: objectClass).bundleIdentifier else { + return true + } + return !bundleIdentifier.hasPrefix("com.apple.") + } + + private static func cleaned(_ label: String) -> String { + let label = label.replacingOccurrences(of: "$__lazy_storage_$_", with: "") + return label.hasPrefix("_") ? String(label.dropFirst()) : label + } +} diff --git a/Sources/DeallocTests/Diagnostics/LeakReport.swift b/Sources/DeallocTests/Diagnostics/LeakReport.swift index 3c29935..2782497 100644 --- a/Sources/DeallocTests/Diagnostics/LeakReport.swift +++ b/Sources/DeallocTests/Diagnostics/LeakReport.swift @@ -9,10 +9,23 @@ struct LeakReport: Sendable { let typeName: String let timeout: Duration + let hints: [String] + + init(typeName: String, timeout: Duration, hints: [String] = []) { + self.typeName = typeName + self.timeout = timeout + self.hints = hints + } 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." + let summary = "\(typeName) was not deallocated within \(DurationText.describe(timeout))." + + guard !hints.isEmpty else { + return summary + " 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." + } + + let causes = hints + ["Or something outside still holds it: a parent's list of children, a cache or a singleton"] + return summary + " Possible causes:\n" + causes.map { " • \($0)" }.joined(separator: "\n") } } diff --git a/Sources/DeallocTests/Expectation/DeallocationTracker.swift b/Sources/DeallocTests/Expectation/DeallocationTracker.swift index 8cc980a..50ca678 100644 --- a/Sources/DeallocTests/Expectation/DeallocationTracker.swift +++ b/Sources/DeallocTests/Expectation/DeallocationTracker.swift @@ -13,6 +13,8 @@ final class DeallocationTracker { let location: TestSourceLocation } + @TaskLocal static var current: DeallocationTracker? + private var trackedObjects = [TrackedObject]() func track(_ object: AnyObject, at location: TestSourceLocation) { @@ -21,7 +23,7 @@ final class DeallocationTracker { ) } - func verifyDeallocation(timeout: Duration) async { + func verifyDeallocation(timeout: Duration = .seconds(2)) async { let objects = trackedObjects trackedObjects.removeAll() @@ -33,9 +35,17 @@ final class DeallocationTracker { return } - for trackedObject in objects where trackedObject.object != nil { + for trackedObject in objects { + guard let object = trackedObject.object else { + continue + } + reportIssue( - LeakReport(typeName: trackedObject.typeName, timeout: timeout).message, + LeakReport( + typeName: trackedObject.typeName, + timeout: timeout, + hints: LeakHints.hints(for: object) + ).message, at: trackedObject.location ) } diff --git a/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift b/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift new file mode 100644 index 0000000..5dc2957 --- /dev/null +++ b/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift @@ -0,0 +1,71 @@ +// +// ExpectDeallocation+DependencyInjection.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +#if DependencyInjection + +import DependencyInjection + +/// 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) +} + +@MainActor +private func resolveAndTrack( + _ type: Dependency.Type, + from container: AsyncContainer, + tracker: DeallocationTracker, + location: TestSourceLocation +) async -> Bool { + let dependency = await container.resolve(type: type) + + guard Mirror(reflecting: dependency).displayStyle == .class else { + let concreteType = Swift.type(of: dependency as Any) + reportIssue( + "\(concreteType) resolved for \(type) is a value type, so it can't leak. Check the class instances it holds instead.", + 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 index 0e1c4c4..66f748c 100644 --- a/Sources/DeallocTests/Expectation/ExpectDeallocation.swift +++ b/Sources/DeallocTests/Expectation/ExpectDeallocation.swift @@ -7,7 +7,8 @@ /// 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. +/// Works in Swift Testing and XCTest. A leak is reported at the line that calls this function, +/// with hints about properties that commonly cause leaks. /// /// ```swift /// @Test func secondScreenDoesNotLeak() async { @@ -22,6 +23,7 @@ /// - 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. +/// Objects passed to `trackForDeallocation(_:)` inside it are checked too. @MainActor public func expectDeallocation( _ lifecycle: Lifecycle = .none, @@ -51,7 +53,9 @@ private func createAndRun( tracker: DeallocationTracker, location: TestSourceLocation ) async rethrows -> Bool { - let object = try await makeObject() - tracker.track(object, at: location) - return await lifecycle.run(object, location) + try await DeallocationTracker.$current.withValue(tracker) { + let object = try await makeObject() + tracker.track(object, at: location) + return await lifecycle.run(object, location) + } } diff --git a/Sources/DeallocTests/Expectation/TrackForDeallocation.swift b/Sources/DeallocTests/Expectation/TrackForDeallocation.swift new file mode 100644 index 0000000..08df116 --- /dev/null +++ b/Sources/DeallocTests/Expectation/TrackForDeallocation.swift @@ -0,0 +1,124 @@ +// +// 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, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) -> Object { + let location = TestSourceLocation(fileID: fileID, filePath: filePath, line: line, column: column) + + if let tracker = DeallocationTracker.current { + tracker.track(object, at: location) + return object + } + + let tracker = DeallocationTracker() + tracker.track(object, at: location) + + addTeardownBlock { @MainActor in + await tracker.verifyDeallocation() + } + + return object + } +} + +// MARK: - Swift Testing + +/// Checks that the object deallocates when the test ends. Requires the `.checksDeallocation` trait, +/// or a call inside `expectDeallocation`, which then checks the object together with the tested one. +/// +/// ```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, " + + "or a call inside expectDeallocation. In XCTest, call it on the test case.", + at: location + ) + return object + } + + tracker.track(object, at: location) + return object +} + +#if canImport(Testing) + +/// Checks that every object passed to `trackForDeallocation(_:)` deallocates when the test ends +public struct DeallocationCheckTrait: TestTrait, SuiteTrait, TestScoping { + 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() + } +} + +public extension Trait where Self == DeallocationCheckTrait { + /// Checks that every object passed to `trackForDeallocation(_:)` deallocates when the test ends + static var checksDeallocation: Self { + Self() + } +} + +#endif diff --git a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift index 2e88888..3fe1eb0 100644 --- a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift +++ b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift @@ -8,6 +8,10 @@ import DeallocTests import Testing +#if DependencyInjection + import DependencyInjection +#endif + #if canImport(AppKit) import AppKit #endif @@ -24,6 +28,14 @@ final class RetainCycleObject { } } +protocol AnyService: Sendable {} + +protocol Service: AnyObject, Sendable {} + +final class ContainerService: Service {} + +struct ValueService: AnyService {} + @MainActor final class Cache { var objects = [AnyObject]() @@ -160,3 +172,87 @@ struct ExpectDeallocationTests { } } #endif + +// MARK: - Dependency Injection + +#if DependencyInjection + +@Suite("expectDeallocation with AsyncContainer") +@MainActor +struct ExpectDeallocationDependencyInjectionTests { + let container = AsyncContainer() + + @Test func sharedInstanceIsReleasedWithContainer() async { + await container.register(type: Service.self, in: .shared) { _ in ContainerService() } + + await expectDeallocation(of: Service.self, resolvedFrom: container) + } + + @Test func newInstanceIsChecked() async { + await container.register(type: Service.self, in: .new) { _ in ContainerService() } + + 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.hasPrefix("ValueService resolved for AnyService is a value type") } + } + } +} + +#endif + +// MARK: - trackForDeallocation + +@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.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 { + let object = trackForDeallocation(PlainObject()) + + @Test func storedPropertyIsChecked() { + _ = object + } + + @Test(arguments: [1, 2, 3]) + func parameterizedTestIsChecked(value: Int) { + _ = trackForDeallocation(PlainObject()) + } +} diff --git a/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift b/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift index 042fcdd..128c0b4 100644 --- a/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift +++ b/Tests/DeallocTestsTests/LeakHintsAndSwiftUITests.swift @@ -5,12 +5,66 @@ // Copyright © 2026 STRV. All rights reserved. // +import Combine +import Observation import DeallocTests import SwiftUI import Testing // MARK: - Fixtures +final class ClosureLeak { + var onUpdate: (() -> Void)? + + init() { + onUpdate = { _ = self } + } +} + +@available(macOS 14, iOS 17, *) +@Observable +final class ObservableClosureLeak { + var onUpdate: (() -> Void)? + + init() { + onUpdate = { _ = self } + } +} + +final class CycleParent { + var child: CycleChild? + + init() { + child = CycleChild(parent: self) + } +} + +final class CycleChild { + let parent: CycleParent + + init(parent: CycleParent) { + self.parent = parent + } +} + +final class SubscriptionLeak { + let updates = PassthroughSubject() + var cancellables = Set() + var value = 0 + + init() { + updates.sink { self.value = $0 }.store(in: &cancellables) + } +} + +final class OwnerObject { + let viewModel: PlainObject + + init(viewModel: PlainObject) { + self.viewModel = viewModel + } +} + @MainActor final class TaskLeakModel { var task: Task? @@ -57,6 +111,112 @@ struct TaskModifierView: View { } } +func isLeakReport(of typeName: String, mentioning hint: String) -> (Issue) -> Bool { + { issue in + isLeakReport(of: typeName)(issue) && issue.comments.contains { $0.rawValue.contains(hint) } + } +} + +final class SelfReference { + var me: SelfReference? + + init() { + me = self + } +} + +// MARK: - Leak hints + +@Suite("Leak hints") +@MainActor +struct LeakHintsTests { + @Test func closurePropertyIsNamed() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { ClosureLeak() } + } matching: { issue in + isLeakReport(of: "ClosureLeak", mentioning: "`onUpdate` is a closure")(issue) + } + } + + @Test @available(macOS 14, iOS 17, *) + func observablePropertyNamesAreReadable() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { ObservableClosureLeak() } + } matching: { issue in + isLeakReport(of: "ObservableClosureLeak", mentioning: "• `onUpdate` is a closure")(issue) + && !issue.comments.contains { $0.rawValue.contains("observationRegistrar") } + } + } + + @Test func propertyCycleIsShown() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { CycleParent() } + } matching: { issue in + isLeakReport(of: "CycleParent", mentioning: "`self.child.parent` refers back to the object")(issue) + } + } + + @Test func directSelfReferenceIsShown() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { SelfReference() } + } matching: { issue in + isLeakReport(of: "SelfReference", mentioning: "`self.me` refers back to the object")(issue) + } + } + + @Test func subscriptionIsNamed() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { SubscriptionLeak() } + } matching: { issue in + isLeakReport(of: "SubscriptionLeak", mentioning: "`cancellables` is a Combine subscription")(issue) + } + } + + @Test func hintsMentionExternalOwners() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { ClosureLeak() } + } matching: { issue in + isLeakReport(of: "ClosureLeak", mentioning: "something outside still holds it")(issue) + } + } + + @Test func leakWithoutSuspectsGetsGenericMessage() async { + let cache = Cache() + + await withKnownIssue { + await expectDeallocation(.custom { cache.objects.append($0) }, timeout: .milliseconds(100)) { PlainObject() } + } matching: { issue in + isLeakReport(of: "PlainObject", mentioning: "Something still holds a strong reference")(issue) + } + } +} + +// MARK: - Tracking objects inside expectDeallocation + +@Suite("trackForDeallocation inside expectDeallocation") +@MainActor +struct NestedTrackingTests { + @Test func trackedChildPasses() async { + await expectDeallocation { + OwnerObject(viewModel: trackForDeallocation(PlainObject())) + } + } + + @Test func leakedChildIsReported() async { + let cache = Cache() + + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(100)) { + let viewModel = trackForDeallocation(PlainObject()) + cache.objects.append(viewModel) + return OwnerObject(viewModel: viewModel) + } + } matching: { issue in + isLeakReport(of: "PlainObject")(issue) + } + } +} + // MARK: - SwiftUI @Suite("SwiftUI hosting", .serialized) @@ -83,7 +243,7 @@ struct SwiftUIHostingTests { TaskLeakModel() } } matching: { issue in - isLeakReport(of: "TaskLeakModel")(issue) + isLeakReport(of: "TaskLeakModel", mentioning: "`task` is a task")(issue) } } } diff --git a/Tests/DeallocTestsTests/LeakReportTests.swift b/Tests/DeallocTestsTests/LeakReportTests.swift index 0cec0c6..a559c80 100644 --- a/Tests/DeallocTestsTests/LeakReportTests.swift +++ b/Tests/DeallocTestsTests/LeakReportTests.swift @@ -5,11 +5,31 @@ // Copyright © 2026 STRV. All rights reserved. // +import Combine @testable import DeallocTests +import Foundation import Testing private final class PrivateObject {} +final class ClosureHolder { + var onUpdate: (() -> Void)? +} + +final class TimerService {} + +final class UnusedOptionals { + var onUpdate: (() -> Void)? + var timer: Timer? + var task: Task? + var cancellables = Set() + let timerService = TimerService() +} + +final class TimerHolder { + var timer: Timer? = Timer(timeInterval: 60, repeats: false) { _ in } +} + @Suite("Leak reports") struct LeakReportTests { @Test func readableNameDropsUnknownContext() { @@ -24,6 +44,16 @@ struct LeakReportTests { #expect(report.message.hasPrefix("App.Screen was not deallocated within 2 sec. Something still holds")) } + @Test func messageWithHintsListsCausesAndExternalOwners() { + let report = LeakReport(typeName: "App.Screen", timeout: .milliseconds(500), hints: ["`onUpdate` is a closure"]) + + #expect(report.message == """ + App.Screen was not deallocated within 500 ms. Possible causes: + • `onUpdate` is a closure + • Or something outside still holds it: a parent's list of children, a cache or a singleton + """) + } + @Test(arguments: [ (Duration.milliseconds(400), "400 ms"), (.seconds(2), "2 sec"), @@ -34,6 +64,21 @@ struct LeakReportTests { #expect(DurationText.describe(duration) == text) } + @Test func hintsNameClosureProperties() { + let holder = ClosureHolder() + holder.onUpdate = {} + + #expect(LeakHints.hints(for: holder).contains { $0.hasPrefix("`onUpdate` is a closure") }) + } + + @Test func hintsSkipEmptyOptionalsAndCollections() { + #expect(LeakHints.hints(for: UnusedOptionals()).isEmpty) + } + + @Test func hintsNameTimersByType() { + #expect(LeakHints.hints(for: TimerHolder()).contains { $0.hasPrefix("`timer` is a timer") }) + } + @Test @MainActor func pollingReturnsAsSoonAsTheConditionHolds() async { let clock = ContinuousClock() let start = clock.now diff --git a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift index 7973507..95e91d8 100644 --- a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift +++ b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift @@ -9,6 +9,19 @@ 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()) + } + @MainActor func test_expectDeallocation_cleanObject_passes() async { await expectDeallocation { PlainObject() } @@ -20,4 +33,16 @@ final class TrackForDeallocationXCTests: XCTestCase { await expectDeallocation(timeout: .milliseconds(100)) { RetainCycleObject() } } + + @MainActor + func test_trackedChildInsideExpectDeallocation_isCheckedWithIt() async { + XCTExpectFailure("The child is kept alive by the cache") + let cache = Cache() + + await expectDeallocation(timeout: .milliseconds(100)) { + let child = trackForDeallocation(PlainObject()) + cache.objects.append(child) + return OwnerObject(viewModel: child) + } + } } From 0810f0f8a8410db431bc478537728f38bdb34da2 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 03/12] feat: deallocation configuration, warning severity and grace period `DeallocationConfiguration` (timeout, severity, grace period) is set per Swift Testing test or suite with traits, and in XCTest with `withDeallocationConfiguration` around a test's code or `invokeTest()`. `timeout` stays a parameter of `expectDeallocation` because it describes the object; the rest is configuration only. An object is checked with the configuration in effect where it was tracked. `.deallocationIssues(.warning)` reports leaks without failing the test, for adopting dealloc tests in an existing project. An object still alive at the timeout is watched for a grace period (3 s); if it goes away then, it's reported as bounded retention, a warning, instead of a leak. Passing checks never wait for it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../DeallocTests/Diagnostics/LeakReport.swift | 15 +- .../DeallocationConfiguration.swift | 138 ++++++++++++++++++ .../Expectation/DeallocationTracker.swift | 43 +++++- ...pectDeallocation+DependencyInjection.swift | 4 +- .../Expectation/ExpectDeallocation.swift | 4 +- .../Expectation/IssueReporting.swift | 30 +++- .../Expectation/TrackForDeallocation.swift | 1 + .../ConfigurationTests.swift | 104 +++++++++++++ .../ExpectDeallocationTests.swift | 4 +- .../DeallocTestsTests/GracePeriodTests.swift | 61 ++++++++ Tests/DeallocTestsTests/LeakReportTests.swift | 12 ++ .../TrackForDeallocationXCTests.swift | 66 ++++++++- 12 files changed, 466 insertions(+), 16 deletions(-) create mode 100644 Sources/DeallocTests/Expectation/DeallocationConfiguration.swift create mode 100644 Tests/DeallocTestsTests/ConfigurationTests.swift create mode 100644 Tests/DeallocTestsTests/GracePeriodTests.swift diff --git a/Sources/DeallocTests/Diagnostics/LeakReport.swift b/Sources/DeallocTests/Diagnostics/LeakReport.swift index 2782497..5bee5a0 100644 --- a/Sources/DeallocTests/Diagnostics/LeakReport.swift +++ b/Sources/DeallocTests/Diagnostics/LeakReport.swift @@ -9,16 +9,27 @@ struct LeakReport: Sendable { let typeName: String let timeout: Duration + let gracePeriod: Duration let hints: [String] - init(typeName: String, timeout: Duration, hints: [String] = []) { + init(typeName: String, timeout: Duration, gracePeriod: Duration = .zero, hints: [String] = []) { self.typeName = typeName self.timeout = timeout + self.gracePeriod = gracePeriod self.hints = hints } + static func lateReleaseMessage(typeName: String, releasedAfter: Duration, timeout: Duration) -> String { + "\(typeName) was released after \(DurationText.describe(releasedAfter)), later than the \(DurationText.describe(timeout)) timeout. " + + "That's bounded retention, not a leak: something kept it alive for a while, e.g. a task, " + + "an animation or a delayed callback. If that's expected, raise the timeout." + } + var message: String { - let summary = "\(typeName) was not deallocated within \(DurationText.describe(timeout))." + var summary = "\(typeName) was not deallocated within \(DurationText.describe(timeout))." + if gracePeriod > .zero { + summary += " It was watched for another \(DurationText.describe(gracePeriod)) after that." + } guard !hints.isEmpty else { return summary + " Something still holds a strong reference to it: look for closures capturing self, " diff --git a/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift b/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift new file mode 100644 index 0000000..4f9c5e5 --- /dev/null +++ b/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift @@ -0,0 +1,138 @@ +// +// DeallocationConfiguration.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +#if canImport(Testing) + import Testing +#endif + +/// How deallocation checks behave by default. +/// +/// An object is checked with the configuration in effect where it's tracked. Only the timeout +/// can also be passed to `expectDeallocation`, because it describes the object. Set the +/// configuration for a Swift Testing suite or test with traits such as `.deallocationTimeout(_:)`, +/// or for a block of code with `withDeallocationConfiguration(_:operation:)`. +public struct DeallocationConfiguration: Sendable { + /// How long a check waits for the objects to deallocate + public var timeout: Duration = .seconds(2) + /// How much longer to keep watching objects that are still alive after the timeout. + /// An object released in that time is reported as a warning (bounded retention, not a + /// leak) instead of a failure. `.zero` turns it off. Not used when `severity` is `.warning`, + /// because a leak is then reported as a warning anyway. + public var gracePeriod: Duration = .seconds(3) + /// Whether a leak fails the test or is reported as a warning + public var severity: DeallocationIssueSeverity = .error + + public init() {} + + /// The configuration that applies to the current task + @TaskLocal public static var current = DeallocationConfiguration() +} + +/// How a leak is reported +public enum DeallocationIssueSeverity: Sendable { + /// The test fails + case error + /// The test passes and the leak is shown as a warning, e.g. while adopting dealloc tests + case warning +} + +/// Runs the operation with a changed deallocation configuration. +/// +/// The way to configure checks in XCTest, which has no traits: +/// +/// ```swift +/// func test_profileScreen() async { +/// await withDeallocationConfiguration({ $0.timeout = .seconds(5) }) { +/// await expectDeallocation(.present) { makeProfileViewController() } +/// } +/// } +/// ``` +/// +/// It runs on the caller's actor, so it can be called from `@MainActor` tests. +public func withDeallocationConfiguration( + _ change: (inout DeallocationConfiguration) -> Void, + isolation: isolated (any Actor)? = #isolation, + operation: () async throws -> Result +) async rethrows -> Result { + var configuration = DeallocationConfiguration.current + change(&configuration) + return try await DeallocationConfiguration.$current.withValue(configuration) { + try await operation() + } +} + +/// Runs the operation with a changed deallocation configuration. +/// +/// The synchronous variant, e.g. for a whole XCTest test case class: +/// +/// ```swift +/// final class LegacyDeallocTests: XCTestCase { +/// override func invokeTest() { +/// withDeallocationConfiguration({ $0.severity = .warning }) { +/// super.invokeTest() +/// } +/// } +/// } +/// ``` +public func withDeallocationConfiguration( + _ change: (inout DeallocationConfiguration) -> Void, + operation: () throws -> Result +) rethrows -> Result { + var configuration = DeallocationConfiguration.current + change(&configuration) + return try DeallocationConfiguration.$current.withValue(configuration) { + try operation() + } +} + +#if canImport(Testing) + +/// Changes the deallocation configuration for a test, or for every test in a suite. +/// A test's own trait wins over its suite's. +public struct DeallocationConfigurationTrait: TestTrait, SuiteTrait, TestScoping { + let change: @Sendable (inout DeallocationConfiguration) -> Void + + public var isRecursive: Bool { + true + } + + public func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: @Sendable () async throws -> Void + ) async throws { + var configuration = DeallocationConfiguration.current + change(&configuration) + try await DeallocationConfiguration.$current.withValue(configuration) { + try await function() + } + } +} + +public extension Trait where Self == DeallocationConfigurationTrait { + /// How long deallocation checks wait for objects to deallocate + /// + /// ```swift + /// @Suite(.deallocationTimeout(.seconds(5))) + /// struct ScreenDeallocTests { … } + /// ``` + static func deallocationTimeout(_ timeout: Duration) -> Self { + Self { $0.timeout = timeout } + } + + /// How much longer to watch objects still alive after the timeout before calling them leaked + static func deallocationGracePeriod(_ gracePeriod: Duration) -> Self { + Self { $0.gracePeriod = gracePeriod } + } + + /// Whether leaks fail the test (`.error`, the default) or are reported as warnings + static func deallocationIssues(_ severity: DeallocationIssueSeverity) -> Self { + Self { $0.severity = severity } + } +} + +#endif diff --git a/Sources/DeallocTests/Expectation/DeallocationTracker.swift b/Sources/DeallocTests/Expectation/DeallocationTracker.swift index 50ca678..c609b93 100644 --- a/Sources/DeallocTests/Expectation/DeallocationTracker.swift +++ b/Sources/DeallocTests/Expectation/DeallocationTracker.swift @@ -5,6 +5,7 @@ // Copyright © 2026 STRV. All rights reserved. // + @MainActor final class DeallocationTracker { private struct TrackedObject { @@ -16,26 +17,60 @@ final class DeallocationTracker { @TaskLocal static var current: DeallocationTracker? private var trackedObjects = [TrackedObject]() + private var configuration = DeallocationConfiguration.current func track(_ object: AnyObject, at location: TestSourceLocation) { + if trackedObjects.isEmpty { + configuration = DeallocationConfiguration.current + } + trackedObjects.append( TrackedObject(object: object, typeName: TypeNames.readableName(of: object), location: location) ) } - func verifyDeallocation(timeout: Duration = .seconds(2)) async { + func verifyDeallocation(timeout: Duration? = nil) async { let objects = trackedObjects trackedObjects.removeAll() + let timeout = timeout ?? configuration.timeout + let gracePeriod = configuration.severity == .error ? configuration.gracePeriod : .zero + let clock = ContinuousClock() + let start = clock.now + _ = await Polling.waitUntil(timeout: timeout) { !objects.contains { $0.object != nil } } + var lateReleases = [(TrackedObject, Duration)]() + var pending = objects.filter { $0.object != nil } + + if !pending.isEmpty, gracePeriod > .zero { + _ = await Polling.waitUntil(timeout: gracePeriod) { + pending.removeAll { trackedObject in + guard trackedObject.object == nil else { + return false + } + lateReleases.append((trackedObject, clock.now - start)) + return true + } + return pending.isEmpty + } + } + guard !Task.isCancelled else { return } - for trackedObject in objects { + for (trackedObject, releasedAfter) in lateReleases { + reportIssue( + LeakReport.lateReleaseMessage(typeName: trackedObject.typeName, releasedAfter: releasedAfter, timeout: timeout), + at: trackedObject.location, + severity: .warning + ) + } + + for trackedObject in pending { guard let object = trackedObject.object else { continue } @@ -44,9 +79,11 @@ final class DeallocationTracker { LeakReport( typeName: trackedObject.typeName, timeout: timeout, + gracePeriod: gracePeriod, hints: LeakHints.hints(for: object) ).message, - at: trackedObject.location + at: trackedObject.location, + severity: configuration.severity ) } } diff --git a/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift b/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift index 5dc2957..1698d5c 100644 --- a/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift +++ b/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift @@ -24,12 +24,12 @@ import DependencyInjection /// - 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 +/// - timeout: How long to wait for the object to deallocate. Defaults to `DeallocationConfiguration.current.timeout` @MainActor public func expectDeallocation( of type: Dependency.Type, resolvedFrom container: AsyncContainer, - timeout: Duration = .seconds(2), + timeout: Duration? = nil, fileID: StaticString = #fileID, filePath: StaticString = #filePath, line: UInt = #line, diff --git a/Sources/DeallocTests/Expectation/ExpectDeallocation.swift b/Sources/DeallocTests/Expectation/ExpectDeallocation.swift index 66f748c..2fb31d0 100644 --- a/Sources/DeallocTests/Expectation/ExpectDeallocation.swift +++ b/Sources/DeallocTests/Expectation/ExpectDeallocation.swift @@ -20,14 +20,14 @@ /// /// - 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 +/// - timeout: How long to wait for the object to deallocate. Defaults to `DeallocationConfiguration.current.timeout` /// - 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. /// Objects passed to `trackForDeallocation(_:)` inside it are checked too. @MainActor public func expectDeallocation( _ lifecycle: Lifecycle = .none, - timeout: Duration = .seconds(2), + timeout: Duration? = nil, afterRelease: @MainActor () async -> Void = {}, fileID: StaticString = #fileID, filePath: StaticString = #filePath, diff --git a/Sources/DeallocTests/Expectation/IssueReporting.swift b/Sources/DeallocTests/Expectation/IssueReporting.swift index 239664c..948e8d0 100644 --- a/Sources/DeallocTests/Expectation/IssueReporting.swift +++ b/Sources/DeallocTests/Expectation/IssueReporting.swift @@ -18,20 +18,29 @@ struct TestSourceLocation: Sendable { let column: UInt } -func reportIssue(_ message: String, at location: TestSourceLocation) { +func reportIssue(_ message: String, at location: TestSourceLocation, severity: DeallocationIssueSeverity = .error) { #if canImport(Testing) if Test.current != nil { - recordSwiftTestingIssue(message, at: location) + recordSwiftTestingIssue(message, at: location, severity: severity) return } #endif - XCTFail(message, file: location.filePath, line: location.line) + switch severity { + case .error: + XCTFail(message, file: location.filePath, line: location.line) + case .warning: + let options = XCTExpectedFailure.Options() + options.isStrict = false + XCTExpectFailure("Reported as a warning", options: options) { + XCTFail(message, file: location.filePath, line: location.line) + } + } } #if canImport(Testing) -private func recordSwiftTestingIssue(_ message: String, at location: TestSourceLocation) { +private func recordSwiftTestingIssue(_ message: String, at location: TestSourceLocation, severity: DeallocationIssueSeverity) { let sourceLocation = SourceLocation( fileID: String(describing: location.fileID), filePath: String(describing: location.filePath), @@ -39,7 +48,18 @@ private func recordSwiftTestingIssue(_ message: String, at location: TestSourceL column: Int(location.column) ) - Issue.record(Comment(rawValue: message), sourceLocation: sourceLocation) + switch severity { + case .error: + Issue.record(Comment(rawValue: message), sourceLocation: sourceLocation) + case .warning: + #if compiler(>=6.3) + Issue.record(Comment(rawValue: message), severity: .warning, sourceLocation: sourceLocation) + #else + withKnownIssue("Reported as a warning", isIntermittent: true) { + Issue.record(Comment(rawValue: message), sourceLocation: sourceLocation) + } + #endif + } } #endif diff --git a/Sources/DeallocTests/Expectation/TrackForDeallocation.swift b/Sources/DeallocTests/Expectation/TrackForDeallocation.swift index 08df116..e9d706f 100644 --- a/Sources/DeallocTests/Expectation/TrackForDeallocation.swift +++ b/Sources/DeallocTests/Expectation/TrackForDeallocation.swift @@ -17,6 +17,7 @@ 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. + /// The check uses the `DeallocationConfiguration` in effect where this method is called. /// /// ```swift /// func test_viewModel() { diff --git a/Tests/DeallocTestsTests/ConfigurationTests.swift b/Tests/DeallocTestsTests/ConfigurationTests.swift new file mode 100644 index 0000000..99ab1c6 --- /dev/null +++ b/Tests/DeallocTestsTests/ConfigurationTests.swift @@ -0,0 +1,104 @@ +// +// ConfigurationTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import DeallocTests +import Testing + +func isLeakReport(within timeout: String) -> (Issue) -> Bool { + { issue in + issue.comments.contains { $0.rawValue.hasPrefix("DeallocTestsTests.RetainCycleObject was not deallocated within \(timeout).") } + } +} + +@Suite("Deallocation configuration", .deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.zero)) +@MainActor +struct ConfigurationTests { + @Test func suiteTimeoutApplies() async { + let clock = ContinuousClock() + let start = clock.now + + await withKnownIssue { + await expectDeallocation { RetainCycleObject() } + } matching: { issue in + isLeakReport(within: "100 ms")(issue) + } + + #expect(clock.now - start < .seconds(1), "the default 2 s timeout must not apply") + } + + @Test(.deallocationTimeout(.milliseconds(300))) + func testTraitWinsOverSuiteTrait() async { + await withKnownIssue { + await expectDeallocation { RetainCycleObject() } + } matching: { issue in + isLeakReport(within: "300 ms")(issue) + } + } + + @Test func explicitTimeoutWinsOverTraits() async { + await withKnownIssue { + await expectDeallocation(timeout: .milliseconds(50)) { RetainCycleObject() } + } matching: { issue in + isLeakReport(within: "50 ms")(issue) + } + } + + @Test func withDeallocationConfigurationChangesTheTimeout() async { + await withKnownIssue { + await withDeallocationConfiguration({ $0.timeout = .milliseconds(150) }) { + await expectDeallocation { RetainCycleObject() } + } + } matching: { issue in + isLeakReport(within: "150 ms")(issue) + } + } + + @Test(.deallocationIssues(.warning)) + func warningSeverityDoesNotFailTheTest() async { + await expectDeallocation { RetainCycleObject() } + } + + @Test func trackedObjectsUseTheConfiguredTimeout() async throws { + try await withKnownIssue { + try await DeallocationCheckTrait.checksDeallocation.provideScope( + for: #require(Test.current), + testCase: Test.Case.current, + performing: { + await MainActor.run { + _ = trackForDeallocation(RetainCycleObject()) + } + } + ) + } matching: { issue in + isLeakReport(within: "100 ms")(issue) + } + } + + @Test func trackedObjectsUseTheConfigurationOfANestedTrait() async throws { + let test = try #require(Test.current) + + try await withKnownIssue { + try await DeallocationCheckTrait.checksDeallocation.provideScope( + for: test, + testCase: Test.Case.current, + performing: { + try await DeallocationConfigurationTrait.deallocationTimeout(.milliseconds(130)).provideScope( + for: test, + testCase: Test.Case.current, + performing: { + await MainActor.run { + _ = trackForDeallocation(RetainCycleObject()) + } + } + ) + } + ) + } matching: { issue in + isLeakReport(within: "130 ms")(issue) + } + } +} diff --git a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift index 3fe1eb0..2413eb1 100644 --- a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift +++ b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift @@ -224,7 +224,9 @@ struct TrackForDeallocationTests { testCase: Test.Case.current, performing: { await MainActor.run { - _ = trackForDeallocation(RetainCycleObject()) + withDeallocationConfiguration({ $0.timeout = .milliseconds(100) }) { + _ = trackForDeallocation(RetainCycleObject()) + } } } ) diff --git a/Tests/DeallocTestsTests/GracePeriodTests.swift b/Tests/DeallocTestsTests/GracePeriodTests.swift new file mode 100644 index 0000000..9a13146 --- /dev/null +++ b/Tests/DeallocTestsTests/GracePeriodTests.swift @@ -0,0 +1,61 @@ +// +// GracePeriodTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import DeallocTests +import Foundation +import Testing + +@MainActor +func makeObjectReleasedAfter(_ delay: Duration) -> PlainObject { + let object = PlainObject() + Task { @MainActor in + try? await Task.sleep(for: delay) + withExtendedLifetime(object) {} + } + return object +} + +@Suite("Grace period") +@MainActor +struct GracePeriodTests { + @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.seconds(2))) + func lateReleaseIsAWarning() async { + await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + } + + @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.milliseconds(300))) + func leakIsStillAFailureAfterTheGracePeriod() async { + await withKnownIssue { + await expectDeallocation { RetainCycleObject() } + } matching: { issue in + issue.comments.contains { + $0.rawValue.hasPrefix( + "DeallocTestsTests.RetainCycleObject was not deallocated within 100 ms. It was watched for another 300 ms after that." + ) + } + } + } + + @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.zero)) + func zeroGracePeriodTurnsItOff() async { + await withKnownIssue { + await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + } matching: { issue in + issue.comments.contains { $0.rawValue.hasPrefix("DeallocTestsTests.PlainObject was not deallocated within 100 ms. Something") } + } + } + + @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.seconds(3)), .deallocationIssues(.warning)) + func warningSeveritySkipsTheGracePeriod() async { + let clock = ContinuousClock() + let start = clock.now + + await expectDeallocation { RetainCycleObject() } + + #expect(clock.now - start < .seconds(1)) + } +} diff --git a/Tests/DeallocTestsTests/LeakReportTests.swift b/Tests/DeallocTestsTests/LeakReportTests.swift index a559c80..520c528 100644 --- a/Tests/DeallocTestsTests/LeakReportTests.swift +++ b/Tests/DeallocTestsTests/LeakReportTests.swift @@ -64,6 +64,18 @@ struct LeakReportTests { #expect(DurationText.describe(duration) == text) } + @Test func lateReleaseMessageExplainsBoundedRetention() { + let message = LeakReport.lateReleaseMessage(typeName: "App.Store", releasedAfter: .milliseconds(3250), timeout: .seconds(2)) + + #expect(message.hasPrefix("App.Store was released after 3.2 sec, later than the 2 sec timeout. That's bounded retention, not a leak")) + } + + @Test func messageMentionsTheGracePeriod() { + let report = LeakReport(typeName: "App.Screen", timeout: .seconds(2), gracePeriod: .seconds(3)) + + #expect(report.message.hasPrefix("App.Screen was not deallocated within 2 sec. It was watched for another 3 sec after that.")) + } + @Test func hintsNameClosureProperties() { let holder = ClosureHolder() holder.onUpdate = {} diff --git a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift index 95e91d8..30df3a3 100644 --- a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift +++ b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift @@ -9,6 +9,23 @@ import DeallocTests import XCTest final class TrackForDeallocationXCTests: XCTestCase { + override func invokeTest() { + withDeallocationConfiguration({ $0.gracePeriod = .zero }) { + super.invokeTest() + } + } + + @MainActor + func test_invokeTestConfiguration_appliesToTheTest() async { + let options = XCTExpectedFailure.Options() + options.issueMatcher = { issue in + issue.compactDescription.contains("within 100 ms.") && !issue.compactDescription.contains("watched for another") + } + XCTExpectFailure("RetainCycleObject has a retain cycle", options: options) + + await expectDeallocation(timeout: .milliseconds(100)) { RetainCycleObject() } + } + @MainActor func test_trackedObject_passes() { let object = trackForDeallocation(PlainObject()) @@ -19,7 +36,9 @@ final class TrackForDeallocationXCTests: XCTestCase { func test_trackedLeak_fails() { XCTExpectFailure("RetainCycleObject has a retain cycle") - trackForDeallocation(RetainCycleObject()) + withDeallocationConfiguration({ $0.timeout = .milliseconds(100) }) { + _ = trackForDeallocation(RetainCycleObject()) + } } @MainActor @@ -45,4 +64,49 @@ final class TrackForDeallocationXCTests: XCTestCase { return OwnerObject(viewModel: child) } } + + @MainActor + func test_withDeallocationConfiguration_changesTheTimeout() async { + let options = XCTExpectedFailure.Options() + options.issueMatcher = { $0.compactDescription.contains("within 120 ms") } + XCTExpectFailure("RetainCycleObject has a retain cycle", options: options) + + await withDeallocationConfiguration({ $0.timeout = .milliseconds(120) }) { + await expectDeallocation { RetainCycleObject() } + } + } + + @MainActor + func test_trackedObject_usesTheConfigurationItWasTrackedWith() { + let options = XCTExpectedFailure.Options() + options.issueMatcher = { $0.compactDescription.contains("within 130 ms") } + XCTExpectFailure("RetainCycleObject has a retain cycle", options: options) + + withDeallocationConfiguration({ + $0.timeout = .milliseconds(130) + $0.gracePeriod = .zero + }) { + _ = trackForDeallocation(RetainCycleObject()) + } + } + + @MainActor + func test_warningSeverity_doesNotFailTheTest() async { + await withDeallocationConfiguration({ + $0.timeout = .milliseconds(100) + $0.severity = .warning + }) { + await expectDeallocation { RetainCycleObject() } + } + } + + @MainActor + func test_lateRelease_isAWarning() async { + await withDeallocationConfiguration({ + $0.timeout = .milliseconds(100) + $0.gracePeriod = .seconds(2) + }) { + await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + } + } } 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 04/12] 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 324567316b011dc7d3315799b9e8f006f3c12db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Mon, 5 Oct 2026 18:13:39 +0200 Subject: [PATCH 05/12] style: no doc comment on the internal LeakHints Doc comments are kept for public API only. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/DeallocTests/Diagnostics/LeakHints.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/DeallocTests/Diagnostics/LeakHints.swift b/Sources/DeallocTests/Diagnostics/LeakHints.swift index 4347eb2..1edffae 100644 --- a/Sources/DeallocTests/Diagnostics/LeakHints.swift +++ b/Sources/DeallocTests/Diagnostics/LeakHints.swift @@ -7,7 +7,6 @@ import Foundation -/// Looks at the stored properties of a leaked object and points at the usual suspects. enum LeakHints { static let maximumCycleDepth = 4 static let maximumVisitedObjects = 300 From 70e7b6f05058132aed4243bef208668049948a3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Mon, 5 Oct 2026 18:15:07 +0200 Subject: [PATCH 06/12] test: deterministic configuration and grace period tests Two tests asserted on wall-clock time and failed on a loaded CI runner (6.2 s and 1.9 s against a 1 s limit). The suite timeout test already proves the 100 ms timeout through the failure message, so its clock check goes. The rule that warnings skip the grace period is now `DeallocationConfiguration.effectiveGracePeriod`, which the tracker uses and the test checks directly. CI also runs the tests with the latest Xcode, so the Swift 6.3 warning path (Issue.record(severity:)) is compiled and tested. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 11 +++++++++++ .../Expectation/DeallocationConfiguration.swift | 6 ++++++ .../Expectation/DeallocationTracker.swift | 2 +- Tests/DeallocTestsTests/ConfigurationTests.swift | 5 ----- Tests/DeallocTestsTests/GracePeriodTests.swift | 15 +++++++-------- 5 files changed, 25 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f32c28..7a1c6f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,17 @@ jobs: - name: Run tests run: swift test ${{ matrix.traits.flags }} + package-latest-xcode: + name: Package tests (macOS, latest Xcode) + # Warning severity uses Issue.record(severity:) from Swift 6.3; macos-15 only builds the fallback + runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + - name: Toolchain + run: swift --version + - name: Run tests + run: swift test + package-ios: name: Package tests (iOS Simulator) runs-on: macos-15 diff --git a/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift b/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift index 4f9c5e5..bc862fc 100644 --- a/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift +++ b/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift @@ -32,6 +32,12 @@ public struct DeallocationConfiguration: Sendable { @TaskLocal public static var current = DeallocationConfiguration() } +extension DeallocationConfiguration { + var effectiveGracePeriod: Duration { + severity == .error ? gracePeriod : .zero + } +} + /// How a leak is reported public enum DeallocationIssueSeverity: Sendable { /// The test fails diff --git a/Sources/DeallocTests/Expectation/DeallocationTracker.swift b/Sources/DeallocTests/Expectation/DeallocationTracker.swift index c609b93..a7bfced 100644 --- a/Sources/DeallocTests/Expectation/DeallocationTracker.swift +++ b/Sources/DeallocTests/Expectation/DeallocationTracker.swift @@ -34,7 +34,7 @@ final class DeallocationTracker { trackedObjects.removeAll() let timeout = timeout ?? configuration.timeout - let gracePeriod = configuration.severity == .error ? configuration.gracePeriod : .zero + let gracePeriod = configuration.effectiveGracePeriod let clock = ContinuousClock() let start = clock.now diff --git a/Tests/DeallocTestsTests/ConfigurationTests.swift b/Tests/DeallocTestsTests/ConfigurationTests.swift index 99ab1c6..d51a687 100644 --- a/Tests/DeallocTestsTests/ConfigurationTests.swift +++ b/Tests/DeallocTestsTests/ConfigurationTests.swift @@ -18,16 +18,11 @@ func isLeakReport(within timeout: String) -> (Issue) -> Bool { @MainActor struct ConfigurationTests { @Test func suiteTimeoutApplies() async { - let clock = ContinuousClock() - let start = clock.now - await withKnownIssue { await expectDeallocation { RetainCycleObject() } } matching: { issue in isLeakReport(within: "100 ms")(issue) } - - #expect(clock.now - start < .seconds(1), "the default 2 s timeout must not apply") } @Test(.deallocationTimeout(.milliseconds(300))) diff --git a/Tests/DeallocTestsTests/GracePeriodTests.swift b/Tests/DeallocTestsTests/GracePeriodTests.swift index 9a13146..5cf9a1d 100644 --- a/Tests/DeallocTestsTests/GracePeriodTests.swift +++ b/Tests/DeallocTestsTests/GracePeriodTests.swift @@ -5,7 +5,7 @@ // Copyright © 2026 STRV. All rights reserved. // -import DeallocTests +@testable import DeallocTests import Foundation import Testing @@ -49,13 +49,12 @@ struct GracePeriodTests { } } - @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.seconds(3)), .deallocationIssues(.warning)) - func warningSeveritySkipsTheGracePeriod() async { - let clock = ContinuousClock() - let start = clock.now + @Test func warningSeveritySkipsTheGracePeriod() { + var configuration = DeallocationConfiguration() + configuration.gracePeriod = .seconds(3) + #expect(configuration.effectiveGracePeriod == .seconds(3)) - await expectDeallocation { RetainCycleObject() } - - #expect(clock.now - start < .seconds(1)) + configuration.severity = .warning + #expect(configuration.effectiveGracePeriod == .zero) } } 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 07/12] 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 From 2bb0c92c1b314b87ff1b1f551c276c9d056b7783 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Tue, 6 Oct 2026 10:19:19 +0200 Subject: [PATCH 08/12] fix: .checksDeallocation also checks tracked objects when the test throws The trait skipped verification when the test body threw, so a failing test silently checked nothing. It now verifies before rethrowing, like XCTest's teardown blocks, which also run after a thrown error. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Expectation/TrackForDeallocation.swift | 9 +++++++-- .../ExpectDeallocationTests.swift | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/Sources/DeallocTests/Expectation/TrackForDeallocation.swift b/Sources/DeallocTests/Expectation/TrackForDeallocation.swift index 08df116..502e421 100644 --- a/Sources/DeallocTests/Expectation/TrackForDeallocation.swift +++ b/Sources/DeallocTests/Expectation/TrackForDeallocation.swift @@ -106,8 +106,13 @@ public struct DeallocationCheckTrait: TestTrait, SuiteTrait, TestScoping { let tracker = await DeallocationTracker() - try await DeallocationTracker.$current.withValue(tracker) { - try await function() + do { + try await DeallocationTracker.$current.withValue(tracker) { + try await function() + } + } catch { + await tracker.verifyDeallocation() + throw error } await tracker.verifyDeallocation() diff --git a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift index c4cc3f8..0ab541a 100644 --- a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift +++ b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift @@ -251,6 +251,25 @@ struct TrackForDeallocationTests { } } + @Test func trackedLeakIsReportedWhenTheTestThrows() async throws { + let test = try #require(Test.current) + + await withKnownIssue { + _ = try? await DeallocationCheckTrait.checksDeallocation.provideScope( + for: test, + testCase: Test.Case.current, + performing: { + await MainActor.run { + _ = trackForDeallocation(RetainCycleObject()) + } + throw LifecycleError() + } + ) + } matching: { issue in + isLeakReport(of: "RetainCycleObject")(issue) + } + } + @Test func missingTraitIsReported() { withKnownIssue { _ = trackForDeallocation(PlainObject()) From b43c82a71b880f023778d62ca970b67f9c9799f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Tue, 6 Oct 2026 10:23:44 +0200 Subject: [PATCH 09/12] fix: per-object configuration and asserted warnings - Each tracked object keeps the configuration in effect where it was tracked (timeout, grace period, severity). The tracker used the first object's configuration for all of them, so objects tracked in a different configuration scope were checked with the wrong values. One polling loop now checks every object against its own deadlines. - The warning tests assert that a warning is emitted with the expected message: in Swift Testing through withKnownIssue matching severity .warning (Swift 6.3+), in XCTest through an XCTestObservation that records expected failures. Before, they only checked that the test didn't fail. - The throwing-test tracking check uses a 100 ms timeout like its neighbours. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Expectation/DeallocationTracker.swift | 87 ++++++++++++------- .../ConfigurationTests.swift | 54 +++++++++++- .../ExpectDeallocationTests.swift | 4 +- .../DeallocTestsTests/GracePeriodTests.swift | 11 ++- .../TrackForDeallocationXCTests.swift | 47 +++++++--- 5 files changed, 156 insertions(+), 47 deletions(-) diff --git a/Sources/DeallocTests/Expectation/DeallocationTracker.swift b/Sources/DeallocTests/Expectation/DeallocationTracker.swift index a7bfced..56ffece 100644 --- a/Sources/DeallocTests/Expectation/DeallocationTracker.swift +++ b/Sources/DeallocTests/Expectation/DeallocationTracker.swift @@ -12,78 +12,101 @@ final class DeallocationTracker { weak var object: AnyObject? let typeName: String let location: TestSourceLocation + let configuration: DeallocationConfiguration + } + + private struct Check { + let trackedObject: TrackedObject + let timeout: Duration + let gracePeriod: Duration } @TaskLocal static var current: DeallocationTracker? private var trackedObjects = [TrackedObject]() - private var configuration = DeallocationConfiguration.current func track(_ object: AnyObject, at location: TestSourceLocation) { - if trackedObjects.isEmpty { - configuration = DeallocationConfiguration.current - } - trackedObjects.append( - TrackedObject(object: object, typeName: TypeNames.readableName(of: object), location: location) + TrackedObject( + object: object, + typeName: TypeNames.readableName(of: object), + location: location, + configuration: DeallocationConfiguration.current + ) ) } func verifyDeallocation(timeout: Duration? = nil) async { - let objects = trackedObjects + let checks = trackedObjects.map { trackedObject in + Check( + trackedObject: trackedObject, + timeout: timeout ?? trackedObject.configuration.timeout, + gracePeriod: trackedObject.configuration.effectiveGracePeriod + ) + } trackedObjects.removeAll() - let timeout = timeout ?? configuration.timeout - let gracePeriod = configuration.effectiveGracePeriod let clock = ContinuousClock() let start = clock.now + let longestWait = checks.map { $0.timeout + $0.gracePeriod }.max() ?? .zero + var pending = checks + var lateReleases = [(Check, Duration)]() + var leaks = [Check]() - _ = await Polling.waitUntil(timeout: timeout) { - !objects.contains { $0.object != nil } - } + _ = await Polling.waitUntil(timeout: longestWait) { + let elapsed = clock.now - start - var lateReleases = [(TrackedObject, Duration)]() - var pending = objects.filter { $0.object != nil } - - if !pending.isEmpty, gracePeriod > .zero { - _ = await Polling.waitUntil(timeout: gracePeriod) { - pending.removeAll { trackedObject in - guard trackedObject.object == nil else { - return false + pending.removeAll { check in + if check.trackedObject.object == nil { + if elapsed > check.timeout, check.gracePeriod > .zero { + lateReleases.append((check, elapsed)) } - lateReleases.append((trackedObject, clock.now - start)) return true } - return pending.isEmpty + + if elapsed >= check.timeout + check.gracePeriod { + leaks.append(check) + return true + } + + return false } + + return pending.isEmpty } + leaks += pending + guard !Task.isCancelled else { return } - for (trackedObject, releasedAfter) in lateReleases { + for (check, releasedAfter) in lateReleases { reportIssue( - LeakReport.lateReleaseMessage(typeName: trackedObject.typeName, releasedAfter: releasedAfter, timeout: timeout), - at: trackedObject.location, + LeakReport.lateReleaseMessage( + typeName: check.trackedObject.typeName, + releasedAfter: releasedAfter, + timeout: check.timeout + ), + at: check.trackedObject.location, severity: .warning ) } - for trackedObject in pending { - guard let object = trackedObject.object else { + for check in leaks { + guard let object = check.trackedObject.object else { continue } reportIssue( LeakReport( - typeName: trackedObject.typeName, - timeout: timeout, - gracePeriod: gracePeriod, + typeName: check.trackedObject.typeName, + timeout: check.timeout, + gracePeriod: check.gracePeriod, hints: LeakHints.hints(for: object) ).message, - at: trackedObject.location, - severity: configuration.severity + at: check.trackedObject.location, + severity: check.trackedObject.configuration.severity ) } } diff --git a/Tests/DeallocTestsTests/ConfigurationTests.swift b/Tests/DeallocTestsTests/ConfigurationTests.swift index d51a687..7ac7f26 100644 --- a/Tests/DeallocTestsTests/ConfigurationTests.swift +++ b/Tests/DeallocTestsTests/ConfigurationTests.swift @@ -6,8 +6,22 @@ // import DeallocTests +import Foundation import Testing +final class RecordedComments: @unchecked Sendable { + private let lock = NSLock() + private var comments = [String]() + + func append(_ issue: Issue) { + lock.withLock { comments += issue.comments.map(\.rawValue) } + } + + func contains(_ text: String) -> Bool { + lock.withLock { comments.contains { $0.contains(text) } } + } +} + func isLeakReport(within timeout: String) -> (Issue) -> Bool { { issue in issue.comments.contains { $0.rawValue.hasPrefix("DeallocTestsTests.RetainCycleObject was not deallocated within \(timeout).") } @@ -53,8 +67,44 @@ struct ConfigurationTests { } @Test(.deallocationIssues(.warning)) - func warningSeverityDoesNotFailTheTest() async { - await expectDeallocation { RetainCycleObject() } + func warningSeverityRecordsAWarning() async { + #if compiler(>=6.3) + await withKnownIssue { + await expectDeallocation { RetainCycleObject() } + } matching: { issue in + issue.severity == .warning && isLeakReport(within: "100 ms")(issue) + } + #else + await expectDeallocation { RetainCycleObject() } + #endif + } + + @Test func eachTrackedObjectUsesItsOwnConfiguration() async throws { + let test = try #require(Test.current) + let recorded = RecordedComments() + + try await withKnownIssue { + try await DeallocationCheckTrait.checksDeallocation.provideScope( + for: test, + testCase: Test.Case.current, + performing: { + await MainActor.run { + withDeallocationConfiguration({ $0.timeout = .milliseconds(150) }) { + _ = trackForDeallocation(RetainCycleObject()) + } + withDeallocationConfiguration({ $0.timeout = .milliseconds(250) }) { + _ = trackForDeallocation(RetainCycleObject()) + } + } + } + ) + } matching: { issue in + recorded.append(issue) + return isLeakReport(within: "150 ms")(issue) || isLeakReport(within: "250 ms")(issue) + } + + #expect(recorded.contains("within 150 ms")) + #expect(recorded.contains("within 250 ms")) } @Test func trackedObjectsUseTheConfiguredTimeout() async throws { diff --git a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift index 8c3cc27..5c7fdea 100644 --- a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift +++ b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift @@ -262,7 +262,9 @@ struct TrackForDeallocationTests { testCase: Test.Case.current, performing: { await MainActor.run { - _ = trackForDeallocation(RetainCycleObject()) + withDeallocationConfiguration({ $0.timeout = .milliseconds(100) }) { + _ = trackForDeallocation(RetainCycleObject()) + } } throw LifecycleError() } diff --git a/Tests/DeallocTestsTests/GracePeriodTests.swift b/Tests/DeallocTestsTests/GracePeriodTests.swift index 5cf9a1d..368fa1f 100644 --- a/Tests/DeallocTestsTests/GracePeriodTests.swift +++ b/Tests/DeallocTestsTests/GracePeriodTests.swift @@ -24,7 +24,16 @@ func makeObjectReleasedAfter(_ delay: Duration) -> PlainObject { struct GracePeriodTests { @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.seconds(2))) func lateReleaseIsAWarning() async { - await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + #if compiler(>=6.3) + await withKnownIssue { + await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + } matching: { issue in + issue.severity == .warning + && issue.comments.contains { $0.rawValue.hasPrefix("DeallocTestsTests.PlainObject was released after") } + } + #else + await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + #endif } @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.milliseconds(300))) diff --git a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift index 30df3a3..6a61d4b 100644 --- a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift +++ b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift @@ -8,7 +8,24 @@ import DeallocTests import XCTest +final class ExpectedFailureRecorder: NSObject, XCTestObservation, @unchecked Sendable { + private(set) var descriptions = [String]() + + func testCase(_ testCase: XCTestCase, didRecord expectedFailure: XCTExpectedFailure) { + descriptions.append(expectedFailure.issue.compactDescription) + } +} + final class TrackForDeallocationXCTests: XCTestCase { + @MainActor + private func recordingExpectedFailures(_ operation: () async -> Void) async -> [String] { + let recorder = ExpectedFailureRecorder() + XCTestObservationCenter.shared.addTestObserver(recorder) + await operation() + XCTestObservationCenter.shared.removeTestObserver(recorder) + return recorder.descriptions + } + override func invokeTest() { withDeallocationConfiguration({ $0.gracePeriod = .zero }) { super.invokeTest() @@ -91,22 +108,30 @@ final class TrackForDeallocationXCTests: XCTestCase { } @MainActor - func test_warningSeverity_doesNotFailTheTest() async { - await withDeallocationConfiguration({ - $0.timeout = .milliseconds(100) - $0.severity = .warning - }) { - await expectDeallocation { RetainCycleObject() } + func test_warningSeverity_isReportedAsAnExpectedFailure() async { + let warnings = await recordingExpectedFailures { + await withDeallocationConfiguration({ + $0.timeout = .milliseconds(100) + $0.severity = .warning + }) { + await expectDeallocation { RetainCycleObject() } + } } + + XCTAssertTrue(warnings.contains { $0.contains("RetainCycleObject was not deallocated within 100 ms") }, "\(warnings)") } @MainActor func test_lateRelease_isAWarning() async { - await withDeallocationConfiguration({ - $0.timeout = .milliseconds(100) - $0.gracePeriod = .seconds(2) - }) { - await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + let warnings = await recordingExpectedFailures { + await withDeallocationConfiguration({ + $0.timeout = .milliseconds(100) + $0.gracePeriod = .seconds(2) + }) { + await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } + } } + + XCTAssertTrue(warnings.contains { $0.contains("PlainObject was released after") }, "\(warnings)") } } From 7597c009aef37920cda24cdc02ca73e681562602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Thu, 15 Oct 2026 11:14:13 +0200 Subject: [PATCH 10/12] fix: compile for visionOS, which has no UIScreen The test window falls back to UIScreen.main.bounds when there is no window scene. visionOS has no UIScreen, so linking the package from a visionOS test target failed to compile. A window without a scene is never shown there anyway, and waitUntilVisible already reports that a host app is needed. Co-Authored-By: Claude Fable 5.1 --- Sources/DeallocTests/Expectation/Lifecycle.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Sources/DeallocTests/Expectation/Lifecycle.swift b/Sources/DeallocTests/Expectation/Lifecycle.swift index 99c1240..d494743 100644 --- a/Sources/DeallocTests/Expectation/Lifecycle.swift +++ b/Sources/DeallocTests/Expectation/Lifecycle.swift @@ -177,7 +177,7 @@ final class TestWindow { if let windowScene = foregroundWindowScene() { window = UIWindow(windowScene: windowScene) } else { - window = UIWindow(frame: UIScreen.main.bounds) + window = Self.makeWindowWithoutScene() } window.rootViewController = rootViewController @@ -189,6 +189,17 @@ final class TestWindow { window.windowScene != nil } + /// Package tests have no host app and therefore no window scene + private static func makeWindowWithoutScene() -> UIWindow { + #if os(visionOS) + // visionOS has no UIScreen, and a window without a scene is never shown there, + // so `waitUntilVisible` reports that a host app is needed + UIWindow() + #else + UIWindow(frame: UIScreen.main.bounds) + #endif + } + 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) From 7368b5f661304df9ca11b15e39cfc7ca38505f30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Thu, 15 Oct 2026 11:24:46 +0200 Subject: [PATCH 11/12] test: late-release tests don't depend on a fast runner With a 2 s grace period, a loaded CI runner released the object after the grace period and the warning became a failure. A passing check ends as soon as the object goes away, so the grace period can be long without slowing the test down. Co-Authored-By: Claude Fable 5.1 --- Tests/DeallocTestsTests/GracePeriodTests.swift | 4 +++- Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Tests/DeallocTestsTests/GracePeriodTests.swift b/Tests/DeallocTestsTests/GracePeriodTests.swift index 368fa1f..cad9a32 100644 --- a/Tests/DeallocTestsTests/GracePeriodTests.swift +++ b/Tests/DeallocTestsTests/GracePeriodTests.swift @@ -22,7 +22,9 @@ func makeObjectReleasedAfter(_ delay: Duration) -> PlainObject { @Suite("Grace period") @MainActor struct GracePeriodTests { - @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.seconds(2))) + // A passing check ends as soon as the object goes away, so the long grace period only + // matters on a loaded runner, where the release can come seconds late + @Test(.deallocationTimeout(.milliseconds(100)), .deallocationGracePeriod(.seconds(60))) func lateReleaseIsAWarning() async { #if compiler(>=6.3) await withKnownIssue { diff --git a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift index 6a61d4b..ae992e0 100644 --- a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift +++ b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift @@ -126,7 +126,8 @@ final class TrackForDeallocationXCTests: XCTestCase { let warnings = await recordingExpectedFailures { await withDeallocationConfiguration({ $0.timeout = .milliseconds(100) - $0.gracePeriod = .seconds(2) + // Long on purpose: a passing check ends when the object goes away, and a loaded runner can release it late + $0.gracePeriod = .seconds(60) }) { await expectDeallocation { makeObjectReleasedAfter(.milliseconds(400)) } } From b7dd63813bd2f8f3e2bd0bf536d4a80e66efed85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Wed, 21 Oct 2026 13:14:02 +0200 Subject: [PATCH 12/12] fix: report a CancellationError thrown while the test is still running `perform` swallowed every CancellationError, so an interaction whose child task threw one while the test task was alive ended silently and the rest of the interaction never ran. The guard in `reportIssue` already handles a cancelled test, so every error now goes through it. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 2eb60fc5608ce9fc606226fb272b0e841fdad4e5) --- Sources/DeallocTests/Expectation/Lifecycle.swift | 2 -- .../ExpectDeallocationTests.swift | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Sources/DeallocTests/Expectation/Lifecycle.swift b/Sources/DeallocTests/Expectation/Lifecycle.swift index d494743..274f1cc 100644 --- a/Sources/DeallocTests/Expectation/Lifecycle.swift +++ b/Sources/DeallocTests/Expectation/Lifecycle.swift @@ -48,8 +48,6 @@ 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) } diff --git a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift index 5c7fdea..430d40d 100644 --- a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift +++ b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift @@ -139,6 +139,20 @@ struct ExpectDeallocationTests { } } + @Test func interactionThrowingCancellationErrorIsReported() async { + await withKnownIssue { + await expectDeallocation(.custom { _ in + // A child throws CancellationError while the test task itself is still active + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { throw CancellationError() } + try await group.waitForAll() + } + }) { PlainObject() } + } matching: { issue in + issue.comments.contains { $0.rawValue.contains("threw an error: CancellationError") } + } + } + @Test func throwingFactoryRethrows() async { await #expect(throws: LifecycleError.self) { try await expectDeallocation { () throws -> PlainObject in throw LifecycleError() }