【iOS】Swift Testingことはじめ
IT技術
はじめに
こんにちは!株式会社ライトコードの福岡本社でモバイルアプリエンジニアやってる こー です!
2021.11.05YOUは何しにライトコードへ?〜こーくん編〜プロジェクト内で安心感を与えられる存在になりたい!今回は、弊社のエンジニアである高さんにフィーチャー!技術力に定評のあ...
今回は現在参画中のプロジェクトでも導入が進んできたiOSのApple公式テストフレームワーク「Swift Testing」について、誕生の歴史から導入方法、便利で強力な機能などを具体的なコード例などを交え、一貫してことはじめ的に解説していこうかと思います。
それでは、早速見ていきましょう!
これまでのiOSテストの歩み
長年、iOSにおける単体テストのデファクトスタンダードは XCTest でした。
iOSの開発がObjective-Cだった頃から続く安定で強力なテストフレームワークですが、
Swift言語が進化し、モダンな機能(Result Builders, Concurrency, Macros等)を取り入れていく中で、XCTestの設計思想には少しずつ「時代とのズレ」が生じていました。
- クラス継承(XCTestCase)を強制されることによる制約
- 値型(Value Type)中心のSwift設計との不一致
- 非同期処理テストの記述の冗長性
これらの課題を一挙に解決すべく、WWDC2024で正式リリースされたのが、新しいテストフレームワーク「Swift Testing」です。
1. なぜXCTest → Swift Testing なのか?
まず、なぜAppleは既存のXCTestを拡張するのではなく、ゼロからフレームワークを作り直したのか。
その背景にある技術的な必然性を理解しておきましょう。
Objective-Cランタイムからの脱却
XCTestは、その根底にObjective-Cのランタイム依存を持っています。
テストメソッドをランタイムで見つけ出し実行する仕組みは動的で柔軟ですが、Swiftの「静的安全性」や「コンパイル時の最適化」とは相性が悪い側面がありました。
Swift Testingは、Swift 5.9で導入された マクロ(Macros) 技術を基盤となっています。
テストの検出や検証ロジックの展開をコンパイル時に行うことで、実行時のオーバーヘッドを減らし、より詳細なエラーレポートを可能としています。
並列実行(Parallel Execution)がデフォルトに
XCTestでも並列実行は可能でしたが、設定が複雑だったり、シミュレーターの起動コストがかかったりと、手軽とは言えませんでした。
Swift Testingでは、デフォルトでテストが並列に実行されます。
これは、Swiftの並行処理モデル Swift Concurrency を前提に設計されているからこそ実現できた機能です。
2. よりSwiftライクな記述へ
それでは、具体的なコードを見ながら、XCTestからどこが進化したのか見ていきましょう。
命名規則の撤廃とマクロの導入
XCTestの場合
1final class CalculatorTests: XCTestCase {
2 // 関数名は "test" で開始必須
3 func testAddition_Success() { ... }
4}Swift Testingの場合
1import Testing
2
3struct CalculatorTests {
4 // マクロで明示。関数名は自由(日本語も可)
5 @Test("足し算の正常系テスト")
6 func addition() { ... }
7}@Test マクロには、テストの表示名(Display Name)を引数として渡すことができます。
これにより、テスト結果一覧には「testAddition」ではなく「足し算の正常系テスト」と表示され、何のためのテストなのかが一目で理解できるようになります。
#expect : アサーションの表現力向上
XCTestでは、検証内容に応じてメソッドを使い分ける必要がありました。
- XCTAssertEqual
- XCTAssertTrue
- XCTAssertNil
- XCTAssertLessThan
など、多くのメソッドを記憶しなければなりませんし、メソッドが異なれば振る舞いにも気を使う必要が出てきます。
しかし、Swift Testingでは、 #expect(...) ひとつで完結できます。
1let result = 1 + 1
2#expect(result == 2)
3
4let user = User(name: "RightCode")
5#expect(user.name.contains("Code"))特筆すべきは、テスト失敗時のログ出力です。
#expect マクロはコンパイル時に式を展開し、評価された値の情報を埋め込みます。
その結果、失敗時には以下のような詳細なログが出力されます。
1Expectation failed: (user.name.contains("Code")) with user.name = "WrongName"「何と比較してどう違ったのか」を自分でログ出力しなくても、フレームワークが全て出力してくれるんですよね。
#require : 必須要件の検証
テスト実行中に、オプショナル値が nil だった場合、それ以降のテストを続けても意味がないことがあります。
この時に利用するのが、 #require(...) です。(XCTest の XCTUnwrap に相当します)
1@Test func userProfile() throws {
2 let user: User? = fetchUser()
3
4 // nilならここでテストを中断(Fail)し、例外を投げる
5 let validUser = try #require(user)
6
7 // 以降、validUserは非オプショナル型として扱える
8 #expect(validUser.isActive)
9}#expect が「検証に失敗してもテストを続行する(Soft Assertion)」のに対し、try #require は「前提条件が満たされないなら即座に止める(Hard Assertion)」という明確な使い分けができます。
3. 構造体(Struct)中心の設計
Swift Testingの大きな特徴の一つが、テストを 構造体(Struct) や アクター(Actor) で定義できる点です。
これは単なる好みの問題ではなく、テストの安全性に大きく関係します。
状態の隔離と副作用の防止
クラスベースのXCTestでは、テストメソッド間でインスタンス変数が共有されるリスクを考慮する必要がありました。
通常はメソッドごとにインスタンス化されますが、設計によっては状態を持ち越すことが可能なためです。
構造体で定義されたテストメソッドは、メソッド実行ごとに確実に新しい値として扱われます。
また、イニシャライザとプロパティ定義がそのままセットアップ処理になります。
1struct DatabaseTests {
2 let db: Database
3
4 // XCTestの setUp() に相当
5 init() async throws {
6 self.db = Database()
7 await db.connect()
8 }
9
10 @Test func save() async throws {
11 // ...
12 }
13
14 // 構造体なので、スコープを抜ければ自動的に破棄される
15}クリーンアップ処理
終了処理(teardown)が必要な場合は、deinit(クラスの場合)を使用するか、Swift Testingならではのアプローチとして、テスト関数内で withKnownIssue や defer で終了処理を書くことができます。
しかし、基本的には構造体のライフサイクルに任せることで、メモリリークやリソースの解放忘れを防ぐ設計になっています。
4. パラメータ化テストの真価
Swift Testingで私が最も強力だと感じる機能が、この パラメータ化テスト(Parameterized Testing)です。
境界値分析やパターンテストを行う際、これまではループ処理やヘルパー関数を駆使していましたが、Swift Testingでは宣言的に記述することができます。
1@Test(arguments: [
2 (0, 0, 0),
3 (10, 5, 15),
4 (-1, -1, -2)
5])
6func addition(a: Int, b: Int, expected: Int) {
7 #expect(a + b == expected)
8}zip / product : 複雑な組み合わせでのテストパターン
引数が複数ある場合、それぞれの組み合わせをどうテストするかを指定できます。
zip(対になる組み合わせ)
配列Aと配列Bの同じインデックス同士を組み合わせます。
1@Test(arguments: zip([1, 2], ["A", "B"]))
2func testZip(number: Int, text: String) {
3 // (1, "A"), (2, "B") の2パターンが実行される
4}product(直積・総当たり)
配列Aと配列Bの全ての組み合わせをテストします。
1enum OsVersion: CaseIterable {
2 case v16
3 case v17
4}
5
6enum Device: CaseIterable {
7 case iPhone
8 case iPad
9}
10
11@Test(arguments: product(OsVersion.allCases, Device.allCases))
12func testCompatibility(os: OsVersion, device: Device) {
13 // (v16, iPhone), (v16, iPad), (v17, iPhone), (v17, iPad) の4パターン
14}この product は非常に強力で、マトリクステストをパターン漏れなく、それでいて簡潔に記述することができます。
仕様変更で行列が増えても、Enumを更新するだけでテストケースが自動的に生成されるため、安全性・堅牢性・保守性の全てにおいて強力な機能となります。
5. Traits : テストの整理と実行制御
数千規模の非常に多くのテストケースを抱えるプロジェクトでは、「どのテストを実行するか」の制御が重要になってくることがあります。
Swift Testingは Traits(特性) システムを採用しており、テストに対してメタデータを付与できます。
タグ(Tags)によるグルーピング
独自のタグを定義し、機能ごとや実行レベルごとに分類できます。
1extension Tag {
2 @Tag static var integration: Self
3 @Tag static var ui: Self
4 @Tag static var slow: Self
5}
6
7@Test(.tags(.integration, .slow))
8func databaseConnectionTest() { ... }CI環境(GitHub Actionsなど)では、このタグを利用してフィルタリングを行います。
例えば、「PR作成時は単体テストのみ実行し、マージ後の夜間バッチで .slow タグを含む全テストを実行する」といった運用が容易になります。
実行条件とバグトラッキング
Tags 以外にも、便利な Traits が用意されています。
.enabled(if:) / .disabled(if:)
特定のOSバージョンや条件でのみ実行します。
1@Test(.enabled(if: FeatureFlags.isNewUIEnabled))
2func newUITest() { ... }.timeLimit
実行時間に制限を設けることができます。
無限ループやデッドロックの検知に役立ちます。
1// 制限時間を「1分」に設定
2// これを超えるとテストは強制的に失敗(Fail)となり、次のテストへ進む
3@Test(.timeLimit(.minutes(1)))
4func heavyProcessingTest() async throws {
5 // 例えば、大量のデータを処理する関数など
6 await DataProcessor.processHugeDataset()
7}もしもっと厳密に秒単位で制御したい場合は、.seconds を使うことも可能です。
1// 「5秒以内」にレスポンスが返ってくることを保証したい場合
2@Test(.timeLimit(.seconds(5)))
3func apiResponseSpeedTest() async throws {
4 let response = try await APIClient.fetchData()
5 #expect(response.status == 200)
6}.bug
既知のバグと紐付けることで、テストが失敗しても「想定内」であることを記録する、などといった運用もできます。
1@Test(.bug("https://github.com/org/repo/issues/101"))
2func failingTest() { ... }6. Swift Concurrencyとの完全な統合
Swift Testingは、テスト関数自体を async にすることができ、非同期処理を自然にテストできます。
Actorのテストと分離
actor で実装されたロジックをテストする場合、データの競合を防ぐために await で呼び出す必要があります。
Swift Testingはこれをネイティブでサポートしており、テスト関数内でスムーズに待機処理を記述することができます。
1// テスト対象のActor
2actor ScoreManager {
3 private(set) var score = 0
4 func add(_ value: Int) { score += value }
5}
6
7// テスト関数に async をつけるだけ
8@Test func actorTest() async {
9 let manager = ScoreManager()
10
11 // Actorのメソッドを自然に await で呼び出せる
12 await manager.add(10)
13 await manager.add(20)
14
15 let currentScore = await manager.score
16 #expect(currentScore == 30)
17}直列実行の強制
デフォルトでは並列実行されますが、データベースへの書き込みなど、どうしても直列(シリアル)に実行したいケースもあります。
その場合は @Serialized を使用します。
1// この構造体内のテストは、1つずつ順番に実行される
2@Suite(.serialized)
3struct DatabaseSequenceTests {
4 @Test func step1_CreateUser() async throws {
5 // ユーザー作成処理...
6 }
7
8 @Test func step2_UpdateUser() async throws {
9 // step1が終わってから実行されることが保証される
10 // 更新処理...
11 }
12}7. エラーハンドリングのテスト
「エラーが発生すること」を検証するテストも、非常にシンプルに記述することができます。
1@Test func loginFailure() {
2 // 処理が特定のエラーを投げることを期待する
3 await #expect(throws: AuthError.invalidPassword) {
4 try await authService.login(password: "wrong")
5 }
6}エラーの型だけでなく、エラーの中身まで一致するかどうかを厳密にチェックすることも、逆に「とにかく何らかのエラーが出ればOK」という緩い検証も可能です。
8. XCTestからの移行戦略
最後に、既存プロジェクトにおける移行戦略について触れておきます。
「便利そうだけど、既存の資産をどうするか...」は最大の悩みどころかと思います。
私としては、結論「共存させながら、少しずつ移行する」ことをおすすめします。
Swift TestingとXCTestは、同じターゲット内で共存できるので、ビルドシステムは両方のテストを認識し、Xcodeのテスト結果画面でも統合されて表示されます。
- 新しいテストはSwift Testingで書く
- 既存のXCTestの置き換え
- 共通処理の切り出し
このようなステップで移行することで、リスクを最小限に抑えながらスムーズにSwift Testingへのリプレイスが行えると思います。
お疲れ様でした!
さいごに
いかがでしたでしょうか?
今回は XCTest から Swift Testing への変遷の歴史から、Swift Testing の基本的な書き方と強力な機能、そしてXCTestからの移行戦略までお話していきました。
Swift Testingは、単なる「XCTestの代替品」ではなく、Swift言語が目指す 「安全性(Safety)」「表現力(Expressiveness)」「並行性(Concurrency)」 という設計思想を、テストコードにも反映させた新しいテストフレームワークです。
「コードが短くなり、可読性が上がり、実行速度も上がる」
これにより開発者全体のテストに対するストレスを軽減でき、より本質的な機能開発や品質向上に注力させてくれる、大きな武器となります。
既存のプロジェクトで、XCTest やサードパーティ製のライブラリ(Quick / Nimbleなど)を利用している現場も多いと思います。
この記事がみなさまのSwift Testing導入の動機づけになったり、導入を検討している方のハードルが少しでも下げられたなら幸いです!
最後までお読みいただき、ありがとうございました!
それではまた!
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!カジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ

「クレヨンしんちゃんは人生のマニュアル」が口癖なモバイルアプリとバックエンドやってる人




