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 1/3] 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 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 2/3] 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 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 3/3] 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())