はじめに

こんにちは!システム開発部のYです。

Xcode 27で相互運用モードが追加され、XCTestからSwift Testingへの移行がしやすくなりました。
本記事では、以下のセッション内容をまとめています。詳細については、WWDCセッションをご参照ください。

Migrate to Swift Testing(WWDC2026 Session 267)

Swift Testingの振り返り

Swift Testingのテストは @Test マクロで宣言し、アサーションには #expect マクロを使います。

import Testing

@testable import DemoApp

@Test func `Default climate: tropical`() async throws {
    let fruit = Fruit(name: "Coconut")
    #expect(fruit.climate == .tropical)
}

XCTestと比較した際に以下の点が挙げられます。

  • testプレフィックスの命名規則は不要です。@Test マクロを関数に付与するだけでテストとして認識されます。
  • XCTAssertEqual / XCTAssertTrue など多数のアサーション関数を、単一の #expect マクロに置き換えられます。
  • バッククォートで囲むことで、`Default climate: tropical` のように、スペースや記号を含む自然な文章をテスト名にできます。
  • 無条件で失敗させたい場合は、XCTFail() の代わりに Issue.record() を使います。

Swift TestingはSwift Concurrencyにネイティブ対応しており、テストがデフォルトで並列実行されるため、テストスイート全体の実行時間の短縮が期待できます。

移行戦略

セッションで推奨されているのは、リスクを最小化する段階的なアプローチです。

  1. 既存のXCTestはそのまま残す – 動いているテストを無理に置き換えない
  2. 新規テストはSwift Testingで書く
  3. 頻繁に更新するテストから移行する – 触る機会が多いコードから順に
  4. 一度に移行するのは数個ずつ – 変更を小さく保ち、問題の切り分けを容易にする

この「共存期間が長くなる」戦略を現実的にするのが、次に紹介する相互運用性です。

テストフレームワーク相互運用性

移行中に起きること

移行を進める間、テストターゲットの中ではXCTestとSwift Testingのテストが同居します。
この時扱いに困るのが共有ヘルパー関数です。中身は XCTFail などXCTestのAPIで書かれているのに、呼び出し元にはSwift Testingへ移行済みのテストが増えていくからです。

func testUniqueFruitNames() async throws {
    assertUnique(Market.fruits + [Fruit.lychee])
}

// TestHelpers.swift

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("Duplicate name: \(name)", file: file, line: line)
        }
    }
}

このヘルパーを移行済みのテストから呼ぶと、Swift Testingのテストの中でXCTestのAPI(XCTFail)が発火することになります。

@Test func `Unique fruit names`() {
    assertUnique(Market.fruits + [Fruit.lychee])  // 中で XCTFail が発火したら?
}

このように、「issueを報告するAPI」と「そのAPIが呼ばれたテスト」が異なるフレームワークに属している場合に生じるissueを、セッションではクロスフレームワークissueと呼んでいます。
Xcode 27のテストフレームワーク相互運用性は、このクロスフレームワークissueを正しく検出・報告する仕組みです。
これがあるおかげで、ヘルパー関数の書き換えを待たずにテスト本体の移行を進められます。

4つの相互運用モード

クロスフレームワークissueをどれだけ厳しく扱うかは、4つの相互運用モードで決まります。
「Swift Testingのテストの中で、XCTestのAPIが失敗を報告した時、どうなるか」を軸に、緩い順で並べるとこうなります。

モードissueの扱いテストの結果どんな時に使う
None報告されない
(両方向とも)
✅ 成功してしまう
(失敗が消える)
非推奨
一時的な回避のみ
Limited⚠️ 警告として記録✅ 成功のままXcode 27より前に作成したテストプランのデフォルト
Complete✖️ エラーとして記録✖️ 失敗する新規プロジェクトのデフォルト。移行中の推奨
Strict💥 fatal error💥 実行が即停止移行完了の最終チェック用

ざっくり言うと、None=「見ない」、Limited=「気づける」、Complete=「失敗にする」、Strict=「許さない」の4段階です。

※この表は「Swift Testingのテスト内でXCTestのAPIが失敗を報告した場合」の挙動です。逆方向(XCTestのテスト内でSwift Testing APIが失敗)は、Noneを除くどのモードでも失敗として扱われます。

Xcode 27より前に作成されたテストプランはLimitedモードを引き継ぐため、移行を始めたらまずCompleteへ引き上げるのが実質的な前提です。そうしないと、ヘルパー経由の失敗が警告どまりになり、テストは緑のまま通ってしまいます。

モードの設定方法

相互運用モードは、次の3つの方法で設定できます。

Xcode

テストプラン設定の Test Execution セクションで、いつでも変更できます。

Swift Package

Swift 6.4ツールチェーンでは、swift-tools-version が6.3以前のパッケージにはLimitedモードが適用されます。
Completeモードをデフォルトで使用するには、swift-tools-version を6.4以降に更新します。

CLI

環境変数 SWIFT_TESTING_XCTEST_INTEROP_MODE を使うと、設定にかかわらずその場で上書きできます。
値にはモード名を小文字で指定します。

SWIFT_TESTING_XCTEST_INTEROP_MODE=strict swift test

相互運用でサポートされるAPI

どんなAPIでも相互運用できるわけではありません。対象は次のAPIに限られます。

フレームワークサポートされるAPI
XCTestXCTFail、すべてのテストアサーション(XCTAssert...)、XCTExpectFailure
Swift TestingIssue.record#expect#require、known issue API(withKnownIssue)、Test.cancel()

相互運用は双方向なので、Swift Testing側のAPIはXCTestのテストの中で呼んでも機能します。
たとえば withKnownIssue を使えばXCTestのアサーション失敗を「既知の問題」としてマークでき、Test.cancel() を使えばXCTestのテストケースをスキップできます。

最終形:ヘルパーをSwift Testing APIに置き換える

相互運用性はあくまで移行期間中の一時的な手段であり、準備ができたら、ヘルパー本体もSwift Testing APIに置き換えます。
ポイントは XCTFailIssue.record に置き換えることです。

import Testing

// Before: func assertUnique(_ fruits: [Fruit], file: StaticString = #filePath, line: UInt = #line)
func assertUnique(_ fruits: [Fruit], sourceLocation: SourceLocation = #_sourceLocation) {
    var uniqueNames = Set<String>()
    for name in fruits.map(\.name) {
        if !uniqueNames.insert(name).inserted {
            Issue.record("Duplicate name: \(name)", sourceLocation: sourceLocation)
        }
    }
}

書き換えたヘルパーは、まだ移行していないXCTestのテストから呼ばれても問題ありません。
相互運用が有効な場合、XCTestのテスト内でSwift Testing APIが報告した失敗も、テストの失敗として扱われます。

よくある移行パターン

テストのスキップ: XCTSkip → Test.cancel() / .enabled(if:)

// XCTest
func testSwallowFallMigration() async throws {
    try XCTSkipIf(!isFall, "Wrong season for migration")
    // ...
}

Swift Testingでは2通りの書き方があります。まず、実行時に条件分岐する場合は Test.cancel() を使います。

@Test func `Swallow fall migration`() async throws {
    if !isFall {
        try Test.cancel("Wrong season for migration")
    }
    // ...
}

もう1つは .enabled(if:) トレイトを使う書き方です。スキップ条件を @Test の宣言側に書くので、見ただけで「いつ動くテストか」が分かります。

@Test(.enabled(if: isFall, "Wrong season for migration"))
func `Swallow fall migration`() async throws {
    // ...
}

失敗時に停止: continueAfterFailure = false → #require

XCTestでは continueAfterFailure = false でアサーション失敗時にテストを中断していました。
Swift Testingでは、中断させたいアサーションにだけ #require を使います。

@Test func example() async throws {
    #expect(Fruit.banana.climate == .temperate)  // 失敗しても続行

    try #require(Fruit.banana == Fruit.plantain) // 失敗したらここで中断
    // これ以降は #require が通った場合のみ実行される
}

テスト全体のフラグではなく、アサーション単位で継続/中断を制御できるのがSwift Testing流です。

移行ついでに強化: パラメータ化テスト

移行はテストを見直す絶好の機会でもあります。XCTestにはパラメータ化の仕組みがなかったため、複数の入力を検証するテストはループで書くしかありませんでした。その書き方をSwift Testingにそのまま持ち込むと、こうなります。

// Before: ネストしたループ — どの入力で失敗したか分かりにくい
@Test func `Birds flap wings successfully`() async throws {
    for bird in Aviary.birds {
        for count in (40...100) {
            try await bird.flapWings(count: count)
        }
    }
}

動きはしますが、これでは移行した意味が半分しかありません。
@Test(arguments:) に書き換えると、ループが消えて入力の組み合わせが宣言になります。

// パラメータ化テスト
@Test(arguments: Aviary.birds, 40...100)
func `Birds flap wings successfully`(bird: Bird, count: Int) async throws {
    try await bird.flapWings(count: count)
}

パラメータ化することで

  • 各組み合わせがデフォルトで並列実行されるため、実行時間の短縮が期待できる
  • Test Navigatorに組み合わせごとの成否が個別に表示される
  • 失敗した特定の入力値が一目で分かる

クラッシュもテストできる: Exit tests

preconditionFailureなど、プロセスが終了することを期待するコードは、XCTestでは標準APIを使って直接テストすることが困難でした。Swift TestingのExit testsなら検証できます。

// テスト対象: Bird.init(...) 内
if name.isEmpty {
    preconditionFailure("Bird name cannot be empty")
}
extension BirdTests {
    @Test func `Bird with empty name crashes`() async throws {
        await #expect(processExitsWith: .failure) {
            _ = Bird(name: "")
        }
    }
}
  • テスト本体は子プロセスで実行されるため、テストランナー自体はクラッシュしない
  • プロセスの終了ステータスで成否を判定する
  • コードカバレッジにも対応
  • 対応プラットフォームは macOS / Linux / FreeBSD / OpenBSD / Windows。iOSなどのApple製プラットフォーム(シミュレータ含む)では利用不可

XCTestを使い続けるべきケース

以下のケースでは引き続きXCTestを使用します。

  • UIオートメーションテスト: 画面操作を自動化する XCUIApplication などのAPIは、XCTestにしかありません
  • パフォーマンステスト: 実行時間などを計測する measure APIも、XCTestにしかありません
  • Objective-C例外をスローするコードのテスト: Objective-Cで書いたXCTestを使う必要があります。Swiftコードは、Swiftで書いたXCTestも含めて、Objective-C例外を安全に処理できないためです

まとめ

WWDC2026のセッション「Migrate to Swift Testing」から、Xcode 27の相互運用モードを中心にSwift Testingへの移行の進め方をまとめました。
Xcode 27より前に作成されたテストプランはLimitedモードを引き継ぎ、失敗が警告どまりでテストが通ってしまうため、まずCompleteモードへ引き上げることが移行のスタートラインになると感じました。
この記事が少しでも参考になれば幸いです。詳細はWWDCのセッションをご参照ください。

参考リンク



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる