Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
105 changes: 105 additions & 0 deletions Sources/DeallocTests/Expectation/DeallocationConfiguration.swift
Original file line number Diff line number Diff line change
@@ -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<Result>(
_ 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
11 changes: 8 additions & 3 deletions Sources/DeallocTests/Expectation/DeallocationTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand All @@ -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
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Dependency: Sendable>(
of type: Dependency.Type,
resolvedFrom container: AsyncContainer,
timeout: Duration = .seconds(2),
timeout: Duration? = nil,
fileID: StaticString = #fileID,
filePath: StaticString = #filePath,
line: UInt = #line,
Expand Down
4 changes: 2 additions & 2 deletions Sources/DeallocTests/Expectation/ExpectDeallocation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Object: AnyObject>(
_ lifecycle: Lifecycle<Object> = .none,
timeout: Duration = .seconds(2),
timeout: Duration? = nil,
afterRelease: @MainActor () async -> Void = {},
fileID: StaticString = #fileID,
filePath: StaticString = #filePath,
Expand Down
54 changes: 42 additions & 12 deletions Sources/DeallocTests/Expectation/IssueReporting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 6 additions & 5 deletions Sources/DeallocTests/Expectation/TrackForDeallocation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public extension XCTestCase {
@discardableResult
func trackForDeallocation<Object: AnyObject>(
_ object: Object,
timeout: Duration = .seconds(2),
timeout: Duration? = nil,
fileID: StaticString = #fileID,
filePath: StaticString = #filePath,
line: UInt = #line,
Expand Down Expand Up @@ -92,7 +92,7 @@ public func trackForDeallocation<Object: AnyObject>(

/// 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
Expand Down Expand Up @@ -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)
}
}
Expand Down
86 changes: 86 additions & 0 deletions Tests/DeallocTestsTests/ConfigurationTests.swift
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading