WWDC Quick Look 💓 By SwiftGGTeam
Swift Testing へ移行する

Swift Testing へ移行する

元の動画を見る

ハイライト

Xcode 27 はテストフレームワークの相互運用性をデフォルトで有効にし、同じテスト Target 内で XCTest と Swift Testing を混在させられるようにします。開発者はすべてのテストを一度に書き直すのではなく、段階的に移行できます。

主要内容

「全面書き直し」から「段階的移行」へ

昨年 Xcode 16 で Swift Testing が登場したとき、多くのチームは魅力を感じながらも手を出しにくい状況でした。成熟したプロジェクトには何千行もの XCTest コードがあることが多く、すべてを書き直すのはリスクが高く、ROI も高くありません。多くのチームは、新規プロジェクトには Swift Testing を使い、既存プロジェクトでは XCTest を保守し続ける、という選択をしました。

(03:16) この session の中心的な主張は、古いテストを一度に全部変えない、というものです。よく触るテストを毎回少しずつ移行し、残りの XCTest はそのまま動かします。同じテスト Target に二つのフレームワークのテストファイルを同時に含められ、Xcode はそれらをまとめて実行します。

相互運用性:二つのフレームワークの「翻訳層」

(05:48) 相互運用性 (interoperability) は Xcode 27 の新機能です。Swift Testing のテスト関数内で XCTFail のような XCTest API を呼べますし、XCTest の中で #expect のような Swift Testing API を呼ぶこともできます。フレームワークは内部で問題を適切なテストランナーへ自動的に報告します。

例として、何十もの XCTest から呼ばれている assertUnique という helper 関数があり、内部で XCTFail を使って失敗を報告しているとします。この helper を Swift Testing のテストからも再利用したい場合、相互運用性によりそのまま使えます。XCTFail の呼び出しは Swift Testing の issue 報告へ変換されます。

四つの相互運用モード

(07:43) Xcode 27 は、フレームワークをまたいだ問題の扱いを制御する四つのモードを提供します。

  • Limited(Xcode 27 より前に作成されたプロジェクトのデフォルト):XCTest が発生させたフレームワーク横断の問題は warning として表示され、テストは通過します
  • Complete(Xcode 27 の新規プロジェクトのデフォルト):フレームワーク横断の問題は error のままで、テストは失敗します
  • Strict:XCTest が発生させたフレームワーク横断の問題は fatal error を直接起こし、Swift Testing API へ置き換えることを強制します
  • None:相互運用性を完全に無効にし、二つのフレームワークは互いを認識しません

(09:05) おすすめの戦略は、まず Complete モードで問題を表面化させ、それから一つずつ置き換えることです。移行がかなり進んだら、CI で Strict モードへ切り替え、新しい XCTest API 呼び出しが Swift Testing のテストへ混ざるのを防ぎます。

パラメータ化テスト:ネストしたループを置き換える

(15:38) Swift Testing のパラメータ化テストは、XCTest 時代によくあった痛点を解決します。たとえば、すべての鳥について、40回から100回まで羽ばたかせたときの挙動をテストしたいとします。XCTest ではネストした for ループを書くことになります。

func testBirdsFlapWings() async throws {
    for bird in Aviary.birds {
        for count in (40...100) {
            try await bird.flapWings(count: count)
        }
    }
}

問題は明らかです。ループ内のどこかが失敗したとき、どの for ループで落ちたかは分かっても、具体的にどの鳥のどの回数だったかは分かりません。デバッグにはログを追加するか、デバッガを使う必要があります。

(16:47) Swift Testing のパラメータ化テストは、ループを独立したテストケースへ展開します。

struct BirdTests {
    @Test(arguments: Aviary.birds, 40...100)
    func `鳥が正常に羽ばたく`(bird: Bird, count: Int) async throws {
        try await bird.flapWings(count: count)
    }
}

各パラメータの組み合わせは独立したテストケースになり、Test Navigator で展開して確認できます。さらに重要なのは、デフォルトで並列実行されるため、総実行時間が大幅に短くなることです。失敗時には、どの鳥がどの回数で失敗したかを Xcode が直接示してくれるため、推測する必要がありません。

Exit tests:クラッシュ経路をテストする

(18:08) Swift Testing の exit test により、preconditionFailurefatalError を起こすコードパスをテストできます。フレームワークはテスト本体を子プロセスで実行するため、メインプロセスは影響を受けません。

extension BirdTests {
    @Test func `名前が空の Bird はクラッシュする`() async throws {
        await #expect(processExitsWith: .failure) {
            _ = Bird(name: "")
        }
    }
}

(19:57) 実行後、Code Coverage で以前は赤く表示されていた preconditionFailure の行が緑になります。このクラッシュ経路はテストでカバーされたことになります。

詳細

既存の XCTest ファイルに Swift Testing テストを追加する

(04:18) 移行の第一歩では、新しいファイルを作る必要はありません。既存の .swift テストファイルの末尾で import Testing し、@Test 関数を書くだけです。

import Testing
@testable import DemoApp

@Test func `デフォルトの気候: 熱帯`() async throws {
    let fruit = Fruit(name: "ココナッツ")
    #expect(fruit.climate == .tropical)
}

重要な点:

  • @Test マクロは、これが Swift Testing のテスト関数であることを示します
  • バッククォート、つまり raw identifier により、関数名に空白や句読点を含められるため、テスト名を読みやすい自然言語で書けます
  • #expectXCTAssertEqual を置き換え、失敗時には式を自動で展開して左右の値を表示します
  • XCTestCase を継承する必要はなく、関数はファイルのトップレベルに置けます

helper 関数を XCTFail から Issue.record へ移行する

(05:03) XCTest 時代の helper 関数があるとします。

func assertUnique(_ fruits: [Fruit], file: StaticString = #filePath, line: UInt = #line) {
    var uniqueNames = Set<String>()
    for name in fruits.map(\.name) {
        if !uniqueNames.insert(name).inserted {
            XCTFail("重複した名前: \(name)", file: file, line: line)
        }
    }
}

(10:12) これを Swift Testing にも対応する版へ書き換えます。

import Testing

func assertUnique(_ fruits: [Fruit], sourceLocation: SourceLocation = #_sourceLocation) {
    var uniqueNames = Set<String>()
    for name in fruits.map(\.name) {
        if !uniqueNames.insert(name).inserted {
            Issue.record("重複した名前: \(name)", sourceLocation: sourceLocation)
        }
    }
}

重要な点:

  • XCTFailIssue.record に置き換えます
  • file:line: 引数を sourceLocation: SourceLocation に置き換えます
  • 更新後の helper が XCTest と Swift Testing の両方から呼ばれても、どちらでも正しい失敗位置が報告されます

テストをスキップする三つの書き方

(13:10) XCTest では XCTSkipIf でテストをスキップします。Swift Testing は同等の書き方を三つ提供します。

let isFall = false

// XCTest の書き方
func testSwallowFallMigration() async throws {
    try XCTSkipIf(!isFall, "渡りの季節ではありません")
}

// Swift Testing: テスト本体の中で Test.cancel を呼ぶ
func testSwallowFallMigration() async throws {
    if !isFall {
        try Test.cancel("渡りの季節ではありません")
    }
}

// Swift Testing の推奨: 条件を trait に移す
@Test(.enabled(if: isFall, "渡りの季節ではありません"))
func `ツバメの秋の渡り`() async throws {
    // テストロジック
}

重要な点:

  • Test.cancel は、テスト本体の中で動的にスキップするかどうかを決められます
  • .enabled(if:) trait はスキップ条件を関数シグネチャへ移し、テスト本体をきれいに保ちます
  • trait 方式では、関数本体を読まなくてもテストの「実行条件」が一目で分かります

continueAfterFailure の代わりに #require を使う

(13:41) XCTest では continueAfterFailure = false を設定し、最初の失敗した assertion でテストを止めます。Swift Testing では #require マクロで同じ効果を実現します。

func testExample() async throws {
    #expect(Fruit.banana.climate == .temperate)

    try #require(Fruit.banana == Fruit.plantain)
    // #require が失敗すると error を throw し、後続のコードは実行されない
}

重要な点:

  • #expect は失敗しても後続の assertion を実行し続けます
  • #require は失敗時に error を throw し、テストをすぐ停止します
  • どの assertion を「強く停止」させ、どれを「柔らかく収集」するかを自由に選べます

Swift Package で相互運用モードを制御する

(12:15) Swift Package プロジェクトのデフォルトモードは tools version によって決まります。

  • swift-tools-version: 6.3 以前:デフォルトは limited モード
  • swift-tools-version: 6.4 以降:デフォルトは complete モード

Terminal では環境変数で上書きできます。

SWIFT_TESTING_XCTEST_INTEROP_MODE=strict swift test

このコマンドは CI パイプラインで有用です。相互運用モードを strict に設定し、チームメンバーが Swift Testing のテスト内で XCTest API をこっそり呼ばないようにできます。

重要な示唆

既存プロジェクトのテストを「健康診断」する

  • 何をするか: プロジェクト内の XCTest helper 関数をスキャンし、段階的に Swift Testing API へ移行します
  • なぜ価値があるか: これらの helper は多くのテストから呼ばれていることが多く、移行するとすべての呼び出し側が自動的に恩恵を受けます。Issue.recordXCTFail より詳細な失敗情報を提供します
  • どう始めるか: カスタムの assertSnapshotassertUnique など、呼び出し回数が多い helper を一つ選び、XCTFailIssue.record に置き換え、テストを一度実行して挙動が一致することを確認します

パラメータ化テストでデータ駆動テストのループをなくす

  • 何をするか: テストデータを for ループで回している XCTest を、@Test(arguments:) へ書き換えます
  • なぜ価値があるか: 並列実行で CI 時間が短くなります。失敗時も具体的なパラメータの組み合わせまで分かるため、デバッグ時の推測が不要になります
  • どう始めるか: ネストしたループを持つテストを一つ見つけ、ループ変数を関数パラメータへ取り出し、ループ範囲を arguments: に置きます

クラッシュ経路にテストを追加する

  • 何をするか: exit test を使い、すべての preconditionFailurefatalErrorassertionFailure の呼び出し箇所をカバーします
  • なぜ価値があるか: これらのコードパスは以前ほとんどテストできませんでした。今は #expect(processExitsWith:) で安全に検証でき、Code Coverage も改善できます
  • どう始めるか: Xcode で Code Coverage を開き、赤く表示された preconditionFailure 行を見つけ、対応する exit test を書きます

CI でテストフレームワークの純度を強制する

  • 何をするか: CI 設定に SWIFT_TESTING_XCTEST_INTEROP_MODE=strict を追加します
  • なぜ価値があるか: 新しく書く Swift Testing のテストへ XCTest API が混ざるのを防ぎ、混成コードの蓄積を避けられます
  • どう始めるか: .github/workflows/ または Jenkins pipeline の swift test コマンドの前に、この環境変数を追加します

raw identifier でテストの可読性を上げる

  • 何をするか: testDefaultClimateIsTropical のような camelCase 名を、`デフォルトの気候: 熱帯` のような raw identifier に変えます
  • なぜ価値があるか: テスト名そのものがドキュメントになります。空白を含む文章は圧縮された camelCase より読みやすく、失敗ログに表示される名前も直感的です
  • どう始めるか: 新しい Swift Testing テストを書くとき、略語にせず、自然言語の説明をバッククォートで囲みます

関連セッション

  • Swift Testing をさらに活用する — カスタム traits、parallelization 制御、テスト整理戦略など、Swift Testing の高度な機能
  • Xcode 27 — Xcode 27 のテスト関連 UI 改善と Coding Assistant による移行支援
  • SwiftUI — SwiftUI プレビューと Swift Testing を組み合わせた統合テスト戦略
  • SwiftData — データベース層のテストベストプラクティスと、Swift Testing による永続化検証

コメント

GitHub Issues · utterances