XCTestは、Xcodeに同梱されているAppleの公式テストフレームワークです。ユニットテスト・UIテスト・パフォーマンステストの3種類を書けます。Xcode 16でSwift Testingが加わってからは、新しく書くユニットテストはSwift Testing、画面操作と性能測定はXCTest、という分担が基本になりました。この記事では、2026年9月14日に公開されたXcode 27を前提に、XCTestの書き方と、Swift Testingとどう併用するかを実行例つきで説明します。
まとめ:XCTestの役割とSwift Testingとの使い分けの要点
- XCTestは非推奨ではありません。AppleはUIテストとパフォーマンステストにはXCTestを使い続けるよう案内しています。
- 新しいユニットテストはSwift Testingが既定です。Xcode 16以降、Unit Testing Bundleのテンプレートで選ばれる既定のテストシステムはSwift Testingです。
- 1つのテストターゲットに両方を同居させられます。ただし、1つのテスト関数の中で2つのフレームワークのAPIを混ぜないでください。
- 非同期処理は
async throwsのテストメソッドで書き、コールバック型のAPIだけexpectationとfulfillment(of:timeout:)で待ちます。 - Xcode 27では相互運用が既定で有効になり、limited・complete・strict・noneの4つのモードから選べます。Xcode 27より前に作ったテストプランはlimited、新規プロジェクトはcompleteで動きます。
以下、テストの種類ごとに書き方を説明し、最後にSwift Testingへ移行するときの判断基準を示します。
XCTestの位置づけ:Xcode 27でのXCTestとSwift Testingの関係
XCTestで書ける3種類のテスト
ユニットテストは XCTestCase のサブクラスに書き、UIテストは XCUIApplication でアプリを別プロセスとして起動して操作し、パフォーマンステストは measure(metrics:block:) で処理時間やメモリを記録します。UIテストの部分は「XCUITest」と呼ばれることもあります。Xcode 16.3からは、XCUIApplication などのUI操作用APIがXCUIAutomationという独立したフレームワークに移りました。ただしXCTestがXCUIAutomationを再エクスポートしているので、これまでどおり import XCTest だけで使えます。
Swift Testingとの違いと使い分けの基準
Swift TestingはXcode 16で同梱された新しいテストフレームワークで、Swiftのマクロを使って書きます。AppleはXCTestのドキュメントで、Swift TestingとXCTestを同じテストターゲットに共存させてよいとしたうえで、UIテストとパフォーマンステストではXCTestを使い続けるよう案内しています。
| 項目 | XCTest | Swift Testing |
|---|---|---|
| テストの宣言 | XCTestCase継承+test接頭辞 | @Test属性 |
| 検証 | XCTAssert系の関数 | #expect/#require |
| 前処理 | setUp系メソッド | init |
| パラメータ化 | なし | arguments: |
| UIテスト | 可(XCUIApplication) | 不可 |
| 性能測定 | 可(measure) | 不可 |
| Objective-C | 可 | 不可 |
新しく書くロジックのテストはSwift Testing、画面操作・性能測定はXCTestにします。Objective-Cの例外を投げるコードも例外で、WWDC26のセッション「Migrate to Swift Testing」によると、SwiftのコードではXCTestで書いても例外を安全に扱えないため、Objective-Cで書いたXCTestでテストする必要があります。
テストターゲットの追加とテストの実行手順
既存プロジェクトにテストを足すときは、File > New > Target を開き、TestセクションのUnit Testing Bundle(ユニットテスト用)かUI Testing Bundle(UIテスト用)を選びます。Unit Testing Bundleの作成画面には「Testing System」という欄があります。ここでSwift TestingとXCTestのどちらでテンプレートを作るかを選び、Xcode 16以降の既定はSwift Testingです。XCTestで書きたい場合は、ここでXCTestに切り替えます。
⌘U でスキーム内の全テストを、テストナビゲータ(⌘6)や行番号横のひし形アイコンで個別のテストを実行します。テストファイルの先頭に @testable import アプリのモジュール名 を書くと、internal の型やメソッドまでテストから参照できます。
Command Line Toolsだけを入れたMacでは、XCTestを使えません。Command Line Tools(Swift 6.3.3)で import XCTest を含むパッケージをビルドすると、no such module 'XCTest' で止まりました。Swift TestingのTesting.frameworkは入っていますが、素の swift test では見つからず、-Xswiftc -F と -Xlinker -rpath で /Library/Developer/CommandLineTools/Library/Developer/Frameworks と同じ階層の usr/lib を指定して初めて実行できました。CIでXCTestを回すならXcode本体が必要です。
XCTestCaseによるユニットテストの書き方
XCTestCaseの継承とtest接頭辞のテストメソッド
XCTestCase を継承したクラスを作り、名前が test で始まる引数なしのメソッドを書くと、XCTestはそれを1件のテストとして実行します。次の例は、在庫を確認してから商品をカートに追加する Cart 型をテストするものです。在庫の確認はプロトコル経由で外から差し込めるようにしてあり、テストでは常に決まった答えを返すスタブを渡します。
import XCTest
@testable import Cart
final class StubStock: StockChecking {
let available: Set<String>
init(_ available: Set<String>) { self.available = available }
func isAvailable(_ sku: String) async -> Bool { available.contains(sku) }
}
final class CartXCTests: XCTestCase {
var cart: Cart!
override func setUpWithError() throws {
cart = Cart(stock: StubStock(["A", "B"]))
}
override func tearDownWithError() throws {
cart = nil
}
func test_add_合計金額が加算される() async throws {
try await cart.add(Item(sku: "A", price: 300))
try await cart.add(Item(sku: "B", price: 200))
XCTAssertEqual(cart.total, 500)
}
func test_first_追加した商品を取り出せる() async throws {
try await cart.add(Item(sku: "A", price: 300))
let item = try XCTUnwrap(cart.first(sku: "A"))
XCTAssertEqual(item.price, 300)
}
}
テストメソッド名を「対象_条件_期待結果」の形にすると、失敗時にテストナビゲータを見るだけで原因の見当がつきます。依存先をプロトコルで受け取ってスタブを渡す考え方は、モックとスタブの違いとテストダブル5分類で整理しています。
この例は、Cart がメインアクターに属さない場合(Swift Packageのライブラリなど)の書き方です。Xcode 26以降で新規作成したアプリは、モジュールの既定がメインアクター分離になっています。その場合、同期の setUpWithError() からは Cart を作れません。Appleのドキュメントの案内どおり、前処理を @MainActor override func setUp() async throws にし、テストメソッドにも @MainActor を付けてください。
setUpWithErrorとtearDownの実行順
テストメソッドを1件実行する前に、XCTestは非同期版の setUp() async throws、setUpWithError()、setUp() の順で前処理を呼びます。テスト後の後処理は tearDown()、tearDownWithError()、非同期版の順です。addTeardownBlock で登録したブロックは、後に登録したものから順に1つずつ実行され、tearDown系メソッドより先に終わります。
新しく書くなら、前処理は setUpWithError() にします。ファイルの準備やモックサーバーの起動のように失敗しうる処理を try で書け、失敗するとそのテストが失敗として記録されるからです。非同期の準備が要るときだけ setUp() async throws を使います。テストごとにインスタンスが作り直されるので、プロパティはテストをまたいで共有されません。static なプロパティやシングルトンだけは状態が残るため、後処理で必ず元に戻してください。
XCTAssert系アサーションの使い分け
| 関数 | 検証する内容 |
|---|---|
| XCTAssertEqual | 2つの値が等しい |
| XCTAssertEqual(_:_:accuracy:) | 数値の差が許容誤差以内 |
| XCTAssertTrue/False | 条件式の真偽 |
| XCTAssertNil/NotNil | Optionalがnilかどうか |
| XCTUnwrap | nilでなければ値を返す |
| XCTAssertThrowsError | 式がエラーを投げる |
| XCTFail | 無条件に失敗させる |
| XCTSkipIf/XCTSkipUnless | 条件次第でテストを飛ばす |
| XCTExpectFailure | 既知の失敗として扱う |
浮動小数点の比較には accuracy: 付きの XCTAssertEqual を使います。値、期待値、許容誤差の3つは同じ数値型でなければなりません。Optionalを取り出して後続の検証に使うなら、XCTAssertNotNil と強制アンラップを組み合わせるより XCTUnwrap が安全です。nilのときはクラッシュせず、テストの失敗として記録されます。
XCTAssertThrowsError が受け取るのは @autoclosure () throws の式なので、await が必要な呼び出しは渡せません。async関数がエラーを投げることを確かめるには、do–catch で受けて型と値を比べます。
func test_add_在庫切れはエラーになる() async {
do {
try await cart.add(Item(sku: "Z", price: 100))
XCTFail("outOfStockが投げられるはず")
} catch {
XCTAssertEqual(error as? CartError, .outOfStock("Z"))
}
}
非同期処理のテスト:async testとXCTestExpectation
async throwsのテストメソッド
async/awaitを使うコードは、テストメソッドを async または async throws にすれば、そのまま await で呼べます。投げられたエラーはテストの失敗として記録されます。前の章の test_add_合計金額が加算される がこの書き方の例で、待ち合わせのためのコードは要りません。
コールバック型APIを待つexpectationとfulfillment
完了ハンドラで結果を返す古いAPIは、XCTestExpectation を作り、コールバックの中で fulfill() を呼んで待ちます。asyncのテストメソッドで待つときは fulfillment(of:timeout:enforceOrder:) を使ってください。同期版の wait(for:timeout:) は、Xcode 14.3からasyncの関数の中では使えない(unavailable)扱いになりました。Appleはテストがデッドロックするおそれがあることを理由に挙げ、代わりに fulfillment(of:timeout:enforceOrder:) を使うよう案内しています。
func test_loadLegacy_完了ハンドラの値を検証する() async {
let exp = expectation(description: "completion")
cart.loadLegacy { value in
XCTAssertEqual(value, 42)
exp.fulfill()
}
await fulfillment(of: [exp], timeout: 1.0)
}
通知が複数回届くことを確かめたいときは、expectedFulfillmentCount に必要な回数を入れます。複数のexpectationが決まった順に満たされることを確かめたいなら、enforceOrder: true を指定します。ネットワークを実際に呼んで遅いなら、タイムアウトを延ばさず依存先をスタブに差し替えてください。
XCUITestによるUIテストの書き方
XCUIApplicationの起動引数と環境変数
UIテストではアプリを別プロセスで起動するため、アプリ内部の変数を直接書き換えられません。テスト用の状態を作るには、launchArguments(文字列の配列)と launchEnvironment(文字列の辞書)に値を入れてから launch() を呼び、アプリ側でそれを読みます。どちらも次の起動のときに渡される値で、起動した後に変えても、動いているアプリには反映されません。
import XCTest
final class LoginUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
func test_ログイン後にホームが表示される() throws {
let app = XCUIApplication()
app.launchArguments = ["-uitest"]
app.launchEnvironment["API_BASE_URL"] = "http://localhost:8080"
app.launch()
let email = app.textFields["login.email"]
email.tap()
email.typeText("[email protected]")
app.secureTextFields["login.password"].tap()
app.secureTextFields["login.password"].typeText("password")
app.buttons["login.submit"].tap()
XCTAssertTrue(app.staticTexts["home.title"].waitForExistence(timeout: 5))
}
}
accessibilityIdentifierとwaitForExistenceによる要素の特定
上の例の "login.email" は、アプリ側のSwiftUIで .accessibilityIdentifier("login.email") と付けた識別子です。この値はユーザーには表示されないため、表示文言を翻訳・変更してもテストは壊れません。UIテストを書き始める前に、識別子の命名ルールをチームで決めておいてください。
画面遷移や通信の結果を待つときは、sleep で固定時間止めるのではなく waitForExistence(timeout:) を使います。要素が現れた時点で次に進み、タイムアウトしたら false を返すので、XCTAssertTrue で包めば失敗の場所がはっきりします。たまに落ちる不安定なUIテストの原因は、待ち方が固定時間になっていることと、前のテストが残したログイン状態などに引きずられていることがよくあります。launchArguments でテストごとに初期状態を作り直せば、両方とも防げます。
UI automation recordingによる操作の記録
Xcode 26でUIテストの記録機能が新しくなりました。テストメソッドの中で、エディタの端にある記録ボタンを押すと、シミュレータや実機での操作がテストコードとして書き込まれます。生成されたコードは要素を表示文言で探していることがあります。記録された要素クエリには同じ要素を指す別の候補が用意されているので、識別子で探すものに切り替えると、文言の変更に強くなります。実機やクラウドの多数の端末で回したい場合は、Firebase Test Labの実機と仮想端末の使い分けや、XCUITestを内部で使うAppium 3でのモバイルテスト自動化も比べてみてください。
パフォーマンステストのmeasureとベースライン
measure(metrics:block:) にメトリクスを渡すと、ブロックを繰り返し実行して値を記録します。XCTClockMetric は経過時間で、CPUが待機していた時間も含みます。XCTMemoryMetric は、ブロックを実行する前と後の物理メモリ使用量の差を記録します。
func test_並べ替えの処理時間とメモリ() {
let input = (0..<100_000).map { _ in Int.random(in: 0..<1_000_000) }
measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) {
_ = input.sorted()
}
}
記録した結果からベースラインと許容する最大標準偏差を設定すると、測った値がその範囲を超えて悪化したときにテストが失敗します。ベースラインが無いあいだは値を記録するだけで、比較はされません。Appleのドキュメントは、性能テストを実際の動作に近い条件で測るため、テスト用のビルドをRelease構成にして「Debug executable」を切り、そのテストプランではコードカバレッジとランタイムサニタイザを無効にするよう勧めています。ユニットテストと同じテストプランのまま測ると、カバレッジ収集の負荷まで数値に入ります。
xcodebuildでのCI実行とカバレッジ取得
CIではXcodeの画面を使わず、xcodebuild test でテストを実行します。-only-testing と -skip-testing には「ターゲット/クラス/メソッド」の形で、実行するテストと除外するテストを指定できます。-resultBundlePath を付けると、結果が .xcresult バンドルとして保存されます。
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-only-testing:MyAppTests/CartXCTests \
-enableCodeCoverage YES \
-resultBundlePath build/TestResults.xcresult
xcrun xccov view --report build/TestResults.xcresult
xcrun xcresulttool get test-results summary --path build/TestResults.xcresult
-destination に書くシミュレータ名は、xcrun simctl list devices で確認して、ランナーに入っているものに合わせてください。xccov view --report はファイルごとのカバレッジ率を表示します。目標値の決め方はテストカバレッジの網羅率と目標設定で説明しています。実行するテストや環境変数の組み合わせを切り替えたいときは、テストプラン(.xctestplan)を1つのスキームに複数持たせます。ビルドから配信までの自動化には、fastlaneでのiOSビルドと配信の自動化も使えます。
Swift Testingへの移行判断と相互運用モード
同じ処理をSwift Testingで書いた例
前の章の在庫チェックのテストをSwift Testingで書き直すと、次のようになります。StubStock は前の章のXCTestのファイルで定義したものを、同じテストターゲットからそのまま使います。これは筆者の環境のCommand Line Tools(Swift 6.3.3)で、フレームワークの検索パスを指定して swift test を実行し、パラメータ化した2ケースを含む2件のテストが通ることを確認したコードです。テストごとに構造体のインスタンスが新しく作られるので、setUp にあたる処理はプロパティの初期化に書きます。
import Testing
@testable import Cart
struct CartSwiftTests {
let cart = Cart(stock: StubStock(["A", "B"]))
@Test("在庫がある商品は追加できる", arguments: ["A", "B"])
func addAvailable(sku: String) async throws {
try await cart.add(Item(sku: sku, price: 100))
#expect(cart.total == 100)
}
@Test func outOfStock() async {
await #expect(throws: CartError.outOfStock("Z")) {
try await cart.add(Item(sku: "Z", price: 100))
}
}
}
XCTestでは do–catch が必要だったasync関数のエラー検証が、#expect(throws:) の1回で書けます。値だけが違う似たテストは arguments: で1本にまとめられ、結果には引数ごとの成否が表示されます。
テスト対象がメインアクター既定のアプリのモジュールなら、スイートに @MainActor を付けて @MainActor struct CartSwiftTests とします。付けないと、main actor-isolated property 'total' cannot be accessed from outside of the actor などのエラーでビルドできません。逆に、メインアクターに属さない型のテストに一律で付けると、別のデータ競合のエラーになることがあります。
Xcode 27の4つの相互運用モード
Swift TestingのテストからXCTestのAPI(たとえば XCTFail を中で呼ぶ共通ヘルパー)を呼ぶと、フレームワークをまたいだ失敗になります。WWDC26のセッション「Migrate to Swift Testing」によると、Xcodeはこの相互運用を既定で有効にしています。扱い方は次の4つのモードから選びます。
| モード | XCTest由来の失敗の扱い | 既定になる場合 |
|---|---|---|
| limited | 警告 | Xcode 27より前に作ったテストプラン |
| complete | エラーのまま | 新規プロジェクト |
| strict | 致命的エラーでテストを止める | なし |
| none | 報告しない(相互運用を無効化) | なし |
モードは、テストプランの設定画面のTest Executionで変更します。Swift Packageでは、Swift 6.4のツールチェーンで swift-tools-version が6.4未満(例:6.3)のパッケージを実行するとlimited、6.4以上に上げるとcompleteになります。環境変数 SWIFT_TESTING_XCTEST_INTEROP_MODE にモード名を小文字で入れて、どちらも上書きできます。
注意が要るのは、既存プロジェクトをXcode 27で開いたときです。limitedでは、Swift Testingのテストの中で起きた XCTFail が警告に格下げされるので、本当は失敗しているテストがCIで緑のまま通ります。移行途中のプロジェクトはcompleteに上げ、XCTestのAPIを全部置き換えられたらstrictにして、XCTestのAPIが残っていないことを確かめてください。noneは、フレームワークをまたいだ失敗を両方向とも報告しなくなります。WWDC26のセッションも、使うなら一時的な回避に限り、completeかstrictを選ぶよう勧めています。
XCTestを残すべきテストと移行の順番
すべてを一度にSwift Testingへ書き直す必要はありません。WWDC26のセッションが勧めているのも段階的なやり方です。既存のXCTestはそのまま残し、新しいテストはすぐSwift Testingで書き始め、既存のテストはよく更新するものから少しずつ移していきます。UIテスト・measure を使う性能テスト・Objective-Cのテストは移せないので、XCTestのまま残します。よく更新するテストの中でも、入力値だけが違うテストメソッドの集まりは、パラメータ化で本数を大きく減らせるので先に移す価値があります。単体・結合・E2Eのテストをどの割合で書くかは、テストピラミッドの配分とテスト戦略設計を参考にしてください。
よくある質問
XCTestは非推奨になったのですか?
なっていません。廃止されたのは一部のシンボルだけで、フレームワーク自体はXcode 27にも同梱されています。変わったのは、新しく書くユニットテストの既定がSwift Testingになったことです。
XCTestとXCUITestは何が違うのですか?
XCUITestは、UIテスト用のAPI群(XCUIApplication や XCUIElement など)の通称です。Xcode 16.3からこれらのAPIはXCUIAutomationという独立したフレームワークに移りましたが、XCTestが再エクスポートしているため、テストコードは import XCTest のままで使えます。UIテストはUI Testing Bundleのターゲットに書きます。
Xcodeの「Testing System」とは何ですか?
Unit Testing Bundleのテンプレートを、Swift TestingとXCTestのどちらで作るかを選ぶ欄です。既定はSwift Testingで、後から同じターゲットにもう一方のテストを足すこともできます。
setUpとsetUpWithErrorはどちらを使えばよいですか?
setUpWithErrorを使ってください。準備の失敗をエラーとして記録できます。両方を実装するとsetUpWithErrorが先に呼ばれます。
XCTestExpectationはasync/awaitでも必要ですか?
asyncのAPIは await で呼ぶだけなので不要です。完了ハンドラや通知で結果を返すAPIを待つときだけ使い、fulfillment(of:timeout:) で待ちます。