Swift Digest
ST-0008 | Swift Evolution

exit test

Exit tests

Proposal
ST-0008
Authors
Jonathan Grynspan
Review Manager
Maarten Engels
Status
Implemented (Swift 6.2)

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

01 何が問題だったのか

Swift Testing でテストを書く際、precondition()fatalError() のように プロセスを終了させる コードの挙動を検証したい場面があります。たとえば次のような関数では、

func eat(_ taco: consuming Taco) {
  precondition(taco.isDelicious, "Tasty tacos only!")
  // ...
}

isDeliciousfalse のタコスを渡したときに precondition 違反でプロセスがクラッシュすることが正しい挙動ですが、テスト関数からそのまま eat(_:) を呼ぶと、テストプロセス自体が落ちてしまうため、これまでの Swift Testing ではこの種のクラッシュ系の挙動を検証する手段がありませんでした。これは XCTest でも長年要望が寄せられてきた機能であり、Swift Testing にも初期からエンハンスメント要望として挙がっていました。

この種のテストは文献によって「exit test」「death test」「death assertion」「termination test」など複数の呼び方がありますが、本提案では一貫して「exit test」という呼称が使われます。

一般に exit test は、対象のコードを子プロセスで実行し、その子プロセスがどのように終了したか(特定の終了コードか、特定のシグナルか、成功か失敗か)を親プロセス側で検査する形で実装されます。Swift Testing にも、この仕組みを #expect / #require の自然な拡張として組み込むことが求められていました。

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

#expect#require に新しいオーバーロードを追加し、クロージャを 子プロセスで実行 して、その終了状態を期待値と突き合わせる「exit test」をサポートします。

基本の使い方

新しいオーバーロードでは、期待する終了状態を processExitsWith: で指定し、子プロセスで実行したいコードを末尾のクロージャに書きます。次の例は、isDeliciousfalse のタコスを eat(_:) に渡すと precondition 違反でプロセスが終了することを検証します。

@Test func weOnlyEatDeliciousTacos() async {
  await #expect(processExitsWith: .failure) {
    var taco = Taco()
    taco.isDelicious = false
    eat(taco) // precondition 違反でプロセスが終了するはず
  }
}

このマクロは内部で、現在のテストプロセスと同じ実行ファイルを使って新しい子プロセスを起動し、その中でクロージャを呼び出します。親プロセス側ではクロージャは実行されず、子プロセスの終了を await で待ち合わせます。子プロセスの終了状態が processExitsWith: で指定した条件に一致すれば exit test は成功し、一致しなければ issue として記録されます。

クロージャがプロセスを終了させずに最後まで実行された場合は、子プロセスの main 関数が自然に return したかのように扱われ、自動的にプロセスが終了します。クロージャからエラーが throw されて捕捉されなかった場合も、main() からエラーが throw されたときと同じ扱いになり、プロセスが異常終了します。

exit test は await を伴うため、テスト関数は async である必要があります。また、exit test の中からさらに別の exit test を呼び出すことはできません。

終了条件 ExitTest.Condition

processExitsWith: には ExitTest.Condition 型の値を渡します。次の 4 種類の条件を表現できます。

  • .success: EXIT_SUCCESS(C の標準で定義される成功終了)で終了したとき。
  • .failure: EXIT_SUCCESS 以外の任意の終了コード、または任意のシグナルで終了したとき。
  • .exitCode(_:): 特定の終了コードで終了したとき(CInt で指定)。
  • .signal(_:): 特定のシグナルで終了したとき(CInt で指定)。
extension ExitTest {
  public struct Condition: Sendable, CustomStringConvertible {
    public static var success: Self { get }
    public static var failure: Self { get }
    public init(_ exitStatus: ExitStatus)
    public static func exitCode(_ exitCode: CInt) -> Self
    public static func signal(_ signal: CInt) -> Self
  }
}

終了コードは macOS / FreeBSD / OpenBSD / Windows ではプロセスから報告された値全体が親に渡されますが、Linux など POSIX 系では信頼できるのは下位 8 ビット(0 〜 255)のみという制約があります。シグナルについては、Windows は POSIX ほどシグナルをサポートしていないため、Swift Testing が “best effort” でエミュレーションを行っています。

実際にプロセスが報告した終了状態を表すのは ExitStatus 列挙型です。

public enum ExitStatus: Sendable, Equatable, CustomStringConvertible {
  case exitCode(_ exitCode: CInt)
  case signal(_ signal: CInt)
}

標準出力・標準エラー出力の取得

子プロセスの標準出力・標準エラー出力は、デフォルトでは取得されません。必要な場合は、observing: 引数に ExitTest.Result のキーパスを渡すことで、結果の中に含めるよう要求します。これらのストリームの取得はメモリ消費が大きくなり得るため、明示的にオプトインする設計です。

@Test func weOnlyEatDeliciousTacos() async throws {
  let result = try await #require(
    processExitsWith: .failure,
    observing: [\.standardErrorContent]
  ) {
    var taco = Taco()
    taco.isDelicious = false
    eat(taco)
  }
  #expect(result.standardErrorContent.contains("ERROR: This taco tastes terrible!".utf8))
}

#expect(processExitsWith:) は exit test が成功すれば ExitTest.Result? を返し(失敗時は nil)、#require(processExitsWith:) は成功時の ExitTest.Result を返します。ExitTest.Result には次のプロパティがあります。

  • exitStatus: ExitStatus: 子プロセスの終了状態。常に取得されます。
  • standardOutputContent: [UInt8]: 標準出力に書き込まれた全バイト。observing:\.standardOutputContent を要求した場合のみ値が入り、そうでない場合は空配列です。
  • standardErrorContent: [UInt8]: 標準エラー出力に書き込まれた全バイト。同様にオプトインが必要です。

これらのストリームの内容は UTF-8 として有効とは限らないため、文字列化する際は String.init(validatingCString:) などを使い、比較は == ではなく contains(_:) を使うことが推奨されます。子プロセスでは OS や依存ライブラリも標準出力・標準エラー出力に書き込み得るためです。

親プロセスからの状態の持ち込みは不可

exit test は別プロセスで実行されるため、囲んでいるレキシカルコンテキストから値をキャプチャできません。たとえば次のようなパラメータ化テストの引数を捕捉するコードはコンパイルエラーになります。

@Test(arguments: 100 ..< 200)
func sellIceCreamCones(count: Int) async {
  await #expect(processExitsWith: .failure) {
    precondition(
      count < 10, // ERROR: A C function pointer cannot be formed from a
                  // closure that captures context
      "Too many ice cream cones"
    )
  }
}

これは exit test のクロージャが C 関数ポインタとして扱われる必要があることに由来する制約です。状態を子プロセスに渡したい場合の方向性は「今後の見通し」を参照してください。

サポートされるプラットフォーム

exit test は子プロセスを起動して待ち合わせる仕組みに依存するため、その API(posix_spawn()CreateProcessW() など)が利用できるプラットフォームで提供されます。本提案の時点では macOSLinuxFreeBSDOpenBSDWindows で利用可能です。サポートされないプラットフォーム向けにビルドする際には SWT_NO_EXIT_TESTS が定義され、#expect(processExitsWith:) / #require(processExitsWith:) は利用不能としてマークされます。クロスプラットフォームなテストでは、#if os(...)@available で対象を絞る必要があります。

ツール連携

swift test および swift build --build-tests を使うツールは追加対応なしで exit test を扱えます。SwiftPM を経由せずにテストターゲットをビルド・実行するツール向けには、@_spi(ForToolsIntegrationOnly)ExitTest.Handler などの SPI が公開されており、子プロセスの起動方法を独自に差し替えられます。

03 今後の見通し

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

サポートプラットフォームの拡大

iOS や WASI など、現状では子プロセスの起動を直接サポートしないプラットフォームでも exit test を提供したいという要望があります。これらのプラットフォームでは、別の仕組みで exit test を実行する必要があります。Android については posix_spawn() 相当の API が存在するため、Linux と同じ実装で対応できる可能性があり、Swift Testing チームで検討が続けられています。なお、公開インターフェースを変えずにプラットフォームサポートを追加できる場合は、Testing Workgroup の合意により別途 Swift Evolution の提案を必要としない方針です。

再帰的な exit test

現状では exit test の中からさらに別の exit test を呼び出すことはできません。これは技術的な制約であり、必要が出てくれば解消できる余地はありますが、当面は強い需要は見込まれていないとされています。

親プロセスから子プロセスへの状態の受け渡し

exit test では原理的に親プロセスの任意の状態を子プロセスに引き継げません。これに対し、可変長の arguments: 引数を追加して Codable に適合する値を渡せるようにする案が示されています。ただし、現状のマクロ展開の仕組みでは引数の型情報が得られず、子プロセス側でデコードする際の型を決定できないため、実現にはマクロ展開コンテキストでの型情報の利用や、C++ の decltype() 相当の機能などが必要になります。将来的には次のように、クロージャのキャプチャリスト構文を流用する形で書けるようにすることが構想されています。

let (lettuce, cheese) = taco.addToppings()
await #expect(processExitsWith: .failure) { [taco, plant = lettuce, cheese] in
  try taco.removeToppings(plant, cheese)
}

exit test 本体から throw されたエラーの扱いの改善

現状では、exit test のクロージャからエラーが捕捉されずに throw されると、main() throws からエラーが throw されたときと同じ扱いとなり、プロセスが異常終了します。.failure を期待している場合はテストが成功扱いになりますが、エラーハンドリングを意識していないテスト作者にとっては意外に映る可能性があります。将来的には、未捕捉のエラーに対するコンパイル時診断や、.errorNotCaught(_ error: Error & Codable) のような専用の終了条件、あるいは Codable なエラー型に限った rethrow 的な振る舞いを提供することが構想されています。

任意のプロセスを対象とする exit test

現在の exit test は、テスト対象の実行ファイルのコピーを子プロセスとして起動する形に固定されています。将来的には、任意のコマンドや Foundation.Process / 提案中の Foundation.Subprocess を直接対象として、引数や環境変数を指定して起動し、その終了状態と標準出力・標準エラー出力を検査するような API も検討されています。

let result = try await #require(
  executableAt: "/usr/bin/swift",
  passing: ["build", "--package-path", ...],
  environment: [:],
  exitsWith: .success
)
#expect(result.standardOutputContent.contains("Build went well!".utf8))

ExitStatusExpressibleByIntegerLiteral への適合

ExitStatusExpressibleByIntegerLiteral に適合させ、整数リテラルを終了コードとして解釈できるようにすることで、#expect(processExitsWith: EX_CANTCREAT) { ... } のように書けるようにする案も挙がっています。SIGABRT のようなシグナル定数が誤って終了コードとして解釈されないよう設計上の配慮は必要となるため、本提案の範囲外とされています。