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..1edffae --- /dev/null +++ b/Sources/DeallocTests/Diagnostics/LeakHints.swift @@ -0,0 +1,165 @@ +// +// LeakHints.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import Foundation + +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 ea58f9d..7942d0e 100644 --- a/Sources/DeallocTests/Diagnostics/LeakReport.swift +++ b/Sources/DeallocTests/Diagnostics/LeakReport.swift @@ -8,10 +8,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 00a0744..3132281 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, @@ -53,7 +55,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..502e421 --- /dev/null +++ b/Sources/DeallocTests/Expectation/TrackForDeallocation.swift @@ -0,0 +1,129 @@ +// +// 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() + + do { + try await DeallocationTracker.$current.withValue(tracker) { + try await function() + } + } catch { + await tracker.verifyDeallocation() + throw error + } + + 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 bc28546..0ab541a 100644 --- a/Tests/DeallocTestsTests/ExpectDeallocationTests.swift +++ b/Tests/DeallocTestsTests/ExpectDeallocationTests.swift @@ -8,6 +8,10 @@ @testable 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]() @@ -178,3 +190,106 @@ 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 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()) + } 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 191517c..45300f3 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) @@ -87,7 +247,7 @@ struct SwiftUIHostingTests { return model } } matching: { issue in - isLeakReport(of: "TaskLeakModel")(issue) + isLeakReport(of: "TaskLeakModel", mentioning: "`task` is a task")(issue) } leakedModel?.task?.cancel() 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) + } + } }