Swift Digest
ST-0005 | Swift Evolution

範囲ベースのconfirmation

Range-based confirmations

Proposal
ST-0005
Authors
Jonathan Grynspan
Status
Implemented (Swift 6.1)

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

01 何が問題だったのか

Swift Testing には、テスト中に非同期のイベントが想定どおりの回数だけ発生したかをチェックする confirmation() という API があります。典型的には、ちょうど1回だけ発生するか、まったく発生しないかをチェックするために使われます。

await confirmation(expectedCount: 1) { mouseClicked in
  var eventLoop = EventLoop()
  eventLoop.eventHandler = { event in
    if event == .mouseClicked {
      mouseClicked()
    }
  }
  await eventLoop.simulate(.mouseClicked)
}

問題は、expectedCount に整数しか渡せないため、回数をちょうどその数としてしか指定できなかったことです。テストの中には、フィクスチャや外部の状態に依存していて、イベントの発生回数が常に一定とは限らないものもあります。たとえば上のテストは、テスト実行中にユーザーがたまたま実際にマウスをクリックすると .mouseClicked イベントが余分に1回発生し、テストが意図せず失敗してしまいます。

このような場合、テスト作成者が表現したいのは「ちょうど1回」ではなく「1回以上」のように回数の範囲ですが、これまでの API ではそれを表現できませんでした。

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

confirmation() に、expectedCount として整数の代わりに範囲を受け取るオーバーロードを追加します。これにより、「1回以上」「5回から10回まで」のように、回数の幅を持たせた指定ができるようになります。これまでの整数を受け取るオーバーロードはそのまま残ります。

await confirmation(expectedCount: 1...) { mouseClicked in
  // ...
}

新しいオーバーロード

新しいオーバーロードのシグネチャは次のとおりです。expectedCount には、RangeExpression<Int>Sequence<Int> の両方に適合する範囲を渡します。

public func confirmation<R>(
  _ comment: Comment? = nil,
  expectedCount: some RangeExpression<Int> & Sequence<Int> & Sendable,
  isolation: isolated (any Actor)? = #isolation,
  sourceLocation: SourceLocation = #_sourceLocation,
  _ body: (Confirmation) async throws -> sending R
) async rethrows -> R

使い方は次のようになります。クロージャに渡される Confirmation を期待するイベントが起きるたびに呼び出し、クロージャから戻った時点で、呼ばれた回数が指定した範囲に収まっているかどうかがチェックされます。範囲外であれば issue として記録されます。

let minBuns = 5
let maxBuns = 10
await confirmation(
  "Baked between \(minBuns) and \(maxBuns) buns",
  expectedCount: minBuns ... maxBuns
) { bunBaked in
  foodTruck.eventHandler = { event in
    if event == .baked(.cinnamonBun) {
      bunBaked()
    }
  }
  await foodTruck.bakeTray(of: .cinnamonBun)
}

ちょうど特定の回数を期待したい場合は、これまでどおり整数を受け取るオーバーロードを使います。

下限のない範囲を弾く

...10 のような PartialRangeUpToPartialRangeThrough、そして ... 単独の UnboundedRange は、この API では扱いません。これらは仕様上 0 を含むため、たとえば ...10 を「1回から10回」のつもりで書くと、イベントが一度も起きなかった場合に意図に反してテストが成功してしまうおそれがあるためです。

PartialRangeUpToPartialRangeThroughRangeExpression に適合しますが Sequence には適合しないため、新しいオーバーロードの型制約によってコンパイル時に弾かれます。UnboundedRange も同様に対象外です。さらに、これらの型を渡そうとした場合に分かりやすい診断が出るよう、unavailable としてマークされたオーバーロードが用意されており、たとえば次のようなメッセージが表示されます。

@available(*, unavailable, message: "Unbounded range '...' has no effect when used with a confirmation.")

ツール連携への影響

回数が合わなかったときに記録される issue の種類 Issue.Kind.confirmationMiscounted(actual:expected:) のうち、expected の型がこれまでの Int から any RangeExpression & Sendable に変わります。イベントストリームを処理する外部ツールでこの値を扱っている場合は、Int であることを前提にしないようにする必要があります。