Swift Digest
ST-0021 | Swift Evolution

Swift Testing と XCTest の間での的を絞った相互運用

Targeted Interoperability between Swift Testing and XCTest

Proposal
ST-0021
Authors
Jerry Chen
Review Manager
Rachel Brindle
Status
Accepted

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

01 何が問題だったのか

XCTest から Swift Testing への移行は段階的に行われることが多く、その途中では、XCTest の API を使って書かれたテストヘルパーを Swift Testing のテストから呼び出したり、その逆を行ったりする状況が生じます。しかし、Swift Testing と XCTest は基本的に独立して動作するため、フレームワークの境界をまたいで API を呼び出すと、想定外の挙動になることがありました。

たとえば、XCTest 用に書かれた次のヘルパー関数を Swift Testing のテストから呼び出すと、XCTAssertEqual の失敗は通知されず、テストはそのまま成功扱いになります。

func assertUnique(_ elements: [Int]) {
  XCTAssertEqual(Set(elements).count, elements.count, "\(elements) has non unique elements")
}

// XCTest

class FooTests: XCTestCase {
    func testDups() {
        assertUnique([1, 2, 1]) // Fails as expected
    }
}

// Swift Testing

@Test func `Duplicate elements`() {
  assertUnique([1, 2, 1]) // Passes? Oh no!
}

このように、

  • XCTest の API を Swift Testing のテストから呼び出している(または逆)
  • その API が一部または全部のケースで期待どおりに動かない
  • ビルド時にも実行時にも何の警告も出ない

という条件をすべて満たす API を、本 Proposal では lossy without interop と呼んでいます。XCTAssert* 系のアサーションのように、失敗が黙って捨てられてしまう API がこれに該当します。

このような API があると、Swift Testing への移行の途中でテストカバレッジを意図せず後退させてしまったり、移行完了後に新しく XCTest API が紛れ込んでしまっても気づけなかったりします。一方で、throw XCTSkip のように Swift Testing 側でテスト失敗として目立つ形で現れる API は、移行が必要なことがすぐにわかるので問題になりません。

02 どのように解決されるのか

Swift Testing と XCTest の間で、次の2方向の interop(相互運用)を導入します。

  • lossy without interop な XCTest API は、Swift Testing から呼び出しても期待どおりに動くようにする。あわせて、より新しい Swift Testing 相当の API への移行を促すため、実行時に診断(runtime warning issue)を出します。
  • Swift Testing API は、XCTest 側に同等機能がある場合に限り、XCTest から呼び出しても期待どおりに動くようにする。これにより、新しいテストやヘルパーを Swift Testing で書いておいても、XCTest 側からも安全に利用できます。

なお、本 Proposal が対象とする XCTest は、Swift Evolution のプロセスで扱える open source 実装である swift-corelibs-xctest です。Xcode に同梱されている proprietary な XCTest は対象外です。

Swift Testing 内での XCTest API のサポート

次の XCTest API が、Swift Testing のテスト内でも期待どおりに動くようになります。

  • アサーション(XCTAssert* および無条件失敗の XCTFail
  • XCTExpectFailure などの expected failure 系 API(利用可能な場合)。Swift Testing の issue をこの方法で marking すると、ランタイム警告 issue が記録されます。
  • issue handling trait。XCTest の issue は、可能な限り Swift Testing の issue に翻訳されます。XCTest 固有の情報は、Swift Testing 側の issue にコメントとして付与されます。

XCTSkip は Swift Testing 内で throw するとそのままテスト失敗として目立つ形で現れるため、変更はありません。XCTestExpectationXCTWaiter についても、Swift concurrency の文脈で安全に使えないため、サポートの対象外です。これらの代替としては、Swift concurrency への移行が推奨されます。async / await に置き換えにくい場合は、continuation や confirmation を利用します。

あわせて、Swift Testing 内で XCTest API が呼び出されたことを開発者に伝えるために、次の仕組みが導入されます。

  • runtime warning issue の記録。アサーション失敗とは別に、XCTest API が使われたこと自体をランタイム警告 issue として記録し、Swift Testing 相当 API への置き換えを促します。
  • strict interop モード(オプトイン)。XCTest API の使用を fatalError("Usage of XCTest API in a Swift Testing context is unsupported") で停止させ、Swift Testing のみで構成されたテストに混入しないことを強制します。

具体的な挙動の変化は次の表のとおりです。

Swift Testing のテスト内で… これまで 本 Proposal 後 本 Proposal 後(strict)
XCTAssert の失敗 偽陰性(no-op) テスト失敗 + ランタイム警告 issue fatalError
XCTAssert の成功 no-op ランタイム警告 issue fatalError
throw XCTSkip テスト失敗 テスト失敗 テスト失敗

XCTest 内での Swift Testing API のサポート

次の Swift Testing API が、XCTest のテスト内でも期待どおりに動くようになります。

  • #expect / #require#expect(throws:) や exit testing、Issue.record() を含む)
  • withKnownIssue。XCTest の issue をこの方法で marking すると、ランタイム警告 issue が記録されます。strict モードではこれが fatalError になります。
  • test cancellation

これにより、XCTest のテストの中でも XCTAssert#expect に置き換えるだけで、より新しく扱いやすい Swift Testing API の利点をすぐに享受できます。テストインフラ全体を Swift Testing に置き換えるのを待たずに、自分のペースで段階的に移行できます。

将来追加される Swift Testing の API についても、XCTest 側に同等機能がある場合に限り interop の対象になります。一方で、Swift Testing 固有の概念である trait のように XCTest 側に対応物がない機能については、interop の対象外です。なお、test cancellation は XCTest の XCTSkip と対応関係にあるため、本 Proposal で interop の対象に含まれます。

具体的な挙動の変化は次の表のとおりです。

XCTest のテスト内で… これまで 本 Proposal 後 本 Proposal 後(strict)
#expect の失敗 偽陰性(no-op) テスト失敗 テスト失敗
#expect の成功 no-op no-op no-op
XCTFailwithKnownIssue で包んだもの テスト失敗 ランタイム警告 issue fatalError

interop モード

interop の振る舞いは、次の4つのモードで切り替えられます。none 以外のいずれのモードでも、XCTest 内で呼び出された Swift Testing API(Issue.record() を含む)は期待どおりに動き、アサーション失敗はそのままテスト失敗として記録されます。モードによって挙動が変わるのは、Swift Testing 内で呼び出された XCTest API の扱いです。

  • None: interop なし。本 Proposal 適用前と同じ挙動です。
  • Limited: Swift Testing 内での XCTest API 使用に対して、これまで無視されていた XCTest issue をランタイム警告 issue として記録します。あわせて、XCTest API が使われたこと自体に対するランタイム警告 issue も出ます。interop 導入によって既存の Swift Testing テストに新たなテスト失敗を持ち込みたくないプロジェクト向けです。
  • Complete: デフォルトのモード。Swift Testing 内での XCTest API 使用について、これまで無視されていた XCTest issue を実際のテスト失敗として表面化させます。あわせて、XCTest API が使われたこと自体に対するランタイム警告 issue も出ます。
  • Strict: complete モードの警告 issue は CI 上などで見落とされやすいため、Swift Testing 内に XCTest API がまったく混入しないことを保証するモードです。XCTest API の使用を fatalError にします。

interop モードは、テスト実行時の環境変数 SWIFT_TESTING_XCTEST_INTEROP_MODE で切り替えます。

interop モード SWIFT_TESTING_XCTEST_INTEROP_MODE
None none
Limited limited
Complete complete、空文字、または不正な値
Strict strict

具体的な挙動は次の例のとおりです。Issue.record("Interop failure") のように Swift Testing API を XCTest 内で使った場合、none 以外のすべてのモードでテスト失敗として記録されます。一方、XCTFail("Interop failure") のように XCTest API を Swift Testing 内で使った場合、limited ではランタイム警告 issue 止まり、complete では本来のテスト失敗として表面化、strict では fatalError になります。

class FooTests: XCTestCase {
    func testInterop() {
      // None:     no-op
      // Limited:  ❌ "Interop failure"
      // Complete: ❌ "Interop failure"
      // Strict:   ❌ "Interop failure"
      Issue.record("Interop failure")
    }
}

@Test func `Test Interop`() {
    // None:     no-op
    // Limited:  ⚠️ "Interop failure", ⚠️ Swift Testing primitive への移行を促す警告
    // Complete: ❌ "Interop failure", ⚠️ Swift Testing primitive への移行を促す警告
    // Strict:   💥 fatalError: Swift Testing primitive を使うべき
    XCTFail("Interop failure")
}

デフォルトの interop モードと移行

新しいプロジェクトのデフォルトは “complete”、既存プロジェクトのデフォルトは “limited” になり、後者は “complete” へのオプトインが容易な形で提供されます。

具体的には、interop が利用可能になるツールチェイン版を 6.X とすると、Swift Package では Package.swiftswift-tools-version を基準に次のように決まります。

  • swift-tools-version >= 6.X: “complete” interop モード
  • swift-tools-version < 6.X: “limited” interop モード

interop の目的は挙動を変えることそのものなので、本 Proposal の適用後は、これまで「成功」とみなされていたテストが新たに失敗する状況が出てきます。これは、見逃していた本物のバグを表面化させるという意味で、基本的にはプラスの変化として捉えられます。短期的に従来の挙動に戻したい場合は、次のいずれかを指定します。

  • SWIFT_TESTING_XCTEST_INTEROP_MODE=limited: Swift Testing 内で使われた XCTest issue の重要度をエラーから警告に下げます(Swift Testing API を XCTest 内で使った場合の失敗はそのまま残ります)。
  • SWIFT_TESTING_XCTEST_INTEROP_MODE=none: interop を完全に無効化します。

なお、当初の Proposal では limited モードが Swift Testing API の issue まで含めて警告に降格していましたが、XCTFail()Issue.record() に書き換えると失敗が静かに警告へ降格してテストカバレッジを失う、という副作用がありました。Apr 2026 の amendment で limited モードの定義が見直され、Swift Testing API 由来の issue はモードによらずテスト失敗として扱う形に整理されています。

ツールとの統合

  • Swift Package では、テスト実行に使うツールチェイン版にかかわらず、Package.swiftswift-tools-version から interop モードが決まります(そのツールチェイン版に対応するデフォルトモードが選ばれます。初期リリース時点では “complete”)。
  • それ以外のプロジェクトでは、インストールされているツールチェイン版に対応するデフォルトモードが使われます。
  • どのプロジェクトでも、環境変数 SWIFT_TESTING_XCTEST_INTEROP_MODE で実行時にモードを上書きできます。

03 今後の見通し

本 Proposal の議論の中で、XCTest から Swift Testing への移行をさらに後押しするための発展方向がいくつか挙げられています。いずれも将来の構想であり、実現を約束するものではありません。

  • strict interop モードのデフォルト化。将来のリリースで、strict interop モードをデフォルトにすることが検討されています。
  • コンパイル時の fix-it による置き換えXCTAssert#expect に置き換えるなど、XCTest API の使用箇所をコンパイル時に Swift Testing API に書き換える fix-it の提供が検討されています。ただし、テスト本体の内省が必要であり、ヘルパーメソッド内の使用まで含めて完全に検出するのは難しい課題があります。
  • 新規 Swift Testing API の interop の継続評価。今後追加される Swift Testing API についても、XCTest との interop を逐次評価していくとされています。
  • XCTest API の成功時のランタイム警告。Swift Testing 内での XCTest API 使用については、失敗時だけでなく成功時にもランタイム警告 issue を出せるとより移行を促せますが、成功は失敗より頻繁に発生するため警告がノイジーになりやすく、また毎回の検証はパフォーマンスへの影響もあるため、現状は導入されていません。