Swift Digest
ST-0012 | Swift Evolution

exit test での値のキャプチャ

Capturing values in exit tests

Proposal
ST-0012
Authors
Jonathan Grynspan
Review Manager
Paul LeMarquand
Status
Implemented (Swift 6.3)

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

01 何が問題だったのか

Swift 6.2 で導入された exit test では、#expect(processExitsWith:) / #require(processExitsWith:) に渡したクロージャを子プロセスで実行し、そのプロセスの終了状態を親プロセス側で検査できるようになりました。これにより precondition()fatalError() のような プロセスを終了させる コードの挙動を、テストプロセス自体を巻き込まずに検証できます。

しかし、ST-0008 の時点では exit test のクロージャが囲んでいるレキシカルコンテキストから値をキャプチャできないという制約がありました。これは特にパラメータ化テストとの組み合わせで不便で、たとえば次のように @Test(arguments:) で渡された引数を exit test の本体から参照することができません。

@Test(arguments: [Fruit.olive, .tomato])
func `Fruit bats don't eat savory fruits`(_ fruit: Fruit) async {
  await #expect(processExitsWith: .failure) {
    let bat = FruitBat(named: "Chauncey")
    fruit.feed(to: bat) // 🛑 can't capture 'fruit' from enclosing scope
  }
}

引数ごとに本体だけ違うテスト関数を複製すれば回避できますが、件数が増えると現実的ではなく、Swift Testing で推奨される書き方からも外れてしまいます。exit test を実用的にパラメータ化テストや周囲のローカル状態と組み合わせるには、親プロセスの値を子プロセスに引き渡す手段が必要でした。

02 どのように解決されるのか

exit test の本体クロージャに クロージャのキャプチャリスト を書けるようにし、そこに列挙された値だけを親プロセスから子プロセスへ転送できるようにします。#expect(processExitsWith:) / #require(processExitsWith:) のシグネチャ自体は変わりません。

@Test(arguments: [Fruit.olive, .tomato])
func `Fruit bats don't eat savory fruits`(_ fruit: Fruit) async {
  await #expect(processExitsWith: .failure) { [fruit] in
    let bat = FruitBat(named: "Chauncey")
    fruit.feed(to: bat)
  }
}

これまで暗黙のキャプチャに対して出ていたコンパイルエラー(C 関数ポインタはコンテキストをキャプチャするクロージャから生成できない、というメッセージ)は引き続き発生し、明示的にキャプチャリストに書いた値だけが子プロセスに渡されます。

キャプチャできる値の条件

キャプチャリストに記述した値は、親プロセスでエンコードされ、子プロセスに送られて再構築されます。そのため、キャプチャ対象の型は SendableCodable の両方に適合している必要があります。CopyableEscapable への適合は前提として要求されます。

これらに適合しない値をキャプチャリストに書くと、コンパイル時に診断が出ます。

let bat: FruitBat = ...
await #expect(processExitsWith: .failure) { [bat] in
  // 🛑 Type of captured value 'bat' must conform to 'Sendable' and 'Codable'
  ...
}

キャプチャされた値の型は exit test マクロから見えている必要がある

子プロセスでキャプチャ値をデコードするには、その Swift の型が分かっている必要があります。マクロ展開は構文木のみを頼りに行われるため、型情報は構文から復元できる場合に限り暗黙に推論されます。

具体的には次の場合は型が自動的に分かります。

  • self
  • 呼び出し元の関数の引数
  • リテラル([x = 123] のような場合は IntegerLiteralType などとして扱われます)

それ以外の値(ローカル変数やグローバル変数など)はキャプチャリスト中で as を使って型を明示する必要があります。

await #expect(processExitsWith: .failure) { [fruit = fruit as Fruit] in
  ...
}

型を文脈から決められない場合は、Fix-It 付きで次のような診断が出ます。

await #expect(processExitsWith: .failure) { [fruit] in
  // 🛑 Type of captured value 'fruit' is ambiguous
  //     Fix-It: Add '= fruit as T'
  ...
}

なお、ローカル変数が self や関数引数と同名のとき(シャドーイングしている場合)は型推論を誤る可能性があり、その場合も同じ “Type of captured value … is ambiguous” 診断が出ます。

ツール連携

Xcode、Swift Package Manager、VS Code の Swift プラグインなど、Swift Testing 内蔵の exit test 実装に乗っているツールは、追加対応なしでキャプチャ値を扱えます。

独自に exit test の実行を担っているツール向けには、ExitTest 型に SPI として capturedValues プロパティが追加されました。

extension ExitTest {
  @_spi(ForToolsIntegrationOnly)
  public var capturedValues: [CapturedValue] { get set }
}

親プロセス側(Configuration.exitTestHandler に渡される ExitTest のインスタンス)では、このプロパティに実行時に収集されたキャプチャ値が入ります。子プロセス側(ExitTest.find(identifiedBy:) から返されるインスタンス)では、配列の各要素は最初は値を持たず、ホスト側ツールから値を埋めてもらう前提になっています。配列の順序は親と子で一致している必要があります。

03 今後の見通し

将来の構想として、いくつかの拡張が挙げられています。いずれも実現を約束するものではありません。

型情報の明示なしでのキャプチャ

現状はマクロが構文だけを頼りにコードを生成するため、ローカル変数などをキャプチャするときには as で型を書く必要があります。将来 Swift コンパイラに、C++ の decltype() や C23 の typeof() に相当する言語機能が入れば、それを利用してキャプチャリストでの型注釈を不要にできる可能性があります。Swift Testing チームとしては、この緩和は別の Swift Evolution 提案を立てずに行えると見込んでいます。

暗黙キャプチャに対する診断の改善

現状、本体クロージャが暗黙に値をキャプチャすると、コンパイラからは「C 関数ポインタはコンテキストをキャプチャするクロージャから生成できない」というやや分かりにくいメッセージが出ます。将来的には、本体クロージャに「明示的なキャプチャリストを要求する」ことを示す属性やキーワードのような目印を付けられるようにし、より明確な診断を出せるようにすることが構想されています。

Codable 以外のシリアライズ手段への対応

キャプチャ値は現状 Codable 経由で子プロセスに送る前提ですが、NSSecureCoding や、議論中の JSONCodable のような別のシリアライズプロトコルを介して値を渡せるように拡張する余地も挙げられています。