Swift Digest
ST-0016 | Swift Evolution

テストのキャンセル

Test cancellation

Proposal
ST-0016
Authors
Jonathan Grynspan
Review Manager
Maarten Engels
Status
Implemented (Swift 6.3)

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

01 何が問題だったのか

Swift Testing には、テストを開始する前に条件付きでスキップするための .enabled(if:).disabled(if:) といったトレイトが用意されています。これらは事前に評価できるため、テストランの計画も効率的に行えます。

@Test(.disabled(if: Species.all(in: .dinosauria).isEmpty)
func `Are all dinosaurs extinct?`() {
  // ...
}

しかし、テストを実行してみないとわからない条件もあります。テストの中でデータを取得して初めてわかる前提条件や、パラメータ化テストにおいて特定の引数だけをスキップしたいケースなどです。これまでの Swift Testing には「テストの実行を途中で打ち切る」ための公式な API がなく、開発ツールにもその状態を伝えられませんでした。

回避策として withUnsafeCurrentTask で現在のタスクを取得し、Task.cancel() を呼ぶことは可能でした。

@Test(arguments: Species.all(in: .dinosauria))
func `Are all dinosaurs extinct?`(_ species: Species) {
  if species.in(.aves)  {
    // Birds aren't extinct (I hope)
    withUnsafeCurrentTask { $0?.cancel() }
    return
  }
  // ...
}

しかし UnsafeCurrentTask は名前のとおり unsafe なインターフェイスであり、また「テストケースをキャンセルしたい」という意図がコードから読み取りにくいという問題がありました。さらに、キャンセルの理由をコメントとして添えたり、ソースロケーションを開発ツールに伝えたりすることもできません。

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

Test 型に、現在実行中のテストまたはテストケースをキャンセルするための静的関数 cancel(_:sourceLocation:) が追加されます。

extension Test {
  public static func cancel(
    _ comment: Comment? = nil,
    sourceLocation: SourceLocation = #_sourceLocation
  ) throws -> Never
}

テスト本文やトレイトの prepare(for:) などから呼ばれると、Swift Testing は現在実行中のテストをキャンセルします。キャンセルの理由を表すコメントとソースロケーションを添えられるため、コンソール出力や IDE のテスト結果画面にこれらの情報を表示できます。

テストとタスクの関係

Swift Testing は各テストおよび各テストケースをそれぞれ独立した Task で実行します。Test.cancel(...) がキャンセルする対象は、その「現在のテスト」または「現在のテストケース」に紐付いたタスクです。具体的には、

  • 現在実行中のテストが通常のテスト関数なら、そのテストがキャンセルされます。
  • 現在実行中のテストがパラメータ化テスト関数なら、現在のテストケースのみがキャンセルされ、他のテストケースは実行が続きます。
  • 現在実行中のテストがスイートなら、そのスイートに属する pending / running なテストすべてがキャンセルされます。

すでにキャンセル済み、もしくは実行が終わっているテストに対して再度呼ばれた場合は、エラーを throw するだけで二重にキャンセルしようとはしません。また、テストに紐付いていないタスク(たとえば Task.detached(...) で作られたタスク)から呼ばれた場合は、issue を記録したうえで現在のタスクをキャンセルします。

テストやテストケースのキャンセルは、暗黙にその関連タスク(およびその子タスク)に対して Task.cancel() を呼んだのと同じ効果を持ちます。

必ず throw するセマンティクス

Task.cancel() と異なり、Test.cancel(...) は戻り値を返さず必ずエラーを throw します。これにより、

if condition {
  theTask.cancel()
  return
}

のように「キャンセルしてから return する」と書く必要がなくなり、

if condition {
  try Test.cancel()
}

と書くだけで済みます。

throw されるエラーは Swift Testing 内部の型で、CancellationError と同様の意味を持ちつつ、commentsourceLocation の情報を持ちます。Swift Testing はこの型のエラーをキャッチしたとき、現在のテストやテストケースに対して issue を記録しません。do / catchtry? でこのエラーを握りつぶしてもキャンセル状態は解除されませんが、テストやテストケースが終わる前にローカルな後処理を行いたい場合に活用できます。

なお、テストやテストケースが CancellationErrorthrow し、かつ現在のタスクがキャンセル済みであれば、Swift Testing はそれをキャンセルとして扱います。タスクがキャンセルされていないのに CancellationErrorthrow された場合は、通常どおり issue として記録されます。

記録済みの issue との関係

キャンセルする前にすでに issue を記録していた場合、その issue は無効化されません。特に、すでに重大度 error の issue が記録されているテストやテストケースを cancel(...) した場合、そのテストやテストケースは依然として失敗扱いになります。

利用例

パラメータ化テストで現在のテストケースだけをキャンセルし、他のテストケースは実行を続けたい場合は次のように書けます。

@Test(arguments: Species.all(in: .dinosauria))
func `Are all dinosaurs extinct?`(_ species: Species) throws {
  if species.in(.aves)  {
    try Test.cancel("\(species) is birds!")
  }
  // ...
}

実行が始まった後にしか確かめられない条件(営業時間外など)を理由に、テスト全体を打ち切ることもできます。

@Test func `Food truck is well-stocked`() throws {
  guard businessHours.contains(.now) else {
    try Test.cancel("We're off the clock.")
  }
  // ...
}

JSON イベントストリームの拡張

Swift Testing が提供する JSON イベントストリームには、新たに "testCancelled""testCaseCancelled" の 2 種類のイベント種別が追加されます。前者はテスト関数全体やテストスイートがキャンセルされたとき、後者はパラメータ化テストの個別のテストケースがキャンセルされたときに発行されます。

イベントレコードには cancel(...) に渡されたコメントとソースロケーションを表すフィールド commentssourceLocation が追加され、新しいイベント種別だけでなくこれらを伴いうる他のイベント種別でも利用されます。これらの追加は、JSON スキーマの次のリビジョン(バージョン "6.3" を予定)に含まれる見込みです。

03 今後の見通し

将来の発展として、以下のようなアイデアが挙げられています。

  • Test.checkCancellation() 関数や Test.isCancelled 静的プロパティの追加。ただし Task.checkCancellation()Task.isCancelled がすでにテスト中でも機能するため、本 Proposal の範囲外とされています。
  • パラメータ化テスト関数のテストケースをまとめてキャンセルする Test.Case.cancelAll() の追加。導入する前にユースケースとセマンティクスをさらに検討する必要があるとされています。

これらはいずれも将来の構想であり、実現を約束するものではありません。