issue を処理する trait
Issue Handling Traits
このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら↗。
01 何が問題だったのか
Swift Testing では、@Suite や @Test に trait を付与することでテストの属性をカスタマイズしたり、独自のロジックを差し込んだりできます。一方で、テスト中に記録される issue(#expect の失敗や Issue.record(...) で残される情報)について、それがテスティングライブラリに取り込まれる前に手を加える手段は用意されていませんでした。
この機能がないと、たとえば次のようなことが実現できません。
- 失敗の種類に応じてドキュメントへのリンクや背景情報を
commentsに自動で添える - ランダムな入力で失敗したときに、メッセージ中の値を正規化して同じ原因の失敗をまとめやすくする
- 警告レベルの issue(ST-0013 で導入された severity)のうち、特定の条件に当てはまるものを抑制する
- 既定ではエラーとして扱われる issue を警告に下げたり、逆に警告をエラーに引き上げたりする
Swift コンパイラ側では SE-0443 によって警告診断の制御が可能になりましたが、テストで記録される issue についても同様に「記録された issue を観察して、書き換える・抑制する・追加情報を補う」ための仕組みが求められていました。
02 どのように解決されるのか
記録された issue を加工・抑制するための新しい trait 型 IssueHandlingTrait を導入します。TestTrait と SuiteTrait の両方に適合しているため、個々のテストにもスイート全体にも付与でき、スイートに付けた場合は子スイートやテストに再帰的に継承されます。
IssueHandlingTrait を直接構築する代わりに、Trait のスタティックメンバーとして用意される compactMapIssues(_:) と filterIssues(_:) を使って組み立てます。
compactMapIssues(_:) で issue を変換・抑制する
compactMapIssues(_:) には (Issue) -> Issue? 型のクロージャを渡します。クロージャは、対象のテストで issue が記録されるたびに同期的に呼ばれ、加工した issue を返せばそれが元の issue に置き換わり、nil を返せば issue 全体が抑制されます。
@Test(.compactMapIssues { issue in
var issue = issue
issue.comments.append("Checking whether two literals are equal")
return issue
})
func literalComparisons() {
#expect(1 == 1) // OK
#expect(2 == 3) // 失敗。issue handler が呼ばれてコメントが追加される
#expect("a" == "b") // 失敗。issue handler が再度呼ばれる
}
ハンドラのクロージャはエラーを throw しない(non-throwing)ように要求されており、また async も付けられません。async を許さないのは、Swift Testing のイベント配信が同期的に行われていて、#expect のたびに await を要求しないという設計を維持するためです。エラーを throw できないようにしているのは、すでに失敗を表す issue を処理している最中にさらにエラーを投げると、結果の解釈が曖昧になるからです。エラーが起きたときは、加工した issue にその情報を含めて返すか、後述するようにハンドラの中で別の issue を新たに記録するかのどちらかで対応します。
filterIssues(_:) で issue を絞り込む
filterIssues(_:) には (Issue) -> Bool 型の述語クロージャを渡します。true を返した issue はそのまま残り、false を返した issue は抑制されます。
extension Trait where Self == IssueHandlingTrait {
static var ignoreSensitiveWarnings: Self {
.filterIssues { issue in
let description = String(describing: issue)
// Issue.severity は ST-0013 で導入される
return issue.severity <= .warning
&& SensitiveTerms.all.contains { description.contains($0) }
}
}
}
@Test(.ignoreSensitiveWarnings) func exampleA() { /* ... */ }
@Test(.ignoreSensitiveWarnings) func exampleB() { /* ... */ }
このように Trait のエクステンションとして名前付きのプロパティを定義しておくと、複数のテストで同じハンドラを再利用できます。
複数のハンドラの適用順
ひとつのテストに複数の issue handling trait が付与されている、あるいはスイートから継承されている場合、ハンドラは 末尾から先頭へ、内側から外側へ の順で実行されます。
@Suite(.compactMapIssues { /* A */ })
struct ExampleSuite {
@Test(.filterIssues { /* B */ },
.compactMapIssues { /* C */ })
func example() { /* ... */ }
}
example() の中で issue が記録されると、まず C、次に B、最後に A の順で処理されます。途中で nil(あるいは filterIssues の false)が返されて issue が抑制された場合、それ以降のハンドラは呼ばれません。より具体的なハンドラが先に処理されるので、汎用的なハンドラがその結果を見て扱うという階層的な構成にできます。
task-local な状態へのアクセス
ハンドラのクロージャは issue が記録された場所で同期的に呼ばれるため、その文脈の @TaskLocal などの状態を読み取って issue に補足情報を付け加えられます。
actor Session {
@TaskLocal static var current: Session?
let id: String
// ...
}
@Test(.compactMapIssues { issue in
var issue = issue
if let session = Session.current {
issue.comments.append("Current session ID: \(session.id)")
}
return issue
})
func example() async {
let session = Session(id: "ABCDEF")
await Session.$current.withValue(session) {
await session.connect()
#expect(await session.isConnected)
// 失敗時には "Current session ID: ABCDEF" がコメントに付く
}
}
ハンドラの中から issue を記録する
issue handling trait のクロージャの中で Issue.record(...) を呼ぶと、新しく記録された issue は そのハンドラより後(外側) にある issue handling trait の処理対象になります。自分自身や、自分より先に動く(内側の)ハンドラには戻ってきません。これを利用して、ある条件に合致する issue を抑制すると同時に、その理由を別の issue として残すといった構成が可能です。
他の trait が記録する issue も対象になる
issue handling trait は、テストで記録されたあらゆる issue を処理対象とします。たとえば .enabled(if:) の条件クロージャがエラーを throw し、それが issue として記録された場合も同じ仕組みで処理されます。テストに関連する issue は出どころに関係なく一貫したルールで扱われます。
ライブラリ自身が記録する issue は対象外
issue handling trait はあくまで「ユーザーが書いたテストで生じた問題」を扱うためのものなので、テスティングライブラリや基盤側が記録する issue(Issue.kind が .system のもの)は処理対象外で、ハンドラのクロージャには渡されません。compactMapIssues(_:) のクロージャから .system や .apiMisused の issue を新たに作って返すこともできません(元から .apiMisused だった issue をそのまま返す場合を除きます)。
直接 issue を処理する API
IssueHandlingTrait には、保持しているハンドラを直接呼び出すためのインスタンスメソッドも用意されています。
public struct IssueHandlingTrait: TestTrait, SuiteTrait {
/// issue を処理し、置き換え後の issue を返す。`nil` の場合は記録されない。
public func handleIssue(_ issue: Issue) -> Issue?
}
複数の issue handling trait を組み合わせて自前の trait を作りたい場合などに利用できます。
ツールとの連携
CI やエディタなど、Swift Testing と連携するツールは記録された issue をもとに結果を表示します。issue handling trait を経由した issue は、ツール側にも 加工後の姿 で渡されます。抑制された issue はツールにも通知されません。
03 今後の見通し
issue handling trait のクロージャは issue ひとつごとに同期的に呼ばれる仕様で、async な API を使った診断情報の収集や、テストごとに 一度だけ 実行したい後処理には向きません。これに対する補完として、テストの終了後に一度だけ呼ばれる「テスト終了 trait」のようなものを将来追加し、結果(成功・失敗・スキップ)と記録されたすべての issue を async クロージャに渡せるようにする案があります。
さらに一般化した方向として、Swift Testing のあらゆるイベントを観察できる包括的な API を整備することも検討されています。これは Swift Testing の vision でも目標として挙げられているもので、今回の trait はその一部分を担う位置づけになります。
別軸の発展として、trait としてではなく withKnownIssue { } のようなスタンドアロン関数の形で、テストや スイート全体ではなく特定の範囲のコードにだけ issue handler を適用できる API を提供するアイデアもあります。これは issue handling 専用ではなく、他の種類の trait にも応用が利く一般的なパターンとして、別の Proposal で扱われる見込みです。
これらはいずれも将来の構想であり、実現を約束するものではありません。