From 3d74c67a68b1698eb6eccf591c75312b5936c88b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=CC=8Cech?= Date: Sat, 3 Oct 2026 21:10:51 +0200 Subject: [PATCH] feat: deallocation configuration and warning severity - DeallocationConfiguration (timeout, severity) as a task-local default - .deallocationTimeout(_:) and .deallocationIssues(_:) Swift Testing traits for a test or a whole suite; a test's trait wins over its suite's, and an explicit argument wins over both - withDeallocationConfiguration(_:operation:) for XCTest, running on the caller's actor - Warning severity: Issue severity .warning on Swift 6.3+, an intermittent known issue before that, a non-strict expected failure in XCTest - timeout parameters are now optional and default to the configuration Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + README.md | 21 ++++ .../DeallocationConfiguration.swift | 105 ++++++++++++++++++ .../Expectation/DeallocationTracker.swift | 11 +- ...pectDeallocation+DependencyInjection.swift | 4 +- .../Expectation/ExpectDeallocation.swift | 4 +- .../Expectation/IssueReporting.swift | 54 +++++++-- .../Expectation/TrackForDeallocation.swift | 11 +- .../ConfigurationTests.swift | 86 ++++++++++++++ .../TrackForDeallocationXCTests.swift | 22 ++++ 10 files changed, 295 insertions(+), 24 deletions(-) create mode 100644 Sources/DeallocTests/Expectation/DeallocationConfiguration.swift create mode 100644 Tests/DeallocTestsTests/ConfigurationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e0f568..2b7473a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ See "Migrating to 4.0" in the README. - Hints show `@Observable` properties by their declared names, without the macro's `_` prefix and registrar. - UIKit lifecycles wait up to 10 s for a screen to appear, be dismissed or popped, instead of 2 s, so they stay reliable on a loaded simulator. - `trackForDeallocation(_:)`: an `XCTestCase` method and the `.checksDeallocation` Swift Testing trait for checking objects at the end of ordinary unit tests. Inside an `expectDeallocation` closure, it checks the object together with the tested one. +- `DeallocationConfiguration` with the `.deallocationTimeout(_:)` and `.deallocationIssues(_:)` Swift Testing traits for a test or a whole suite, and `withDeallocationConfiguration(_:operation:)` for XCTest. Leaks can be reported as warnings that don't fail the test. - `expectDeallocation(of:resolvedFrom:)` for dependencies resolved from an `AsyncContainer`. A dependency that turns out to be a value type is reported with its concrete type, since it can't leak. - Swift Testing and XCTest tests of the library on macOS and the iOS simulator, and GitHub Actions CI. diff --git a/README.md b/README.md index 9327a6b..803de87 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,27 @@ await expectDeallocation(.present) { Other parameters: - `timeout` sets how long to wait for the deallocation (2 seconds by default). The check passes as soon as the object is gone. + +#### Configuring a suite + +Instead of passing the same values to every call, configure a test or a whole suite with traits: + +```swift +@Suite(.deallocationTimeout(.seconds(5))) +struct ScreenDeallocTests { … } + +// Adopting dealloc tests in an existing project: report leaks as warnings for now +@Suite(.deallocationIssues(.warning)) +struct LegacyDeallocTests { … } +``` + +A test's own trait wins over its suite's, and a value passed to the call wins over both. In XCTest, use `withDeallocationConfiguration`: + +```swift +await withDeallocationConfiguration({ $0.timeout = .seconds(5) }) { + await expectDeallocation(.present) { makeProfileViewController() } +} +``` - `afterRelease` runs after the object is released and before the check, e.g. to clear a cache that legitimately holds it. `.present` needs a test target with a host app, because modal presentation needs a window scene. The other lifecycles also work in package tests. diff --git a/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift b/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift new file mode 100644 index 0000000..c613b41 --- /dev/null +++ b/Sources/DeallocTests/Expectation/DeallocationConfiguration.swift @@ -0,0 +1,105 @@ +// +// DeallocationConfiguration.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import Foundation + +#if canImport(Testing) + import Testing +#endif + +/// How deallocation checks behave by default. +/// +/// Every check uses the current configuration unless it passes its own values. Set it 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) + /// 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() + } +} + +#if canImport(Testing) && compiler(>=6.1) + +/// 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 } + } + + /// 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 26ff0b5..f2e2a7c 100644 --- a/Sources/DeallocTests/Expectation/DeallocationTracker.swift +++ b/Sources/DeallocTests/Expectation/DeallocationTracker.swift @@ -34,8 +34,12 @@ final class DeallocationTracker { ) } - /// Waits until all tracked objects deallocate and reports the ones that didn't within the timeout - func verifyDeallocation(timeout: Duration) async { + /// Waits until all tracked objects deallocate and reports the ones that didn't within the timeout. + /// - Parameter timeout: Overrides the timeout of the current `DeallocationConfiguration` + func verifyDeallocation(timeout: Duration?) async { + let configuration = DeallocationConfiguration.current + let timeout = timeout ?? configuration.timeout + _ = await Polling.waitUntil(timeout: timeout) { [trackedObjects] in !trackedObjects.contains { $0.object != nil } } @@ -47,7 +51,8 @@ final class DeallocationTracker { reportIssue( LeakReport(typeName: trackedObject.typeName, timeout: timeout, 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 83bbe55..f411fe5 100644 --- a/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift +++ b/Sources/DeallocTests/Expectation/ExpectDeallocation+DependencyInjection.swift @@ -25,12 +25,12 @@ import Foundation /// - 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 112c170..3306d1b 100644 --- a/Sources/DeallocTests/Expectation/ExpectDeallocation.swift +++ b/Sources/DeallocTests/Expectation/ExpectDeallocation.swift @@ -22,14 +22,14 @@ import Foundation /// /// - 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 00919c3..455ef2b 100644 --- a/Sources/DeallocTests/Expectation/IssueReporting.swift +++ b/Sources/DeallocTests/Expectation/IssueReporting.swift @@ -26,22 +26,52 @@ struct TestSourceLocation: Sendable { } } -/// Reports a failure to Swift Testing when running inside a Swift Testing test, otherwise to XCTest -func reportIssue(_ message: String, at location: TestSourceLocation) { +/// Reports an issue to Swift Testing when running inside a Swift Testing test, otherwise to +/// XCTest. Exactly one framework gets it: since Swift 6.4 each framework also records the +/// other's failures, so reporting to both would show every issue twice. +func reportIssue(_ message: String, at location: TestSourceLocation, severity: DeallocationIssueSeverity = .error) { #if canImport(Testing) if Test.current != nil { - Issue.record( - Comment(rawValue: message), - sourceLocation: SourceLocation( - fileID: String(describing: location.fileID), - filePath: String(describing: location.filePath), - line: Int(location.line), - column: Int(location.column) - ) - ) + 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: + // Shown as an expected failure; the test passes + let options = XCTExpectedFailure.Options() + options.isStrict = false + XCTExpectFailure("Reported as a warning (DeallocationConfiguration.severity)", options: options) { + XCTFail(message, file: location.filePath, line: location.line) + } + } +} + +#if canImport(Testing) + +private func recordSwiftTestingIssue(_ message: String, at location: TestSourceLocation, severity: DeallocationIssueSeverity) { + let sourceLocation = SourceLocation( + fileID: String(describing: location.fileID), + filePath: String(describing: location.filePath), + line: Int(location.line), + column: Int(location.column) + ) + + 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 (DeallocationConfiguration.severity)", 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 8b34a7d..0d1a87a 100644 --- a/Sources/DeallocTests/Expectation/TrackForDeallocation.swift +++ b/Sources/DeallocTests/Expectation/TrackForDeallocation.swift @@ -28,7 +28,7 @@ public extension XCTestCase { @discardableResult func trackForDeallocation( _ object: Object, - timeout: Duration = .seconds(2), + timeout: Duration? = nil, fileID: StaticString = #fileID, filePath: StaticString = #filePath, line: UInt = #line, @@ -92,7 +92,7 @@ public func trackForDeallocation( /// Checks that every object passed to `trackForDeallocation(_:)` deallocates when the test ends public struct DeallocationCheckTrait: TestTrait, SuiteTrait, TestScoping { - let timeout: Duration + let timeout: Duration? public var isRecursive: Bool { true @@ -121,12 +121,13 @@ public struct DeallocationCheckTrait: TestTrait, SuiteTrait, TestScoping { public extension Trait where Self == DeallocationCheckTrait { /// Checks that every object passed to `trackForDeallocation(_:)` deallocates when the test ends static var checksDeallocation: Self { - checksDeallocation(timeout: .seconds(2)) + checksDeallocation(timeout: nil) } /// Checks that every object passed to `trackForDeallocation(_:)` deallocates when the test ends - /// - Parameter timeout: How long to wait for the objects to deallocate - static func checksDeallocation(timeout: Duration) -> Self { + /// - Parameter timeout: How long to wait for the objects to deallocate. + /// `nil` uses `DeallocationConfiguration.current.timeout`. + static func checksDeallocation(timeout: Duration?) -> Self { Self(timeout: timeout) } } diff --git a/Tests/DeallocTestsTests/ConfigurationTests.swift b/Tests/DeallocTestsTests/ConfigurationTests.swift new file mode 100644 index 0000000..6778bd0 --- /dev/null +++ b/Tests/DeallocTestsTests/ConfigurationTests.swift @@ -0,0 +1,86 @@ +// +// ConfigurationTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import DeallocTests +import Testing + +#if compiler(>=6.1) + +/// Matches a leak report of `RetainCycleObject` that names the expected timeout +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))) +@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) + } + } + + /// A leak reported as a warning doesn't fail the test + @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) + } + } +} + +#endif diff --git a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift index dfa8b67..71e843b 100644 --- a/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift +++ b/Tests/DeallocTestsTests/TrackForDeallocationXCTests.swift @@ -45,4 +45,26 @@ 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() } + } + } + + /// Reported as a non-strict expected failure, so the test passes + @MainActor + func test_warningSeverity_doesNotFailTheTest() async { + await withDeallocationConfiguration({ + $0.timeout = .milliseconds(100) + $0.severity = .warning + }) { + await expectDeallocation { RetainCycleObject() } + } + } }