XCTestの各テストケースを単独でもテストスイート全体でも成功させるには、各テストに独自の変更可能なフィクスチャを用意し、テスト対象のコードに注入します。シングルトンを共有したままにする必要がある場合は、各テストの前にリセットし、テストの実行期間全体を通じてアクセスが重複しないようにします。
このガイドでは、Macでの開発タスクを一つ取り上げます。Xcodeで単独では成功するものの、一緒に実行すると失敗するレイアウト計算テストを修正します。例では、合成した整数の幅とローカルな算術演算のみを使用します。アプリのウインドウ、サービスのモック、ファイルストレージ、ネットワーク接続は必要ありません。
テストはなぜ単独では成功し、テストスイートでは失敗するのでしょうか?
テストを単独で実行すると、シングルトンの初期状態に遭遇することがあります。テストスイートでは、先行するテストがすでに状態を変更している可能性があります。シングルトンへの新しい参照を作成しても、同じ変更可能なオブジェクトを指します。
Swiftではインスタンスプロパティと型プロパティが区別されます。各インスタンスは独自のインスタンスプロパティを持ち、型プロパティは共有のコピーを一つ持ちます。保存型プロパティは最初にアクセスしたときに一度だけ初期化されます。そのため、SpacingCalculator.sharedをテストプロパティに代入しても、新しい計算機は作成されません。SwiftプロジェクトのPropertiesドキュメントを参照してください。
幅が[10, 20, 30]のアイテムを含む、合成した行を考えてみましょう。全幅は、アイテムの幅の合計にアイテム間の隙間を加えた値です。デフォルトの隙間が2の場合、結果は64です。隙間が8の場合は、76です。
あるテストは計算機を設定せずにデフォルト幅を検証し、別のテストは共有の隙間を8に変更するとします。次の表は、各行の開始時に隙間が2の新しい計算機を用意した場合の逐次実行を示しています。これは説明のための計算であり、記録されたXcodeのテスト結果ではありません。
| 実行 | リセットなしの共有計算機 | テストごとに新しい計算機 |
|---|---|---|
| デフォルトテストのみ | 64:成功 |
64:成功 |
| 広い隙間のテストのみ | 76:成功 |
76:成功 |
| デフォルト、次に広い隙間 | どちらも成功 | どちらも成功 |
| 広い隙間、次にデフォルト | デフォルトが76を受け取り、失敗 |
どちらも成功 |
ここから役立つ診断方法が得られます。失敗したアサーションが使う状態をどのテストが変更しているかを調べましょう。都合のよい実行順になるようにテスト名を変えても、依存関係は残ります。
フィクスチャにはどのXCTestライフサイクルメソッドを使うべきでしょうか?
すべてのテストで必要な変更可能な状態には、インスタンスのセットアップメソッドを使います。Appleのドキュメントによると、インスタンスのセットアップは各テストメソッドの前に行われ、クラスレベルのセットアップはテストクラスの前に一度だけ行われます。そのため、クラスレベルのセットアップで作成した変更可能なフィクスチャは、メソッドごとに新しい初期状態になりません。テストの状態のセットアップと解放を参照してください。
作業に合ったセットアップメソッドを選びます。同期的でエラーを投げないフィクスチャの構築にはsetUp()、エラーを投げる構築にはsetUpWithError()、非同期の準備にはsetUp() async throwsが適しています。現在のテストの後片付けにはティアダウンを使います。Appleは、クラッシュによってティアダウンが実行されない可能性があるため、後続テストの準備にティアダウンを依存させないよう明示的に助言しています。同じライフサイクルのドキュメントで、これらの境界が説明されています。
フィクスチャの選択は、所有者と実行範囲の両方によって決まります。
| 状況 | 適切な方法 | 確認する条件 |
|---|---|---|
| テストから依存関係を構築できる | テストごとにインスタンスを作成して注入する | 利用側が注入されたインスタンスを使うこと |
| テストで実際のシングルトンを検証する必要がある | 各テストの前に完全な初期状態を確立する | シングルトンを変更するテストが同時に実行されないこと |
| 共有設定が不変である | 不変の値を再利用する | 参照先のオブジェクトも変更されないこと |
| テストが非同期処理を開始する | テストを終了する前に完了を待つ | その後フィクスチャを変更する未完了の処理がないこと |
この計算機では、最初の方法が最も簡単です。本番コードでは共有のデフォルトを維持しつつ、テストでは独立したインスタンスを明示的に選べます。
独立して所有されるXCTestフィクスチャを作る
Xcodeと既存のmacOSユニットテストターゲットがあるMacを使います。次の完全な例を一つのSwiftファイルとして、そのテストターゲットに追加します。例では計算機、利用側、テストを独自に定義しているため、アプリケーションモジュールのインポートは不要です。ターゲットのデフォルトのアクター分離をnonisolatedに設定したSwift 6ツールチェーンを使います。
計算機はアクターとして定義されており、その変更可能な状態の並行処理境界が明示されています。Swiftのアクターは変更可能な状態へのアクセスを制限し、アクターの外部から呼び出すときはawaitを使います。これらの規則はSwiftプロジェクトのConcurrencyの章で説明されています。アクターを使う場合も、フィクスチャを独立して所有する必要があります。
import XCTest
actor SpacingCalculator {
static let shared = SpacingCalculator()
private var gap = 2
func setGap(_ value: Int) {
precondition(value >= 0)
gap = value
}
func width(of itemWidths: [Int]) -> Int {
precondition(itemWidths.allSatisfy { $0 >= 0 })
let gapCount = max(itemWidths.count - 1, 0)
return itemWidths.reduce(0, +) + gapCount * gap
}
}
struct RowLayout {
let calculator: SpacingCalculator
init(calculator: SpacingCalculator = .shared) {
self.calculator = calculator
}
func width(of itemWidths: [Int]) async -> Int {
await calculator.width(of: itemWidths)
}
}
final class RowLayoutTests: XCTestCase {
private var calculator: SpacingCalculator?
override func setUp() {
super.setUp()
calculator = SpacingCalculator()
}
override func tearDown() {
calculator = nil
super.tearDown()
}
func testDefaultGap() async throws {
let fixture = try XCTUnwrap(calculator)
let layout = RowLayout(calculator: fixture)
let actual = await layout.width(of: [10, 20, 30])
XCTAssertEqual(actual, 64)
}
func testWideGap() async throws {
let fixture = try XCTUnwrap(calculator)
await fixture.setGap(8)
let layout = RowLayout(calculator: fixture)
let actual = await layout.width(of: [10, 20, 30])
XCTAssertEqual(actual, 76)
}
func testEmptyRow() async throws {
let fixture = try XCTUnwrap(calculator)
let layout = RowLayout(calculator: fixture)
let actual = await layout.width(of: [])
XCTAssertEqual(actual, 0)
}
func testSingleItem() async throws {
let fixture = try XCTUnwrap(calculator)
let layout = RowLayout(calculator: fixture)
let actual = await layout.width(of: [10])
XCTAssertEqual(actual, 10)
}
}
決め手となるのはcalculator = SpacingCalculator()の行です。各セットアップで、隙間が2の独立したアクターを作成します。各テストはそのアクターをRowLayoutに渡します。これらのテスト内でRowLayout()を呼ぶと、.sharedが選択されてフィクスチャを迂回します。
この例では意図的に直接構築できるようにしています。既存のシングルトンのイニシャライザーが非公開の場合は、内部から構築する方法を導入するか、変更可能な計算状態を個別に構築できる依存関係として切り出します。アプリケーションモジュールで既存のtestable importの仕組みを利用すれば、内部イニシャライザーをテストターゲットに公開できます。シングルトン参照をグローバルに差し替え可能にする必要はありません。
この算術では、空の行の幅は0となり、アイテムが一つだけなら間に隙間はありません。この例で受け付けるのは、整数オーバーフローを起こさない範囲の非負整数です。負の値は事前条件に違反するため、アサーション失敗を返すのではなく実行を終了します。
期待される算術結果と、二つのテストの両方の実行順は、決定論的なPython計算でローカルに確認しました。Swiftの例は確認しましたが、この調査環境にはSwiftツールチェーンがないため、コンパイルも実行もしていません。ターゲットでコンパイルと実行を確認するには、Xcodeで実行してください。
シングルトンをリセットすれば十分なのはどんな場合でしょうか?
テストで共有インスタンスを実際に使う必要があり、テストから見えるすべての状態を復元できる場合、リセットは移行期間中の方法として有効です。各テストの前に初期状態を確立し、テストがデフォルトと想定している値も含めます。現在失敗しているテストが変更したプロパティだけをリセットすると、ほかの依存関係を見落とす可能性があります。
この計算機の場合、非同期セットアップでSpacingCalculator.sharedを選び、各メソッドの前にsetGap(2)を待機できます。アサーションの前に重複する呼び出し元が隙間を変更しない場合に限り、これで算術上の初期状態が確立されます。フィクスチャにキャッシュ済みの結果や追加設定が加わった場合は、それらの値も初期状態の処理に含める必要があります。
アクターを使っても、複数回のawaitをまたいで一つのテストが所有権を持つわけではありません。たとえば、テストAが共有の隙間を2に設定し、テストBがそれを8に設定した後、テストAが8を使って幅を計算することがあります。各アクター操作が正しく実行されても、テスト全体の前提は崩れる可能性があります。これはSwiftで文書化されているアクターの実行と中断の規則に基づきます。
リセットによる分離を選ぶ前に、共有オブジェクトにアクセスできるすべてのテストとバックグラウンドタスクを特定してください。準備、実行、後片付けの期間全体を通じて排他的な使用を保証できない場合は、独立した状態を注入します。後でスナップショットを復元しても、テスト中の一時的な変更を別の呼び出し元が観察するのを防ぐことはできません。
フィクスチャをSwift Testingに適用するには?
Swift Testingでは、インスタンスのテスト関数ごとに個別のスイートインスタンスが初期化されます。そのため、計算機を構築するインスタンスプロパティを使えば、XCTestのセットアップオーバーライドなしで同じ所有パターンを実現できます。.sharedから初期化したプロパティは、引き続きシングルトンを参照します。Appleはスイート型を使ったテスト関数の整理でインスタンスのライフサイクルを説明しています。
先ほどのSpacingCalculatorとRowLayoutの定義が同じテストターゲット内に一度だけ存在する状態で、次のコードはSwift Testingの追加スイートとして完全に動作します。Xcode 16以降など、Testingモジュールを含むツールチェーンが必要です。デフォルトのアクター分離もnonisolatedにする必要があります。
import Testing
struct RowLayoutSwiftTests {
let calculator = SpacingCalculator()
@Test func defaultGap() async {
let layout = RowLayout(calculator: calculator)
let actual = await layout.width(of: [10, 20, 30])
#expect(actual == 64)
}
@Test func wideGap() async {
await calculator.setGap(8)
let layout = RowLayout(calculator: calculator)
let actual = await layout.width(of: [10, 20, 30])
#expect(actual == 76)
}
}
より複雑な準備が必要な場合、Swift Testingは非同期またはエラーを投げるものを含むスイートイニシャライザーをサポートします。AppleのテストをXCTestから移行するでは、XCTestのセットアップメソッドに代わるこの方法が説明されています。このフィクスチャはローカルな計算状態だけを所有し、バックグラウンド処理も開始しないため、明示的なクリーンアップは不要です。
Swift Testingでは、テストはデフォルトで並列実行されます。スイートに.serializedを適用すると、その中のテストとネストされたスイートは順次実行されますが、無関係なテストが並行して実行されることまでは防げません。このtraitを通常のパラメーター化されていないテスト関数に適用しても効果はありません。これらの制約はテストの逐次実行または並列実行で明示されています。
したがって、シングルトンに依存する二つのスイートに別々の.serializedを付けても、相互排他は保証されません。競合する利用者がすべて共通のシリアル化された親の配下に属していれば、その親で子孫を調整できます。これらの計算テストでは、新しいフィクスチャを使うことで調整が不要になります。
Xcodeで個別テストとスイートの結果を確認する
確認作業を通じて、同じスキーム、テストプラン、Macの実行先を使います。Appleのテストの実行と結果の解釈では、個々のメソッド、スイート、アクティブなテストプランの実行方法が説明されています。
- エディターのガターにある再生コントロールを使って、XCTestの4つのメソッドをそれぞれ個別に実行します。順に
64、76、0、10となることを確認します。 - 宣言の横にある再生コントロールを使って、
RowLayoutTestsクラスを実行します。4つのメソッドすべてが一緒に成功するはずです。 Product > Testを選び、アクティブなテストプランを実行します。これらのテストがスキップまたは除外されず、実際に実行されたことを確認します。- Swift Testingの例も追加した場合は、その二つの関数も個別に、そして一緒に実行します。
個々のテストは成功するのにテストプラン全体が失敗する場合は、失敗した利用側が使っているオブジェクトを追跡します。注入の漏れ、.sharedを呼び出すヘルパー、またはテストの開始元が終了した後も続く非同期処理がないか調べましょう。テストが単独で失敗する場合は、テストスイートの順序が原因だと判断する前に、入力値と期待される計算結果を確認します。
完成したフィクスチャでは、現在のテストの構築経路から初期状態を確認できます。これらのレイアウト計算では、各テストが計算機を所有し、それを利用側に明示的に渡して、その処理を待機します。これにより、隣接するどのテストが先に実行されたかに関係なく、期待される結果が得られます。
参考資料
- テストの状態のセットアップと解放 — Apple
- Properties — The Swift Programming Language、Swiftプロジェクト
- Concurrency — The Swift Programming Language、Swiftプロジェクト
- スイート型を使ったテスト関数の整理 — Apple
- テストをXCTestから移行する — Apple
- テストの逐次実行または並列実行 — Apple
- テストの実行と結果の解釈 — Apple