When migrating XCTest unit tests on a Mac, preserve the ownership sequence: finish the work, release the intended strong references, then check the weak reference. For synchronous cleanup, a test-local defer makes that sequence explicit.

This walkthrough focuses on converting an ownership-sensitive unit test in Xcode. The example calculates the number of cells in a synthetic grid, then checks that its calculator is released. It uses only local Swift objects and integer arithmetic.

What lifecycle behavior must the migration preserve?

XCTest provides a framework-managed sequence. After a test method ends, it runs registered teardown blocks in reverse registration order, followed by the instance teardown methods. Registered cleanup still runs when a test assertion fails; process termination can prevent it. These details matter when one block releases an object and another checks its weak reference. Set Up and Tear Down State in Your Tests

For example, a teardown block that checks for nil must execute after the block that removes the final intended owner. Because XCTest uses reverse registration order, the check is registered first and the release second. Reversing those registrations changes what the test observes.

Swift Testing’s migration guide maps setup to an initializer and teardown to a deinitializer on a class or actor suite. That mapping is useful for cleanup associated with the suite’s lifetime. An ownership assertion needs an additional decision: precisely which reference should disappear before the assertion runs? Migrating a test from XCTest

Use this decision table before changing the test’s syntax:

Existing responsibility Migration choice Reason
Construct fresh fixture state for each instance test Stored property initialization or init() Establishes the fixture before the test runs
Release an object and immediately inspect a weak reference Explicit release followed by #expect, usually inside a local defer Makes the observation point visible
Perform synchronous cleanup tied to a reference-type suite’s lifetime Class or actor suite with deinit Follows the documented lifecycle mapping
Await cleanup or report a cleanup error Awaited cleanup in an asynchronous function, or an appropriate custom scope Requires an execution path that supports suspension and error handling

The table is a migration decision aid derived from the documented lifecycle and language rules. It does not imply that every XCTest hook has an interchangeable Swift Testing method.

Why can moving the weak check into deinit change the test?

Swift calls a class deinitializer immediately before deallocating the instance. The deinitializer can still access that instance’s stored properties. Consequently, entering a suite’s deinit does not itself prove that an object held by one of those properties has already been released. Deinitialization

Consider a suite with a strong layout property. A weak reference to that layout can remain non-nil while the suite’s deinitializer is executing. The test must remove the strong property reference before asking whether the layout was released.

There is another failure mode: a reference cycle that retains the suite can prevent its deinitializer from running. An assertion placed only in that deinitializer then has no opportunity to report the retention. This follows from ARC’s ownership rules: strong references keep instances alive, while weak references do not prevent deallocation and become nil when the referenced instance is deallocated. Automatic Reference Counting

For an ownership test, keep the observer outside the object whose release is being tested. Give the test a specific release operation and execute its assertion through the test’s normal control flow. That arrangement makes the test’s contract inspectable without depending on the suite’s own deallocation.

Establish the XCTest baseline in Xcode

Use an existing unit test target in Xcode 16 or later. Apple documents that XCTest and Swift Testing can coexist in the same target and bundle, so migration does not require another unit testing bundle. To add a test file, use File > New > New File From Template and select XCTest Unit Test or Swift Testing Unit Test as appropriate. Adding tests to your Xcode project

The following is a complete XCTest test file. Its calculator returns 3 * 4, giving an expected result of 12. The two teardown registrations deliberately expose the required execution order.

import XCTest

private final class GridCalculator {
    func cellCount(columns: Int, rows: Int) -> Int {
        columns * rows
    }
}

final class GridCalculatorXCTests: XCTestCase {
    private var calculator: GridCalculator?

    override func setUp() {
        super.setUp()
        calculator = GridCalculator()
    }

    func testCellCountAndRelease() {
        weak var observed = calculator

        // Registered first, so this executes second.
        addTeardownBlock { [weak observed] in
            XCTAssertNil(observed)
        }

        // Registered second, so this executes first.
        addTeardownBlock { [self] in
            calculator = nil
        }

        XCTAssertEqual(
            calculator?.cellCount(columns: 3, rows: 4),
            12
        )
    }
}

The release block retains the test case so it can update the fixture property. The observation block captures the calculator weakly, avoiding an additional strong owner in the check itself. Apple documents teardown blocks as the place for test-specific cleanup registered alongside the associated setup. Set Up and Tear Down State in Your Tests

The baseline’s contract is narrowly defined: after the test body finishes and the fixture property is cleared, the calculator must no longer exist. It does not measure memory consumption or establish that every object created by the application was released.

Migrate the ownership sequence into a local scope

For this synchronous case, use a struct suite and put release plus verification in one defer block. Swift executes deferred code when control leaves its enclosing scope, including an early return or a thrown error. Multiple deferred blocks run in reverse order; a single block makes this example’s release-before-check sequence easier to read. Control Flow

The following is a complete, independent Swift Testing file. Replace the baseline file with it, or keep the examples in separate files where their private helper types remain separate. The second test is a diagnostic control: it demonstrates that an additional strong owner keeps the weak reference populated until that owner is cleared.

import Testing

private final class GridCalculator {
    func cellCount(columns: Int, rows: Int) -> Int {
        columns * rows
    }
}

private final class GridFixture {
    var calculator: GridCalculator? = GridCalculator()
    var additionalOwner: GridCalculator?
}

struct GridCalculatorTests {
    @Test
    func cellCountAndRelease() throws {
        let fixture = GridFixture()
        weak var observed = fixture.calculator

        defer {
            fixture.calculator = nil
            #expect(observed == nil)
        }

        let result = try #require(
            fixture.calculator?.cellCount(columns: 3, rows: 4)
        )
        #expect(result == 12)
    }

    @Test
    func weakProbeDetectsAnAdditionalOwner() {
        let fixture = GridFixture()
        weak var observed = fixture.calculator

        fixture.additionalOwner = fixture.calculator
        fixture.calculator = nil
        #expect(observed != nil)

        fixture.additionalOwner = nil
        #expect(observed == nil)
    }
}

In the first test, the fixture owns the calculator, and observed watches it without owning it. The calculation produces an optional integer, which #require unwraps. No unwrapped calculator is stored in another local variable. On scope exit, the deferred block clears the fixture’s reference and checks the observer.

A failed #expect records an issue and allows execution to continue. A failed #require throws, stopping that execution path. Because the defer is registered before the requirement, its cleanup and weak check still execute when the requirement throws. Expectations and confirmations, Error Handling

The diagnostic control establishes two separate expected states: clearing one of two owners leaves the observer non-nil; clearing the remaining owner leaves it nil. These are illustrative ownership checks, not measured application results. The Swift examples were inspected but could not be compiled in the research environment because a Swift executable was unavailable.

Check helper boundaries, concurrency, and asynchronous cleanup

A local defer belongs to its enclosing scope. Moving the deferred block into a factory helper makes it execute when that helper returns, which may be before the test uses the returned object. Keep the block in the test, or design a helper that executes the entire operation inside the helper’s scope. The enclosing scope is part of the ownership contract. Control Flow

Also check where the fixture lives. Swift Testing creates a distinct suite instance for each instance test method, and tests within a suite run in parallel by default. Instance-local or test-local state fits that model; a global fixture or static collection introduces ownership shared across tests. Organizing test functions with suite types

For Mac application code that requires the main actor, explicitly isolate the Swift Testing test with @MainActor. The migration guide notes that Swift Testing does not inherit XCTest’s default main-actor behavior for synchronous test methods. The synthetic calculator above has no such requirement. Migrating a test from XCTest

The synchronous deferred block is not a replacement for awaited teardown. Swift’s deferred actions cannot throw an error out of the block. If cleanup can fail, handle and report that failure explicitly rather than discarding it. Keep awaited cleanup in an asynchronous execution path and ensure both successful and throwing body paths reach it. Error Handling

For reusable framework-managed setup and teardown, current Swift Testing provides TestScoping, which supplies custom execution around a test or suite. Apple lists it with Swift 6.1 and Xcode 16.3 availability. It is a supported direction when several tests need a common scope, but requires a custom trait and scope provider rather than merely renaming addTeardownBlock. TestScoping

Diagnose a weak check that changes after migration

If the migrated ownership test fails, inspect these points in order:

  1. Confirm the observation point. The intended release must happen before the weak assertion.
  2. Inspect fixture properties. A remaining property can still own the calculator after another property is cleared.
  3. Inspect local aliases and closure captures. An unwrapped object or strongly captured reference may extend ownership through the check.
  4. Inspect the helper scope. A helper’s deferred cleanup may run too early for the test’s intended contract.
  5. Inspect outstanding work. If the real object starts asynchronous work, define and await its completion before applying a synchronous release expectation.

A non-nil weak reference identifies an object that still exists at the observation point. Investigate the remaining owner before changing the assertion or adding a delay. In this synthetic example there is no asynchronous work to wait for, and the diagnostic control shows exactly how an additional owner affects the result.

The migration is complete when the calculation and ownership assertions express the same intended outcome as the baseline: calculate 12, remove the fixture’s intended ownership, and verify release through an independently held weak reference. Keep that sequence visible as the test evolves.

Sources