在 Mac 上遷移 XCTest 單元測試時,請保留擁有權序列:完成工作、釋放預期的強引用,然後檢查弱引用。對於同步清理,測試本地的 defer 會使該序列顯式化。

本逐步解說專注於在 Xcode 中轉換擁有權敏感的單元測試。範例計算合成網格中的儲存格數量,然後檢查其計算器是否被釋放。它僅使用本地 Swift 物件和整數運算。

遷移必須保留哪些生命週期行為?

XCTest 提供由框架管理的序列。測試方法結束後,它會按註冊的相反順序執行已註冊的 teardown 區塋,然後執行實例 teardown 方法。即使測試斷言失敗,已註冊的清理仍會執行;進程終止可能會阻止它。當一個區塋釋放物件而另一個區塋檢查其弱引用時,這些細節很重要。Set Up and Tear Down State in Your Tests

例如,檢查 nil 的 teardown 區塋必須在移除最終預期擁有者的區塋之後執行。因為 XCTest 使用反向註冊順序,檢查先註冊,釋放後註冊。顛倒這些註冊順序會改變測試觀察到的內容。

Swift Testing 的遷移指南將 setup 對應到初始化器,將 teardown 對應到類別或 actor suite 的解構器。該對應適用於與 suite 生命週期相關的清理。擁有權斷言需要額外的決定:確切哪個引用應在斷言執行前消失?Migrating a test from XCTest

在更改測試語法之前,請使用此決策表:

現有職責 遷移選擇 原因
為每個實例測試構建全新的 fixture 狀態 儲存屬性初始化或 init() 在測試運行前建立 fixture
釋放物件並立即檢查弱引用 顯式釋放後接 #expect,通常在本地 defer 內 使觀察點可見
執行與引用類型 suite 生命週期綁定的同步清理 具有 deinit 的類別或 actor suite 遵循文件記載的生命週期對應
等待清理或報告清理錯誤 非同步函數中的待清理,或適當的自訂範圍 需要支援暫停和錯誤處理的執行路徑

此表是源自文件記載的生命週期和語言規則的遷移決策輔助工具。它並不意味著每個 XCTest hook 都有可互換的 Swift Testing 方法。

為什麼將弱引用檢查移入 deinit 會改變測試?

Swift 在解配置實例前會立即呼叫類別的解構器。解構器仍可存取該實例的儲存屬性。因此,進入 suite 的 deinit 本身並不能證明由那些屬性持有的物件已經被釋放。Deinitialization

考慮一個具有強 layout 屬性的 suite。當 suite 的解構器正在執行時,指向該 layout 的弱引用可能仍然是非-nil。測試必須在詢問 layout 是否被釋放之前移除強屬性引用。

還有另一種失敗模式:保留 suite 的循環引用可能會阻止其解構器執行。僅放在該解構器中的斷言將沒有機會報告保留情況。這源於 ARC 的擁有權規則:強引用保持實例存活,而弱引用不會阻止解配置,並在被引用的實例被解配置時變為 nil。Automatic Reference Counting

對於擁有權測試,請將觀察者置於正在測試釋放的物件之外。給測試一個具體的釋放操作,並透過測試的正常控制流程執行其斷言。這種安排使測試的契約可被檢查,而不依賴 suite 自身的解配置。

在 Xcode 中建立 XCTest 基準

使用 Xcode 16 或更新版本中的現有單元測試目標。Apple 文件說明 XCTest 和 Swift Testing 可以在同一目標和 bundle 中共存,因此遷移不需要另一個單元測試 bundle。要新增測試檔案,請使用 File > New > New File From Template 並視情況選擇 XCTest Unit Test 或 Swift Testing Unit Test。Adding tests to your Xcode project

以下是完整的 XCTest 測試檔案。其計算器回傳 3 * 4,預期結果為 12。這兩個 teardown 註冊刻意暴露所需的執行順序。

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
        )
    }
}

釋放區塋保留測試案例以便其可以更新 fixture 屬性。觀察區塋弱捕獲計算器,避免在檢查本身中增加額外的強擁有者。Apple 文件將 teardown 區塋記錄為與相關 setup 一起註冊的測試特定清理位置。Set Up and Tear Down State in Your Tests

基準的契約定義狹窄:測試主體結束並清除 fixture 屬性後,計算器必須不再存在。它不測量記憶體消耗,也不證明應用程式建立的每個物件都被釋放了。

將擁有權序列遷移至本地範圍

對於此同步案例,請使用 struct suite 並將釋放和驗證放在一個 defer 區塋中。當控制權離開其封閉範圍時,Swift 會執行延遲代碼,包括提前返回或拋出錯誤。多個延遲區塋按相反順序執行;單一區塋使此範例的釋放前檢查序列更易閱讀。Control Flow

以下是完整、獨立的 Swift Testing 檔案。用它替換基準檔案,或將範例保留在單獨的檔案中,使其私有輔助類型保持分離。第二個測試是診斷控制:它示範額外的強擁有者會保持弱引用被填充,直到該擁有者被清除。

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)
    }
}

在第一個測試中,fixture 擁有計算器,而 observed 在不擁有它的情況下監視它。計算產生一個可選整數,#require 將其解包。沒有將解包後的計算器儲存在另一個區域變數中。在範圍結束時,延遲區塋清除 fixture 的引用並檢查觀察者。

失敗的 #expect 會記錄問題並允許繼續執行。失敗的 #require 會拋出錯誤,停止該執行路徑。因為 defer 在需求之前註冊,所以當需求拋出錯誤時,其清理和弱引用檢查仍會執行。Expectations and confirmations, Error Handling

診斷控制建立兩個獨立的預期狀態:清除兩個擁有者之一會使觀察者保持非-nil;清除剩餘的擁有者會使其成為 nil。這些是說明性的擁有權檢查,並非測量的應用程式結果。Swift 範例經過檢查,但無法在研究環境中編譯,因為沒有可用的 Swift 執行檔。

檢查輔助邊界、併發和非同步清理

本地 defer 屬於其封閉範圍。將延遲區塋移入工廠輔助函數會使其在該輔助函數返回時執行,這可能發生在測試使用返回物件之前。請將區塋保留在測試中,或設計一個在輔助函數範圍內執行整個操作的輔助函數。封閉範圍是擁有權契約的一部分。Control Flow

還要檢查 fixture 所在的位置。Swift Testing 為每個實例測試方法建立不同的 suite 實例,且 suite 內的測試預設並行執行。實例本地或測試本地狀態符合該模型;全域 fixture 或靜態集合會引入跨測試共享的擁有權。Organizing test functions with suite types

對於需要主 actor 的 Mac 應用程式代碼,請使用 @MainActor 顯式隔離 Swift Testing 測試。遷移指南指出,Swift Testing 不會繼承 XCTest 對同步測試方法的預設 main-actor 行為。上述合成計算器沒有此類需求。Migrating a test from XCTest

同步延遲區塋不能替代待定的 teardown。Swift 的延遲動作無法從區塋中拋出錯誤。如果清理可能失敗,請顯式處理並報告該失敗,而不是忽略它。將待定清理保留在非同步執行路徑中,並確保成功和拋出錯誤的主體路徑都能到達它。Error Handling

對於可重用的框架管理 setup 和 teardown,目前的 Swift Testing 提供 TestScoping,它在測試或 suite 周圍提供自訂執行。Apple 將其列為 Swift 6.1 和 Xcode 16.3 可用。當多個測試需要共同範圍時,這是一個受支援的方向,但需要自訂 trait 和範圍提供者,而不僅僅是重命名 addTeardownBlock。TestScoping

診斷遷移後變化的弱引用檢查

如果遷移後的擁有權測試失敗,請按順序檢查以下幾點:

  1. 確認觀察點。 預期的釋放必須在弱引用斷言之前發生。
  2. 檢查 fixture 屬性。 剩餘的屬性在另一個屬性被清除後仍可能擁有計算器。
  3. 檢查區域別名和閉包捕獲。 已解包的物件或強捕獲的引用可能會延長檢查過程中的擁有權。
  4. 檢查輔助函數範圍。 輔助函數的延遲清理可能對測試的預期契約來說執行得太早。
  5. 檢查未完成的工作。 如果真實物件啟動非同步工作,請在應用同步釋放預期之前定義並等待其完成。

非-nil 的弱引用識別在觀察點仍然存在的物件。在更改斷言或新增延遲之前,請調查剩餘的擁有者。在這個合成範例中,沒有需要等待的非同步工作,診斷控制準確展示了額外擁有者如何影響結果。

當計算和擁有權斷言表達與基準相同的預期結果時,遷移即完成:計算 12、移除 fixture 的預期擁有權,並透過獨立持有的弱引用驗證釋放。隨著測試演進,保持該序列可見。

來源