テストにスコープを与える trait
Test Scoping Traits
このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら↗。
01 何が問題だったのか
Swift Testing の trait システムには、テストやスイートに共通する振る舞いを切り出すための仕組みとして Trait プロトコルがあります。スイート全体に対する共通のセットアップやティアダウンであれば、suite 型の init や deinit で表現できますが、スイート内の 一部のテスト や、スイート階層の異なる位置にあるテストにまたがって共通の振る舞いを与えたい場合は、それらを trait に閉じ込めるのが自然な選択肢になります。trait はテストやスイートに対して個別に付与できるためです。
しかし、これまでの Trait プロトコルには、適用先のテストやスイートの実行そのものをカスタマイズする手段がありませんでした。具体的には、テスト本体の前後で任意のコードを実行する、あるいはテスト本体をクロージャで包んでスコープを与える、といったことができません。たとえば次のように、モックの API 認証情報をテスト中だけ差し替える trait を考えると、
@Test(.mockAPICredentials)
func example() {
// ...APICredentials.current を参照しながら API の使い方を検証する...
}
struct MockAPICredentialsTrait: TestTrait { ... }
extension Trait where Self == MockAPICredentialsTrait {
static var mockAPICredentials: Self { ... }
}
この MockAPICredentialsTrait がテスト実行中に認証情報を差し替えるためのフックを、これまでの Trait は提供していませんでした。
XCTest のように setUp() / tearDown() という対のメソッドを Trait に追加することも考えられますが、その場合は値をグローバルなミュータブルな状態に書き込む形になりがちで、Swift Testing が掲げるテストの並列実行と相性が悪くなります。Swift 6 では nonisolated な static 変数の使用は基本的にエラーとなり、@TaskLocal などスコープ付きの API を使う必要がありますが、@TaskLocal の値を束縛する TaskLocal.withValue(_:operation:) はクロージャを受け取る形のため、setUp() と tearDown() のように分離された API では扱えません。
そのため、trait がテストの実行をカスタマイズするには、テスト本体をクロージャとして受け取り、その前後で任意の処理を行えるスコープ付きの API が必要でした。一方で、すべての trait に対して無条件にスコープ付きの API を呼び出すと、トレイトを多数適用したテストではコールスタックが trait の数だけ深くなり、デバッグ時のバックトレースが冗長になったり、極端なケースではスタックオーバーフローを引き起こしたりするおそれもあります。実際にスコープを必要とする trait だけを区別できる仕組みも求められていました。
02 どのように解決されるのか
新たに TestScoping プロトコルを導入し、これに適合する型がテストやスイートに対して スコープを与える 形で実行をカスタマイズできるようにします。あわせて Trait プロトコルに、「自分自身に対する TestScoping プロバイダ(あれば)を返す」メソッドを追加します。プロバイダが nil のときはスコープ付きの呼び出し自体が省略されるため、すべての trait のスコープ付き API を毎回呼び出すことによるバックトレースの肥大化を防げます。
TestScoping プロトコル
TestScoping は単一の provideScope(for:testCase:performing:) メソッドを要求します。function クロージャを呼び出す前後で任意の処理を行うことで、テストやスイートにスコープを与えます。
public protocol TestScoping: Sendable {
func provideScope(
for test: Test,
testCase: Test.Case?,
performing function: @Sendable () async throws -> Void
) async throws
}
function の意味は対象が何かによって変わります。test がテスト関数のときは function がそのテスト本体(パラメータ化テストの場合は全ケースの実行)に相当し、test がスイートのときはそのスイート配下のすべてのテストの実行に相当します。provideScope(...) の中では function をちょうど一度だけ呼び出すか、スコープを与えられない場合はエラーを throw します。function が呼ばれる前にエラーが throw されたときは、対応するテストは実行されません。
Trait プロトコルへの追加
Trait プロトコルには、TestScoping に適合する関連型 TestScopeProvider と、そのオプショナル値を返す scopeProvider(for:testCase:) メソッドが追加されます。TestScopeProvider のデフォルトは Never で、その場合 scopeProvider(for:testCase:) は常に nil を返します。
public protocol Trait: Sendable {
// ...
associatedtype TestScopeProvider: TestScoping = Never
func scopeProvider(
for test: Test,
testCase: Test.Case?
) -> TestScopeProvider?
}
extension Never: TestScoping {}
scopeProvider(for:testCase:) が nil を返した trait に対しては、テスティングライブラリは provideScope(...) を呼び出しません。スコープを与える必要のある trait だけが処理に組み込まれるため、不要なネストが増えることを避けられます。
Trait 自身が TestScoping に適合するケース
実際の trait 型は、自分自身を TestScoping にも適合させ、scopeProvider(for:testCase:) から self を返すのが典型的なパターンです。この共通ケースのために、条件付きのデフォルト実装が用意されています。
extension Trait where Self: TestScoping {
// testCase が nil なら nil、そうでなければ self を返す。
public func scopeProvider(for test: Test, testCase: Test.Case?) -> Self?
}
extension SuiteTrait where Self: TestScoping {
// test がスイートの場合は isRecursive が true なら nil、そうでなければ self。
// test がテスト関数の場合は testCase が nil なら nil、そうでなければ self。
public func scopeProvider(for test: Test, testCase: Test.Case?) -> Self?
}
これらのデフォルト実装により、テスト関数に直接付与された trait は テストケースごとに一度 スコープを与え、再帰的に継承されるスイート trait は 配下の各テスト関数に対して一度ずつ 、再帰的に継承されないスイート trait は そのスイート全体に対して一度だけ スコープを与えるという、自然な振る舞いになります。これは init / deinit の挙動とも揃っています。
trait 側で scopeProvider(for:testCase:) を明示的に実装すれば、これらのデフォルト挙動をさらに調整できます。たとえばパラメータ化テスト全体で一度だけ実行したい処理を行うために、testCase が nil のときだけ self を返すようにする、といったカスタマイズも可能です。
適用例
冒頭の MockAPICredentialsTrait は、認証情報を @TaskLocal で保持し、provideScope(...) の中で withValue(_:operation:) を使ってスコープ付きで束縛することで、テストの並列実行を妨げずに値を差し替えられます。
extension APICredentials {
@TaskLocal static var current: Self?
}
struct MockAPICredentialsTrait: TestTrait, TestScoping {
func provideScope(
for test: Test,
testCase: Test.Case?,
performing function: @Sendable () async throws -> Void
) async throws {
let mockCredentials = APICredentials(apiKey: "...")
try await APICredentials.$current.withValue(mockCredentials) {
try await function()
}
}
}
extension Trait where Self == MockAPICredentialsTrait {
static var mockAPICredentials: Self {
Self()
}
}
@Test(.mockAPICredentials)
func example() {
// ...APICredentials.current を参照しながら API の使い方を検証する...
}
複数の trait の適用順
テストに複数の trait が付与されているとき、テスティングライブラリは含むスイートから継承された trait を外側から内側の順に並べ、テスト関数に直接付与された trait は左から右の順に追加し、それぞれの scopeProvider(for:testCase:) を呼び出して nil でないものだけを順にネストして実行します。たとえば traitA、traitB、traitC がいずれもスコープを与える場合、最終的な実行はおおむね次のような入れ子になります。
traitA.provideScope {
traitB.provideScope {
traitC.provideScope {
exampleTest()
}
}
}
スコープを与えない trait はこのネストに参加しないため、トレイトの数に比例してバックトレースが深くなることはありません。
03 今後の見通し
将来の構想として、いくつかの拡張が挙げられています。いずれも実現を約束するものではありません。
スイート型のインスタンスへのアクセス
現在の Swift Testing では、suite 型のインスタンスメンバー(init、deinit、テスト関数自身)だけが self を通してインスタンスにアクセスできます。これに対して、インスタンスメソッドとして書かれた @Test に付与された trait からも、そのテストが動いているインスタンスを参照したり書き換えたりできるようにしたい、という要望があります。実装上の課題が大きく今回のスコープ外とされていますが、将来検討される可能性があります。
Task local の値を設定するための便利 trait
提案中の例で示されているように、テスト実行中に @TaskLocal の値を差し替えるパターンは TestScoping の代表的な用途になると見られます。これを毎回手書きするのを避けるため、@TaskLocal の束縛を行う組み込みの trait 型を追加することが構想されています。プロトタイプは試作されており、本提案で導入される機能が実装されたあとに別途追加される見込みです。