MacでXCTestのユニットテストを移行する際は、所有権の順序を維持してください。処理を終え、意図した強参照を解放してから、弱参照を確認します。同期的なクリーンアップでは、テスト内のdeferによってこの順序が明確になります。

この手順では、Xcodeで所有権に関わるユニットテストを変換する方法を説明します。例では、人工的なグリッドのセル数を計算してから、計算器が解放されることを確認します。使用するのはローカルのSwiftオブジェクトと整数演算のみです。

移行時に維持すべきライフサイクルの動作は?

XCTestでは、フレームワークが管理する一連の処理が提供されます。テストメソッドの終了後、登録されたティアダウンブロックが登録と逆の順序で実行され、その後にインスタンスのティアダウンメソッドが実行されます。テストアサーションが失敗しても登録済みのクリーンアップは実行されますが、プロセスが終了すると実行されないことがあります。あるブロックがオブジェクトを解放し、別のブロックがその弱参照を確認する場合、こうした詳細が重要になります。テストの状態をセットアップおよびティアダウンする

たとえば、nilを確認するティアダウンブロックは、最後の意図した所有者を取り除くブロックの後に実行する必要があります。XCTestでは登録と逆の順序で実行されるため、確認ブロックを先に、解放ブロックを後に登録します。この登録順序を逆にすると、テストで観察される状態が変わります。

Swift Testingの移行ガイドでは、クラスまたはアクターのスイートにおける初期化子をセットアップに、デイニシャライザーをティアダウンに対応づけています。この対応づけは、スイートのライフタイムに伴うクリーンアップに役立ちます。所有権のアサーションには、さらにもう一つ判断が必要です。つまり、アサーションの実行前に、どの参照を正確に消すべきかということです。XCTestからのテストの移行

テストの構文を変更する前に、次の判断表を使ってください。

既存の役割 移行時の選択肢 理由
インスタンステストごとに新しいフィクスチャの状態を構築する 保存プロパティの初期化、またはinit() テストの実行前にフィクスチャを準備する
オブジェクトを解放してすぐに弱参照を確認する 明示的に解放してから#expectを実行する。通常はローカルなdefer内に記述する 観察する時点を明確にする
参照型スイートのライフタイムに紐づく同期クリーンアップを行う deinitを備えたクラスまたはアクタースイート ドキュメントに記載されたライフサイクルの対応づけに従う
クリーンアップを待機する、またはクリーンアップのエラーを報告する 非同期関数で待機してクリーンアップする、または適切なカスタムスコープを使う 中断とエラー処理に対応する実行経路が必要

この表は、ドキュメントに記載されたライフサイクルと言語仕様から導いた移行時の判断材料です。XCTestのすべてのフックに、置き換え可能なSwift Testingのメソッドがあることを意味するものではありません。

弱参照のチェックをdeinitに移すと、なぜテストが変わることがあるのか?

Swiftは、クラスのインスタンスを解放する直前にデイニシャライザーを呼び出します。デイニシャライザー内では、そのインスタンスの保存プロパティに引き続きアクセスできます。そのため、スイートのdeinitに入っただけでは、そのプロパティのいずれかが保持するオブジェクトがすでに解放されたことの証明にはなりません。デイニシャライゼーション

強参照のlayoutプロパティを持つスイートを考えてみましょう。そのレイアウトへの弱参照は、スイートのデイニシャライザーの実行中も非nilのままになることがあります。レイアウトが解放されたかを確認する前に、テストで強参照のプロパティを取り除く必要があります。

別の失敗パターンとして、スイートを保持する参照サイクルによってデイニシャライザーが実行されなくなることがあります。そのデイニシャライザー内だけに置かれたアサーションは、保持を報告する機会を得られません。これはARCの所有権ルールに従った動作です。強参照はインスタンスを生存させますが、弱参照はインスタンスの解放を妨げず、参照先のインスタンスが解放されるとnilになります。自動参照カウント

所有権をテストする際は、解放をテストするオブジェクトの外部に監視役を置いてください。テストに具体的な解放操作を用意し、テストの通常の制御フローからアサーションを実行します。この構成なら、スイート自身の解放に依存せず、テストの契約を確認できます。

XcodeでXCTestの基準となるテストを用意する

Xcode 16以降で既存のユニットテストターゲットを使います。Appleのドキュメントによると、XCTestとSwift Testingは同じターゲットとバンドル内で共存できるため、移行のために別のユニットテストバンドルを作る必要はありません。テストファイルを追加するには、File > New > New File From Templateを選び、適切なものとしてXCTest Unit TestまたはSwift Testing Unit Testを選択します。Xcodeプロジェクトへのテストの追加

以下は、完全なXCTestテストファイルです。計算器は3 * 4を返すため、期待される結果は12です。2つのティアダウン登録により、必要な実行順序が明確になります。

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

解放ブロックは、フィクスチャプロパティを更新できるようにテストケースを保持します。確認ブロックは計算器を弱参照でキャプチャするため、チェック自体が追加の強参照を作ることはありません。Appleのドキュメントでは、テスト固有のクリーンアップを、関連するセットアップとともに登録する場所としてティアダウンブロックが説明されています。テストの状態をセットアップおよびティアダウンする

基準となるテストの契約は、次のように限定されています。テスト本体の実行が終わり、フィクスチャプロパティがクリアされた後は、計算器が存在していてはなりません。メモリ消費量を測定するものでも、アプリケーションが作成したすべてのオブジェクトが解放されたと証明するものでもありません。

所有権の順序をローカルなスコープに移す

この同期的なケースでは、構造体スイートを使い、解放と検証を一つのdeferブロックにまとめます。Swiftでは、早期リターンやエラーのスローを含め、制御がブロックを囲むスコープから抜けると、deferしたコードが実行されます。複数のdeferブロックは逆の順序で実行されます。この例では、単一のブロックにすることで、解放してから確認する順序が読み取りやすくなります。制御フロー

以下は、独立して動作する完全なSwift Testingファイルです。基準となるファイルをこれに置き換えるか、プライベートなヘルパー型を別々に保てるよう、例を別ファイルにしてください。2つ目のテストは診断用の対照例です。追加の強参照があると、その参照をクリアするまで弱参照が値を保持することを示します。

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

最初のテストでは、フィクスチャが計算器を所有し、observedが所有せずに監視します。計算結果はオプショナルな整数で、#requireがアンラップします。アンラップした計算器を別のローカル変数に保存することはありません。スコープを抜けると、deferブロックがフィクスチャの参照をクリアし、監視役を確認します。

#expectが失敗してもイシューが記録され、実行は続きます。#requireが失敗するとエラーがスローされ、その実行経路は停止します。deferはrequireより前に登録されるため、requireがエラーをスローしてもクリーンアップと弱参照のチェックは実行されます。期待と確認、エラー処理

診断用の対照例では、別々の2つの期待状態を確認します。2つの所有者のうち1つをクリアしても、監視役は非nilのままです。残る所有者をクリアすると、nilになります。これらは所有権を確認する例であり、アプリケーションで測定された結果ではありません。Swiftの例を調査環境で確認しましたが、Swift実行ファイルがなかったためコンパイルできませんでした。

ヘルパーの境界、並行処理、非同期クリーンアップを確認する

ローカルなdeferは、それを囲むスコープに属します。deferブロックをファクトリヘルパーに移すと、そのヘルパーが戻るときに実行されます。そのため、テストで返されたオブジェクトを使う前に実行される可能性があります。ブロックはテスト内に置くか、ヘルパーのスコープ内で処理全体を実行するヘルパーを設計してください。外側のスコープは所有権の契約の一部です。制御フロー

フィクスチャをどこに置くかも確認してください。Swift Testingではインスタンステストメソッドごとに異なるスイートインスタンスが作成され、スイート内のテストはデフォルトで並列実行されます。インスタンス単位またはテスト単位の状態はこのモデルに適しています。一方、グローバルなフィクスチャや静的コレクションを使うと、テスト間で所有権が共有されます。スイート型によるテスト関数の整理

メインアクターを必要とするMacアプリケーションのコードでは、Swift Testingのテストを@MainActorで明示的に分離してください。移行ガイドによると、Swift Testingの同期テストメソッドは、XCTestのデフォルトのメインアクター動作を引き継ぎません。上記の人工的な計算器にはその要件はありません。XCTestからのテストの移行

同期的なdeferブロックは、待機が必要なティアダウンの代わりにはなりません。Swiftのdeferアクションからは、エラーをブロックの外へスローできません。クリーンアップが失敗する可能性がある場合は、その失敗を破棄せず、明示的に処理して報告してください。待機が必要なクリーンアップは非同期の実行経路に置き、処理本体が成功した場合もエラーをスローした場合も、そこに到達するようにしてください。エラー処理

再利用可能なフレームワーク管理のセットアップとティアダウンには、現在のSwift Testingが提供するTestScopingを使えます。これは、テストまたはスイートの前後にカスタム処理を行います。Appleのドキュメントでは、Swift 6.1およびXcode 16.3で利用可能とされています。複数のテストに共通のスコープが必要な場合にサポートされている方法ですが、単にaddTeardownBlockと名前を変えるだけではなく、カスタムのトレイトとスコーププロバイダーが必要です。TestScoping

移行後に弱参照のチェック結果が変わった場合の診断

移行した所有権テストが失敗した場合は、次の点を順に確認してください。

  1. 観察する時点を確認する。 弱参照のアサーションより前に、意図した解放を行う必要があります。
  2. フィクスチャのプロパティを調べる。 別のプロパティをクリアした後も、残ったプロパティが計算器を所有している場合があります。
  3. ローカルの別名とクロージャのキャプチャを調べる。 アンラップしたオブジェクトや強参照としてキャプチャされた参照が、チェックの間も所有権を延長することがあります。
  4. ヘルパーのスコープを調べる。 ヘルパーのdeferによるクリーンアップが、テストで意図した契約より早く実行される場合があります。
  5. 未完了の処理を調べる。 実際のオブジェクトが非同期処理を開始する場合は、同期的な解放を期待する前に、その完了を定義して待機してください。

弱参照が非nilであれば、観察した時点でオブジェクトがまだ存在することを示します。アサーションを変更したり待ち時間を追加したりする前に、残っている所有者を調べてください。この人工的な例には待機が必要な非同期処理はなく、診断用の対照例では追加の所有者が結果にどう影響するかを正確に示しています。

移行が完了したといえるのは、計算と所有権のアサーションが基準となるテストと同じ意図した結果を表したときです。12を計算し、フィクスチャが意図していた所有権を取り除き、独立して保持された弱参照を通じて解放を確認します。テストを変更するときも、この順序が見える状態を保ってください。

参考資料