Skip to content

New expectDeallocation API for Swift Testing and XCTest (3.2) - #19

Open
DanielCech wants to merge 2 commits into
dc/chore/phase0-hygienefrom
dc/feat/expect-deallocation
Open

DanielCech wants to merge 2 commits into
dc/chore/phase0-hygienefrom
dc/feat/expect-deallocation

Conversation

@DanielCech

Copy link
Copy Markdown
Member

Stacked on #18. Review and merge #18 first. GitHub will then retarget this PR to master.

Why

Writing a dealloc test today means subclassing DeallocTester, adding a DeallocTestable conformance for every tested class, creating an expectation and waiting for it. When something leaks, the failure points to a line inside the library rather than to your test. And it only works in XCTest, while new projects are moving to Swift Testing.

This PR adds a much simpler way to write dealloc tests, borrowing the best ideas from similar tools (weak-reference checks, SpecLeaks-style lifecycles, test traits). Nothing existing changes: DeallocTester keeps working exactly as before, so this can ship as 3.2.0.

What it looks like

@MainActor
struct LeakTests {
    let coordinator = MainCoordinator()

    @Test func profileScreen() async {
        await expectDeallocation(.present) { coordinator.createProfileViewController() }
    }

    @Test func profileViewModel() async {
        await expectDeallocation { ProfileViewModel(api: MockAPI()) }
    }
}

That's the whole test. When it leaks, the failure appears on that line of your test:

MyApp.ProfileViewController was not deallocated within 2 sec. 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.

What's new

expectDeallocation creates an object, runs its lifecycle, releases it and checks that it's gone.

  • Works in Swift Testing and XCTest with the same call.
  • No DeallocTestable conformance needed. It works with any class, because it uses plain weak references.
  • Passes as soon as the object is freed, and waits up to timeout (2 s) otherwise.
  • afterRelease lets you clear something that legitimately holds the object (like a cache) before the check.

Lifecycles decide what happens to the object before it's released, because many leaks only show up once a screen loads or appears:

Lifecycle What happens
.none (default) Released right away
.loadView The view loads (viewDidLoad), UIKit and AppKit
.present Presented in a test window, then dismissed
.push Pushed onto a navigation controller, then popped
.custom { … } Your own code runs with the object

.present and .push also take an interaction closure that runs while the screen is visible, e.g. to tap through the flow you suspect of leaking.

trackForDeallocation brings leak checks to ordinary unit tests: wrap your system under test, and it's checked when the test ends.

  • XCTest: call it on the test case.
  • Swift Testing: add the .checksDeallocation trait to a test or a whole suite.

Dependency injection: expectDeallocation(of: APIManaging.self, resolvedFrom: container) resolves a dependency, releases the container's shared instances and checks it.

Robustness

  • UIKit steps wait for the real screen state (appeared, dismissed, popped) with a deadline, never with fixed delays or completion handlers that might never fire. A test can't hang.
  • If a screen can't be shown, you get one clear message, e.g. "could not be presented… the test target needs a host app", instead of a misleading leak report.
  • A resolved dependency that turns out to be a struct is reported, because a value can't be checked for deallocation.

Tests

  • macOS (swift test): Swift Testing and XCTest tests for both products. They cover clean objects, retain cycles, failures reported at the caller's line, every lifecycle path, errors thrown during interaction, afterRelease, the trait (including suites, stored properties and parameterized tests), and the DI helper.
  • iOS simulator: .loadView and .push tests run in the package. The .present tests are skipped there with a stated reason, because modal presentation needs a host app.
  • Sample app: a new ExpectDeallocationTests.swift in DeallocTestsAppSPM checks the same screens with the new API in a real host app. .present works, and the deliberate SecondViewController leak is caught on the test's line.
  • CI: new job that runs the package tests on the iOS simulator.

Docs

  • README: the new API is now the recommended way, with a lifecycle table and examples. DeallocTester is documented as the scenario API.
  • New CHANGELOG.md covering 3.1 and 3.2.

Not in this PR

  • Reporting which property still holds a leaked object, by walking the object graph.
  • A SwiftUI hosting lifecycle.
  • Replacing the two products with a package trait for dependency injection, and deprecating DeallocTester (4.0).

🤖 Generated with Claude Code

DanielCech and others added 2 commits October 2, 2026 13:17
- expectDeallocation(_:timeout:afterRelease:of:) checks any class instance
  with weak references, no DeallocTestable conformance needed
- Leaks are reported at the test's call site in both frameworks
- Lifecycles: none, loadView, present, push, custom; interaction while on screen
- trackForDeallocation for XCTestCase and the .checksDeallocation trait
- expectDeallocation(of:resolvedFrom:) for AsyncContainer dependencies
- Tests for macOS and the iOS simulator, Swift Testing sample in the DI sample app
- iOS package tests in CI, README and CHANGELOG

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant