Swift Digest
ST-0026 | Swift Evolution

TaskLocal を束縛するテスト trait

TaskLocal test trait

Proposal
ST-0026
Authors
Brandon Williams, Stephen Celis
Review Manager
Stuart Montgomery
Status
Accepted with revisions

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

01 何が問題だったのか

ST-0007 で導入された test scoping trait を使うと、テスト本体をクロージャで包み、その前後で任意の処理を実行する trait を定義できます。この機能のいちばんの動機は、テスト実行中だけ @TaskLocal(SE-0311)の値を差し替えることであり、ST-0007 自身も Future Directions として「task local を束縛する専用の便利 trait」を挙げていました。この提案は、その便利 trait を実際に追加するものです。

便利 trait が求められる理由は主に 2 つあります。

1 つ目は、@TaskLocal の差し替えが TestScoping を独自に実装するもっとも一般的な理由であるにもかかわらず、そのたびに定型的なコードを書く必要があることです。たとえば Bool の task local を束縛するだけの trait でも、次のように書くことになります。

struct IsEnabledTrait: SuiteTrait, TestTrait, TestScoping {
  let isEnabled: Bool
  let isRecursive = true
  func provideScope(
    for test: Test,
    testCase: Test.Case?,
    performing function: () async throws -> Void
  ) async throws {
    try await FeatureFlags.$isEnabled.withValue(isEnabled) {
      try await function()
    }
  }
}
extension Trait where Self == IsEnabledTrait {
  static func isEnabled(_ isEnabled: Bool) -> Self {
    Self(isEnabled: isEnabled)
  }
}

2 つ目は、再利用可能なライブラリで @TaskLocal を提供している場合、その差し替え用 trait をライブラリ側で用意しにくいことです。テスト以外のターゲットからは Testing のシンボルを参照できないため、こうした trait は専用の「テストサポート」ライブラリに置く必要があり、task local を含むライブラリを配布するうえでの手間になっていました。専用の trait があれば、テストサポートライブラリを別途用意しなくても、テスト内で task local に値を束縛できます。

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

Testing に .taskLocal trait を追加し、単一のテストやスイート全体に対して task local を差し替えられるようにします。

@Suite(.taskLocal(FeatureFlags.$isEnabled, true))
struct MySuite {
  // ...
}

第 1 引数に差し替えたい TaskLocal$ を付けた projected value)、第 2 引数に束縛する値を渡します。引数の順序は TaskLocal.withValue(_:operation:) に揃えてあります。この trait を付けたテストやスイートの実行は、内部的に withValue で包まれ、実行中だけ指定した値が有効になります。

trait の実体は TestScoping に適合する TaskLocalTrait<Value> 型で、Trait 上の静的メソッド taskLocal(_:_:) から構築します。値は @autoclosure で受け取るため、束縛する値の生成はスコープに入るまで遅延されます。

extension Trait {
  /// テストまたはスイートの実行中、task local に値を束縛する trait を構築する。
  public static func taskLocal<Value: Sendable>(
    _ taskLocal: TaskLocal<Value>,
    _ value: @autoclosure @escaping @Sendable () throws -> Value
  ) -> Self
  where Self == TaskLocalTrait<Value>
}

public struct TaskLocalTrait<Value: Sendable>: SuiteTrait, TestTrait, TestScoping {
  public var taskLocal: TaskLocal<Value> { get set }
  public func evaluate() async throws -> Value
  public var isRecursive: Bool { get }
  public func provideScope(
    for test: Test,
    testCase: Test.Case?,
    performing function: @Sendable () async throws -> Void
  ) async throws
}

注意: task local はテストターゲットの外で定義する

重要な制約として、束縛したい @TaskLocalテストターゲットの外 で定義しておく必要があります。次のように、テストと同じターゲットで宣言した task local は束縛できません。

@TaskLocal var isEnabled = false

@Test(.taskLocal($isEnabled, true))  // 🛑 Cannot find '$isEnabled' in scope
func test() {
 // ...
}

これは、@TaskLocal マクロが生成する $isEnabled というシンボルを @Test マクロが参照できないという、マクロの既知の制限によるものです。テストと同じターゲットで task local を定義して差し替えたいケースもありますが、あまり一般的ではなく、その場合は task local を別のターゲットへ移すことで対応できます。

03 今後の見通し

Swift のマクロが、別のマクロによって定義されたシンボルを参照できるようになれば、この API はより多くの状況で使えるようになる可能性があります。前述のとおり、現在は @TaskLocal が生成する $ 付きのシンボルを @Test / @Suite マクロが見られないため、束縛したい task local をテストターゲットの外に置く必要があります。この制限が解消されれば、テストと同じターゲットで定義した task local に対しても .taskLocal trait を使えるようになると期待されます。ただしこれはマクロ側の機能拡張に依存する将来の構想であり、実現を約束するものではありません。