これは「Swift Testingについて」というWWDC24の動画を視聴した際に作成したメモです。
Swift Testing とは
Swift を使用してコードをテストするための新しいパッケージです。
自動テストは長期に渡り、ソフトウェアの品質を高め、維持するための実績のある方法です。
Swift TestingはSwift向けに開発され、並列処理やマクロなどの最新機能に対応しています。LinuxやWindowsなど、すべての主要プラットフォームをサポートしています。
構成要素
- テスト関数:Asnc/Await とアクター分離をサポートして、Swiftの並列処理とシームレスに連携する
- expectation, trait : Expectation でも async/await を使用でき、組み込みの言語演算子をすべて利用できる。またマクロを使用しているので、失敗時の詳細な結果を確認でき、テストごとの情報をコードで直接していできる
- Suites : Value Semantics を備えており、構造体を使用してステートを分離することが推奨されている
テスト関数
Swift Testing を使うには、ユニットテストのフレームワークを導入し、Swift Testing を追加します。
作成後、コード上で Testing モジュールを追加します。
import Testing
@Test
を追加して、「この関数はテスト関数です」であることを示します。
これはグローバル関数でも構いません。
async
または throws
としてマークでき、必要に応じてグローバルアクターに分離できます。
モジュールを import する場合は、@testable import XXX
を追加します。
これで、internal
などの関数をテストすることができます。
import Testing
@testable import DestinationVideo
@Test func videoMetadata() {
let video = Video(fileName: "By the Lake.mov")
let expectedMetadata = Metadata(duration: .seconds(90))
#expect(video.metadata == expectedMetadata)
}
expectation
#expect
マクロは expectation を実行します。#expect
マクロで expectation を実行することで、期待される条件が満たされているかを検証できます。これには通常の式と言語演算子が使えます。
テストが失敗した場合、失敗メッセージの [Show] を選択すると、この行で何が間違っていたのかを詳しく確認することができます。
expectation が失敗した時は、早期にテストを終了したい場合もあるでしょう。
その場合は、#require
マクロを使います。
必須の expectation は、通常の expectation と似ています。ただし、try キーワードがあり、式がfalse の場合はエラーがスローされ、テストが失敗となり、それ以上先には進まないようになっています。
他にも、#require
の使い道としては、オプショナルの値の安全なアンラップを施行し、nil の場合はテストを停止します。
let method = try #require(paymentmethod.first)
特性(Traits)
@Test
属性でカスタムの表示名を渡すことができます。この名前は、Test Navigator や Xcode の他の場所に表示されます。
- テストに関する説明情報を追加する
- テストが実行されるかどうかをカスタマイズする
- テストの動作を変更する
表示名による情報の追加に加え、関連するバグの参照やカスタムタグの追加もできます。特定の条件のみテストを実行したい場合は、Traits を使って制御できます。
一部の Traits はテストの実際の動作に影響を与えます。
スイート(Suites)
Suites を使って、関連するテスト関数や他の Suites をグループ化できます。
@Suite 属性を使用して、明示的に注釈をつけることもできるが、@Test
関数または @Suite
を含むすべての方は、暗黙的にそれ自体が @Suite
とみなされます。
また、スイートにはインスタンスプロパティを格納できます。init または deinit を使用して、各テストの前後にロジックを実行できます。
共通フロー
テストでよくある問題に適用し、対処するためのワークフローとして、以下の内容を見ていきます。
- テストを実行するタイミングの制御
- 共通点があるテストの関連づけ
- 毎回異なる引数でのテストの繰り返し
テストを実行するタイミングの制御
実行条件の制約
テストによっては、特定のデバイスや環境など、実行の条件に制約がある場合もあります。その場合は、.enabled(if: ...)
などの条件特性を適用できます。
テストを実行する前に評価する条件を渡し、条件が false の場合はテストをスキップとしてマークします。
@Test(.enabled(if: AppFeatures.isCommentingEnabled))
func videoCommenting() {
// ...
}
テストを実行しない
また、テストを実行しないようにしたい場合もあるでしょう。その場合は、.disabled(...)
を使います。
テスト関数のコメントアウトといった他の手法よりもテストの無効化を推奨します。
これは、テスト内のコードがまだコンパイルできることを確認できるからです。
.disabled(...)
ではコメントを受け付けるので、テストが無効化された理由を説明するのに使えます。
@Test(.disabled("Due to a known crash"))
func example() {
// ...
}
コメントは常に構造化された結果に表示されるため、CIシステムの可視性が確保できます。
バグの説明
多くの場合、テストを無効化する理由は、バグ追加システムで、追加されている問題にあります。コメントに加え、.bug(...)
特定を他の特性とともに含めて、URLで関連する問題を参照できます。
@Test(.disabled("Due to a known crash"),
.bug("example.org/bugs/1234", "Program crashes at <symbol>"))
func example() {
// ...
}
これにより、そのバグ特性を Xcode 16 のテストレポートで確認し、クリックして、そのURLにアクセスできます。
特定のバージョンOSでしか動作しない場合
特定のバージョンOSでしか動作しないテストの場合、@available(...)
属性をそのテストに配置して、どのバージョンで実行するかを制御できます。
@Test
@available(macOS 15, *)
func useNewAPIs() {
}
実行時に、バージョンを判定するのではなく、@available
属性を使います。
この属性により、テスト用ライブラリでテストに OS バージョンの条件があることが認識され、それを結果により正確に反映できるようになります。
// ❌ Avoid checking availability at runtime using #available
@Test func hasRuntimeVersionCheck() {
guard #available(macOS 15, *) else { return }
// ...
}
// ✅ Prefer @available attribute on test function
@Test
@available(macOS 15, *)
func usesNewAPIs() {
// ...
}
共通点があるテストの関連づけ
異なるスイートやファイル内のものも含め、関連づける方法について説明する。
Swift Testingではカスタムタグをテストに割り当てられます。
これにより、共通点があるテストにはタグ付けをして関連づけることができます。
例えば、特定の機能やサブシステムを検証するすべてのテストに共通のタグを適用できます。これにより、特定のタグを使用してテストを実行できます。
また、テストレポートでフィルタリングしたり、同じタグを持つ複数のテストが失敗し始めたときに情報を確認することもできます。
共通点を持つテストを関連付ける:
- 特定のタグを持つすべてのテストを実行する
- 特定のタグを持つテストによるフィルタリング
異なるファイル、Suits ターゲットのテストにタグを適用可能
複数のプロジェクトでのタグの共有も可能。
Swift Testing では、テストプランにテストを含めるか、除外するかを決めるときにテストの特定の名前ではなくタグを使用します。最良の結果を求めるには、それぞれの状況に最も適したタイプの Suites を必ず使用します。すべてのシナリオでタグを使うわけではありません。
例えば、実行時の条件を表現する場合は、.enabled(if:...)
を使用します。
テスト計画で including/excluding する場合、テスト名よりもタグを優先する
最も適切な特性タイプを使用する
Xcode でのテストタグの使用については、詳しくは Go further with Swift Testing を参照
毎回異なる引数でのテストの繰り返し
Swift Testingでは毎回異なる引数でのテストを効率よく書けるようになりました。
しかし、これの何がメリットなのでしょうか?
テスト対象が増えると多くの似たようなテストが増える傾向にあります。重複するコードも増えるため、保守が難しくなります。
また、このパターンでは各テストに一意の関数名をつける必要がありますが、これらの名前は分かりにくく、テスト対象と名前の一致がしなくなる場合があります。
struct VideoContinentsTests {
@Test func mentionsFor_A_Beach() async throws {
let videoLibrary = try await VideoLibrary()
let video = try #require(await videoLibrary.video(named: "A Beach"))
#expect(!video.mentionedContinents.isEmpty)
#expect(video.mentionedContinents.count <= 3)
}
@Test func mentionsFor_By_the_Lake() async throws {
let videoLibrary = try await VideoLibrary()
let video = try #require(await videoLibrary.video(named: "By the Lake"))
#expect(!video.mentionedContinents.isEmpty)
#expect(video.mentionedContinents.count <= 3)
}
@Test func mentionsFor_Camping_in_the_Woods() async throws {
let videoLibrary = try await VideoLibrary()
let video = try #require(await videoLibrary.video(named: "Camping in the Woods"))
#expect(!video.mentionedContinents.isEmpty)
#expect(video.mentionedContinents.count <= 3)
}
// ...and more, similar test functions
}
この代わりに、パラメータ化という機能を使用すれば、すべてのテストを1つのテストとして記述できます。
まず、パラメータをシグネチャに追加します。これを追加するとすぐにエラーとなり、このテストに渡す引数を指定する必要があると表示されます。
struct VideoContinentsTests {
- @Test func mentionsFor_A_Beach() async throws {
+ @Test("Number of mentioned continents", arguments: [
+ "A Beach",
+ "By the Lake",
+ "Camping in the Woods",
+ ])
+ func mentionedContinentCounts(videoName: String) async throws {
let videoLibrary = try await VideoLibrary()
- let video = try #require(await videoLibrary.video(named: "A Beach"))
+ let video = try #require(await videoLibrary.video(named: videoName))
#expect(!video.mentionedContinents.isEmpty)
#expect(video.mentionedContinents.count <= 3)
}
成功すると Test navigator で、それぞれの個別テストのように下に表示されます。
この構造により、とても簡単に引数の追加やテスト範囲の拡大ができます。
Xcode16では特定の引数のみを実行することもできます。Test Navigator で実行ボタンをクリックするのみです。
このテスト手法は以下のように、for ループを回しているのと同じような処理をしています。
// Using a for…in loop to repeat a test (not recommended)
@Test func mentionedContinentCounts() async throws {
let videoNames = [
"A Beach",
"By the Lake",
"Camping in the Woods",
]
let videoLibrary = try await VideoLibrary()
for videoName in videoNames {
let video = try #require(await videoLibrary.video(named: videoName))
#expect(!video.mentionedContinents.isEmpty)
#expect(video.mentionedContinents.count <= 3)
}
}
しかし、この for ループを使ったテストよりも、パラメータ化テストの方が以下のようなメリットがあります:
- 結果の各引数の詳細を表示:パラメータ化テストをすれば、各引数の詳細を明確に確認できる
- デバッグのために個々の引数を再実行する:引数を個別に再実行できるため、きめ細かなデバッグが可能
- 引数の並列実行:各ひきすうを並列処理してより効率的に実行できるため、素早く結果を得ることができる
この他にも、2組の入力のすべての組み合わせテストができます。
Xcode でのテストタグの使用については、詳しくは Go further with Swift Testing を参照
Swift Testing and XCTest
Swift Testig には XCTest と似ている点もあるが、注意すべき重要な違いもいくつかある。
テスト関数
XCTest のテストは名前が test
から始まるメソッドです。一方、Swift Testing ではテストを@Test
属性で指定するため、曖昧さが生まれません。
Swift Testing ではさまざまな関数がサポートされており、インスタンスメソッドを型であ使えるのはもちろん、静的関数やグローバル関数も必要に応じて使うことができます。
XCTest とは異なり、Swift Testing では traits を使えるので、テストごとまとまった Suits ごとに情報を指定できます。
また、Swift Testing では、別アプローチで並列化を行います。Swift並列処理を使用してインプロセスで動作し、iPhoneやApple Watchなどの物理デバイスをサポートします。
XCTest | Swift Testing | |
---|---|---|
Discovery | "test" から始める必要がある | @Test |
型サポート | インスタンスメソッド | インスタンスメソッド、 Static / Class メソッド、グローバル関数 |
Traits をサポート | No | Yes |
並列実行(Parallel execution) | マルチプロセスmacOSおよびシミュレータのみ | In-process Supports devices |
Expectations
expectation は2つのシステム間で大きく異なります。
XCTestではこの概念はアサーションと呼ばれ、XCTestAssert で始まる多くの関数を使ってその機能を表します。
SwiftTestingのアプローチは別であり、2つの基本的なマクロである #expect
と #require
のみを使用します。
多くの関数を使う代わりに、通常の式と言語演算子を #expect
と #require
に渡すことができます。
テストの失敗後にテストを停止する時も処理方法が異なります。
XCTest では、continueAfterFailure に false を指定し、それに続くアサーションが失敗するとテストが停止します。
func testExample() {
self.continueAfterFailure = false
XCTAssertEqual(x, y)
XCTAssertTrue(z.isEnabled)
}
Swift Testing では、#require
を使用して、必須のexpectation にすることができ、失敗するとエラーがスローされます。
@Test func example() throws {
try #require(x == y)
#expect(z.isEnabled)
}
これにより、どの expectation でテストを停止するかを選択でき、テストの進行に合わせて切り替えることもできます。
Suites
Suite の型については、XCTest がサポートするのはクラスだけで、XCTestCaseから継承する必要がある。
Swift Testing では struct , actor, class を使用できる。
推奨方法としては struct で、ValueSemanticsを使用して意図しないステートの共有によるバグを回避できる。
Suiteは @Suite
属性で明示的に示すこともできるが、テスト関数やネストされた Suite を含むすべての型に対して暗黙的に設定される。この属性が必要となるのは、表示名や他の特性を指定するときだけです。
各テストの実行前にロジックを実行する場合、XCTestでは setUp メソッドなどを使うが、Swift Testingでは型のイニシャライズを使い、async または throws にできる。
同様に、各テストの後にロジックを実行する場合、デイニシャライズを指定できます。デイニシャライズを使えるのは Suite 型がアクターかクラスの時だけで、それが Suite に構造体ではなく参照型を使う最も一般的な理由です。
最後に、Swift Testing では、ネストされた型によってテストのサブグループにグループ化できます。
XCTest | Swift Testing | |
---|---|---|
Types | class | struct, actor, class |
Discovery | Subclass XCTestCase | @Suite |
Before each test | setUp(), setUpWithError() throws, setUp() asynchronous throws | init() asynchronous throws |
After each test | tearDown() async throws, tearDownWithError() throws , tearDown | deinit |
Sub-groups | Unsuppoted | type のネスト |
1つのユニットテストターゲットを共有する
- Swift Testing のテストは XCTest と共存できる
- そのため、移行する際は段階的に進めることができる
似たような XCTest をパラメータ化されたテストにまとめる
1つのテストメソッドだけを持つ各 XCTest クラスをグローバルな @Test
関数に移行する
メソッド名から冗長な 「test」 接頭辞を取り除く
以下のようなテストの場合は、引き続き XCTest を使用してください。
- XCUIApplication などのUI自動化API
- XCTMetricなどの性能テストAPIを使ったテスト
- Objective-Cのみで書かれた場合
また、Swift Testing から XCTest アサーション関数を呼び出すことや、XCTest から #expect
マクロを呼び出すのは避けるべき
Open Source
- コミュニティ機能提案プロセス
- Swift Forms で議論する
- Contributions Welcome