Skip to content

DeallocTests 4.0 (1/5): groundwork – cleanup, CI, Dependency Injection as a package trait - #25

Open
DanielCech wants to merge 1 commit into
masterfrom
dc/release-4.0/1-groundwork
Open

DanielCech wants to merge 1 commit into
masterfrom
dc/release-4.0/1-groundwork

Conversation

@DanielCech

@DanielCech DanielCech commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Part 1 of 5. DeallocTests 4.0 series (merge in order): #25 groundwork → #26 expectDeallocation → #27 tracking, hints, DI → #28 configuration → #29 remove legacy, docs. Why 4.0, the breaking changes and the alternatives we looked at: see #25.

DeallocTests 4.0 in 5 PRs

DeallocTests checks that objects (screens, view models, coordinators, services) go away after use, so retain cycles fail a unit test instead of shipping. The idea is unchanged and still good. The implementation from 2019 is not: some checks give wrong answers, it can't be used from Swift Testing, and its failures don't say where the leak is. 4.0 keeps the idea and replaces the implementation.

This is a series of 5 stacked PRs from master to 4.0.0, one per theme. Each one builds, passes its tests (macOS with and without the Dependency Injection trait, and the iOS Simulator for the UI parts) and can be reviewed in one sitting. The last one ends at the reference branch dc/feat/v4-grace-period (ca911ab) plus the fixes from reviewing this series.

Why change it

3.0.1 has bugs that affect projects using it today:

  • On macOS a test never finishes and waits out its whole timeout.
  • If objectCreation returns nil (easy with [weak self]), the test crashes; a non-DeallocTestable object hangs it.
  • Leaks are matched by class: if one FooViewController is freed and another leaks, the test passes.
  • Only the root object is watched; its view model, store or child coordinator can leak unnoticed.
  • The Dependency Injection dependency uses an SSH URL, so the package doesn't resolve on machines (and CI runners) without SSH access to GitHub.
  • DeallocTestsDIFree is a symlinked copy of the target that switches DI on with canImport, which depends on whatever else is in the build.

And it doesn't fit how we write tests now:

  • XCTest only; new projects use Swift Testing.
  • Every tested class needs extension X: @retroactive DeallocTestable {}; every test subclasses DeallocTester and waits for an expectation.
  • Failures point at a line inside the library, not at the test.
  • Fixed sleeps make tests slow and still flaky on a loaded CI.
  • The library has no tests of its own and no working CI.

What 4.0 looks like

// 3.x
extension ProfileViewController: @retroactive DeallocTestable {}

final class ProfileDeallocTester: DeallocTester {
    @MainActor
    func test_profile() async {
        let expectation = expectation(description: "dealloc test")
        await performDeallocTest(
            deallocTests: [DeallocTest(objectCreation: { _ in coordinator.makeProfileViewController() })],
            expectation: expectation
        )
        await fulfillment(of: [expectation], timeout: 60)
    }
}

// 4.0, Swift Testing or XCTest
await expectDeallocation(.present) { coordinator.makeProfileViewController() }

A leak fails on that line and says where to look:

LeakTests.swift:12: MyApp.ProfileViewController was not deallocated within 2 sec. It was watched for another 3 sec after that. Possible causes:
  • `onUpdate` is a closure. Make sure it captures self weakly
  • Or something outside still holds it: a parent's list of children, a cache or a singleton
  • Any class, no conformances: the check holds a weak reference. The app target still needs no code at all.
  • Real lifecycles: .loadView, .present, .push, .hosting (SwiftUI), .custom. Many leaks only appear on screen.
  • Owned objects: trackForDeallocation checks a screen's view model together with the screen, and the system under test of any unit test.
  • Dependency Injection: expectDeallocation(of: APIManaging.self, resolvedFrom: container).
  • Configuration per test, suite or XCTest class: timeout, leaks as warnings while adopting, and a grace period that tells "released late" from "leaked".

Why the breaking changes

4.0 is a major version, and it uses that:

  • DeallocTester, DeallocTestable and friends are removed, not deprecated. Everything they did is covered by expectDeallocation, and keeping them means two ways to write the same test plus a buggy code path to maintain.
  • DeallocTestsDIFree is replaced by a package trait. Projects with STRV Dependency Injection change nothing; others turn the trait off.
  • Swift 6.1 (Xcode 16.3) is required, for package traits.
  • DefaultInitializable is removed; it wasn't related to dealloc testing.

Migration is mechanical: each DeallocTest becomes one expectDeallocation call and the conformance file is deleted (mapping table in the README). Projects that can't migrate yet stay on 3.x, which keeps working.

Proof on a real app: the JustFlip branch dc/feat/dealloc-tests-v4 has about 130 dealloc tests on 4.0: repositories, services, managers, every store in its SwiftUI view, every coordinator flow and the screens it opens. They run in about 45 s and found a real bug: ten store types shared one state object between all their instances.

Alternatives we looked at

Option Why not instead
The 5-line trackForMemoryLeaks idiom (XCTest teardown block) Fine for a view model in XCTest. No lifecycles, no Swift Testing (no teardown blocks there), no hints. 4.0's trackForDeallocation is this idiom for both frameworks.
XCTAssertNoLeak, SpecLeaks, MemoryLeakTestKit Same category, dormant or dead since 2018–2022, no Swift Testing or SwiftUI.
LifetimeTracker, MLeaksFinder, FBRetainCycleDetector Detect leaks while the app runs. Useful in debug builds, but they complement unit tests rather than replace them.
Point-Free No leak-testing library. Its swift-issue-reporting solves failure reporting, which 4.0 needs only a 60-line slice of.
Apple Nothing for leaks in XCTest or Swift Testing; Instruments and the Memory Graph debugger diagnose a leak once a test finds it.

No maintained library does unit-test leak checks with screen lifecycles, Swift Testing and SwiftUI support.

The PRs

# PR What Source / tests Breaking
1 #25 Remove dead tooling, DI over https, CI, DependencyInjection package trait replaces DeallocTestsDIFree +7 −42 / −17 Yes
2 #26 expectDeallocation with .custom, .loadView, .present, .push, .hosting; both test frameworks +574 / +425 No
3 #27 trackForDeallocation and .checksDeallocation, leak hints, expectDeallocation(of:resolvedFrom:) +398 / +327 No
4 #28 Timeout, warning severity, grace period; traits and XCTest configuration +221 / +245 No
5 #29 Remove DeallocTester & co., migrate the sample apps, README, migration guide, CHANGELOG −420 + docs Yes

Each PR targets the previous one. 2–4 only add API next to the old one, so the library works at every step; 5 removes the old API once the new one is complete. Each PR description has a "How to review" order, so the larger ones (2 and 3) can be read file by file.

Not in scope

  • A runtime leak watcher for debug builds of the app. The app target stays free of DeallocTests code.
  • DocC documentation; a follow-up after 4.0.0.

This PR: groundwork

Groundwork: remove tooling that no longer runs, make the package resolvable everywhere, get CI, and turn Dependency Injection into a package trait.

Why

  • Dead tooling: Travis CI (Xcode 10.2), Danger, a Carthage bootstrap script, jazzy, .swift-version 5.0, .ruby-version, Objective-C umbrella headers and an empty source file. None of it runs.
  • No tests: Tests/DeallocTestsSpec.swift imports Quick and Nimble, which the package doesn't declare, so the test target never compiled.
  • SSH dependency URL: git@github.com:strvcom/ios-dependency-injection.git. SwiftPM resolves every dependency, so consumers without SSH access to GitHub, including many CI runners and DeallocTestsDIFree users, couldn't resolve the package.
  • Fragile DIFree target: DeallocTestsDIFree compiled the same sources through a symlinked target and switched DI on with #if canImport(DependencyInjection), which depends on what else is in the build graph. Swift 6.1 package traits (SE-0450) do this properly.

What changes

  • Dead files deleted; the broken test target removed (part 2 brings real tests).
  • Package.swift: tools 6.1, https URL, a DependencyInjection trait on by default, STRV Dependency Injection 1.0.4 up to 2.x accepted (DeallocTests only uses API both have).
  • DeallocTester checks #if DependencyInjection instead of canImport (7 one-word changes).
  • Sample apps reference the package locally. The DIFree sample links DeallocTests with the trait off, and gets the @MainActor/await it needed to compile under Swift 6.
  • CI builds the package with and without the trait, and for the iOS Simulator.

How to review

  1. Package.swift and .github/workflows/ci.yml
  2. DeallocTester.swift: canImport(DependencyInjection) → DependencyInjection
  3. Sample apps: project.pbxproj switches only the package reference and product
  4. The rest is deletions

Breaking change

  • DeallocTestsDIFree is removed. Link DeallocTests, import DeallocTests, and disable the default traits:
    .package(url: "https://github.com/strvcom/DeallocTests.git", from: "4.0.0", traits: [])
    In an Xcode project, disable the package's default traits (Xcode 26.4+).
  • Swift 6.1 / Xcode 16.3 is required.
  • Projects using STRV Dependency Injection change nothing.

Testing

The package builds on macOS with and without the trait, and for the iOS Simulator. Both sample apps build for testing.

🤖 Generated with Claude Code

…kage trait

Removes tooling that no longer runs (Travis, Danger, Carthage
bootstrap, jazzy, .swift-version, .ruby-version, Objective-C headers,
an empty file and a Quick spec that never compiled). The package
fetches ios-dependency-injection over https, so it resolves without
SSH access to GitHub, and GitHub Actions builds it.

The DeallocTestsDIFree product compiled the same sources through a
symlinked target and switched Dependency Injection on with canImport,
which depends on whatever else is in the build. One target now does it
with the DependencyInjection package trait (Swift 6.1), on by default,
accepting STRV Dependency Injection 1.0.4 up to 2.x. The DIFree sample
uses the trait and gets the @MainActor/await it needed to compile under
Swift 6.

BREAKING CHANGE: the DeallocTestsDIFree product is removed; link
DeallocTests with the default traits disabled. Swift 6.1 is required.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The package, sample, conditional-compilation, cleanup, and CI changes are internally consistent with the stated groundwork scope.

Review effort: Balanced
Findings: None

What changed in this PR

Modernizes package infrastructure by replacing the DIFree target with a package trait, adding CI, and removing obsolete tooling.

Changes:

  • Adds conditional Dependency Injection support through a default package trait.
  • Updates sample apps to use local package references.
  • Replaces legacy tooling with GitHub Actions builds.
File Description
.github/​workflows/​ci.yml Adds macOS trait-matrix and iOS builds.
.jazzy.yml Removes obsolete Jazzy configuration.
.ruby-version Removes obsolete Ruby pin.
.swift-version Removes obsolete Swift pin.
.travis.yml Removes legacy Travis CI.
Dangerfile Removes unused Danger checks.
Package.resolved Uses HTTPS and updates the DI pin.
Package.swift Adds the DI trait and removes DIFree.
SampleApps/​DeallocTestsAppDIFreeSPM/​DeallocTestsAppSPM.xcodeproj/​project.pbxproj Migrates DIFree sample to the trait-disabled product.
SampleApps/​DeallocTestsAppDIFreeSPM/​DeallocTestsAppSPM.xcodeproj/​project.xcworkspace/​xcshareddata/​swiftpm/​Package.resolved Removes obsolete sample dependency pins.
SampleApps/​DeallocTestsAppDIFreeSPM/​DeallocTestsAppSPMTests/​DeallocTestConformances.swift Updates the module import.
SampleApps/​DeallocTestsAppDIFreeSPM/​DeallocTestsAppSPMTests/​MainCoordinatorDeallocTester.swift Updates imports and actor isolation.
SampleApps/​DeallocTestsAppSPM/​DeallocTestsAppSPM.xcodeproj/​project.pbxproj Adds a local package reference.
Sources/​DeallocTests.h Removes an obsolete umbrella header.
Sources/​DeallocTests/​DeallocTests.h Removes an obsolete umbrella header.
Sources/​DeallocTests/​DeallocTests.swift Removes an empty placeholder source.
Sources/​DeallocTests/​DeallocTester.swift Uses the package trait for conditional DI support.
Tests/​DeallocTestsSpec.swift Removes the non-compiling empty test.
scripts/​bootstrap Removes the obsolete Carthage bootstrap.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@DanielCech
DanielCech added this pull request to stack #30 October 9, 2026 08:03
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.

2 participants