Swift Digest
ST-0009 | Swift Evolution

テストへの添付(attachments)

Attachments

Proposal
ST-0009
Authors
Jonathan Grynspan
Review Manager
Rachel Brindle
Status
Implemented (Swift 6.2)

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

01 何が問題だったのか

テストが失敗したとき、特に CI のようなリモート環境では、何が起きたのかを後から追うのは簡単ではありません。テスト中に生成されたデータ(ログ、生成された画像、サーバから受け取ったレスポンスなど)が手元にあれば原因究明に役立ちますが、これまでの Swift Testing には、stdout / stderr への書き出しや、わざと issue を作って Comment に詰め込む以外に、任意のデータをテストの結果として外に持ち出す手段がありませんでした。

XCTest にはこの用途のための「attachments」という機構があり、テストに任意のデータを添付してレポートに残せます。Swift Testing にも、テストに対して構造化された形でデータを添付し、Xcode や VS Code などのツールがそれを扱えるようにする仕組みが求められていました。

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

テストに任意の値を添付するための型 Attachment と、添付可能な値を表すプロトコル Attachable を導入します。標準ライブラリの主要な型にはあらかじめ Attachable への適合が用意され、Foundation を併せて import している場合には DataURL といった Foundation の型に対する適合もクロスインポートオーバーレイ経由で提供されます。

値をそのまま添付する

もっとも単純な使い方は、Attachable に適合した値を Attachment.record(_:named:sourceLocation:) に渡す方法です。標準ライブラリ側では Array<UInt8> / ContiguousArray<UInt8> / ArraySlice<UInt8> / String / Substring に対して Attachable への適合が用意されているので、これらは追加の準備なしにそのまま添付できます。

@Test func generatesReport() {
    let report: String = makeReport()
    Attachment.record(report, named: "report.txt")
}

preferredName はファイル名のヒントとしてテスティングライブラリに渡されます。テスティングライブラリ側で必要に応じて拡張子を補ったり、別の名前に置き換えたりすることがあります。明示的に渡さない場合は、テスティングライブラリが値の型から妥当な名前を導出します。

Attachment を一度組み立ててから添付することもできます。こうすると、添付前にプロパティを調整する余地が生まれます。

@Test func generatesReport() {
    let attachment = Attachment(makeReport(), named: "report.txt")
    Attachment.record(attachment)
}

1 つの Attachment を添付できるのは 1 回だけです。

Attachment 型と Attachable プロトコル

Attachment は添付対象の値の型をジェネリックパラメータに取る non-copyable な構造体で、値が Copyable / Sendable である場合にはそれぞれ条件付きで Copyable / Sendable にも適合します。

public struct Attachment<AttachableValue>: ~Copyable
where AttachableValue: Attachable & ~Copyable {
    public var preferredName: String { get }
    public var attachableValue: AttachableValue { get }

    public init(
        _ attachableValue: consuming AttachableValue,
        named preferredName: String? = nil,
        sourceLocation: SourceLocation = #_sourceLocation
    )

    public static func record(
        _ attachment: consuming Self,
        sourceLocation: SourceLocation = #_sourceLocation
    )

    public static func record(
        _ attachableValue: consuming AttachableValue,
        named preferredName: String? = nil,
        sourceLocation: SourceLocation = #_sourceLocation
    )
}

extension Attachment: Copyable where AttachableValue: Copyable {}
extension Attachment: Sendable where AttachableValue: Sendable {}

record(_:sourceLocation:) に渡された値が Sendable かつ Copyable ではない場合、テスティングライブラリは添付時点で値をバイト列にエンコードします。エンコード中にエラーが throw されると、それは現在のテストの issue として記録され、添付自体は破棄されます。

添付対象の型が満たすべき要件は Attachable プロトコルで表現されます。

public protocol Attachable: ~Copyable {
    var estimatedAttachmentByteCount: Int? { get }

    borrowing func withUnsafeBytes<R>(
        for attachment: borrowing Attachment<Self>,
        _ body: (UnsafeRawBufferPointer) throws -> R
    ) throws -> R

    borrowing func preferredName(
        for attachment: borrowing Attachment<Self>,
        basedOn suggestedName: String
    ) -> String
}

withUnsafeBytes(for:_:) は、その型を「ファイルとして書き出すなら自然な形式」のバイト列として渡す責務を持ちます。たとえば画像を表す型なら PNG や JPEG のバイト列を渡すのが望ましく、画像のテキスト表現を渡すのは適切ではありません。estimatedAttachmentByteCount は、添付をメモリ上に保持するか、すぐにストレージに書き出すかをテスティングライブラリが判断するためのヒントです。preferredName(for:basedOn:) は、テスト作者が指定した名前を基にファイル名を整える機会を型側に与えるもので、たとえば後述の Encodable 経由の実装では拡張子を補うために使われます。

Encodable / NSSecureCoding 経由の添付

Foundation が import されている環境では、Attachable かつ Encodable か、Attachable かつ NSSecureCoding に適合する型に対して、withUnsafeBytes(for:_:) のデフォルト実装が提供されます。エンコーダには Foundation の JSONEncoderPropertyListEncoder が使われるため、この経路を使うには Foundation の import が必要です。利用者は自分の型を Attachable に適合させて Encodable を併用するだけで、JSON や plist として添付できるようになります。

AttachableWrapper でラップする

「直接 Attachable に適合させづらい型」に対しては、Attachable を継承する AttachableWrapper プロトコルが用意されています。サードパーティ製モジュールに定義された型や、final ではないクラス、追加情報がないとエンコードできない型などが該当します。これらに対しては、テスト側でラッパー型を定義して AttachableWrapper に適合させ、wrappedValue から元の値を取り出せるようにします。

public protocol AttachableWrapper<Wrapped>: Attachable, ~Copyable {
    associatedtype Wrapped
    var wrappedValue: Wrapped { get }
}

AttachmentattachableValue プロパティは、添付対象の型が AttachableWrapper に適合している場合には、ラッパー型ではなく内側の Wrapped の値を返すよう特殊化されています。ラッパー自身として取り出したいときは as で型を明示します。

let attachableValue = attachment.attachableValue as MyWrapper

ファイルや URL の中身を添付する

Foundation とのクロスインポートオーバーレイには、URL の指す先を添付するための初期化子も用意されています。最後のパスコンポーネントから自動的にファイル名が導出されるため、preferredName は省略可能です。

@Test func reportsFromDisk() async throws {
    let url = URL(fileURLWithPath: "/tmp/report.txt")
    let attachment = try await Attachment(contentsOf: url)
    Attachment.record(attachment)
}
extension Attachment where AttachableValue == _AttachableURLWrapper {
    public init(
        contentsOf url: URL,
        named preferredName: String? = nil,
        sourceLocation: SourceLocation = #_sourceLocation
    ) async throws
}

_AttachableURLWrapperAttachableWrapper に適合する内部型で、URL とそこから読み込まれたデータを保持します。

ツール連携

swift test には添付の保存先を指定するための新しいオプションが追加されます。

--attachments-path  Path where attachments should be saved.

--attachments-path を指定すると、添付されたデータがそのディレクトリ以下にファイルとして書き出されます。指定されない場合、添付はディスクに保存されません。swift test を内部で呼び出すツールは、たとえばシステムの一時ディレクトリ配下にディレクトリを作ってこのオプションに渡し、終了後に必要なファイルを取り出すような使い方ができます。

JSON ベースのイベントストリーム ABI にも、添付を表す要素が追加されます。新しい event kind として valueAttached が追加され、その際のイベントには attachment フィールドが付き、そこに添付ファイルの絶対パスが入ります。これらの追加は後方互換であるため、JSON スキーマのバージョンは据え置きです。

03 今後の見通し

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

添付のライフタイム管理

XCTest には添付の「lifetime」を指定する仕組みがあり、XCTAttachmentLifetimeKeepAlwaysXCTAttachmentLifetimeDeleteOnSuccess の 2 種類が用意されています。テストが成功した場合の添付までディスクに残しておく必要はないことが多く、Swift Testing でも同等の概念を持ち込みたいとされていますが、Swift Testing にふさわしい API の形はまだ固まっていません。

画像の添付

画像をテストに添付したいというニーズは大きい一方で、クロスプラットフォームに通用する画像型が標準で存在しないという難しさがあります。Apple プラットフォームでは CGImage を添付できる実験的な実装が Swift Testing のリポジトリにあり、最終的にどのような形で提供すべきかの参考になっています。

他モジュールの型に対する追加の適合

Swift Testing は依存グラフを小さく保つ方針のため、たとえば swift-collections のような任意のパッケージに直接リンクすることはできず、それらのモジュールの型に対する Attachable への適合をデフォルトで提供することはできません。Foundation と同様にクロスインポートオーバーレイを増やしていけば、両方を import している場合に限り適合を提供できる余地があります。これは Swift Package Manager 側の対応も必要になり得るため、本提案の範囲外とされています。

UnsafeRawBufferPointer から RawSpan への置き換え

Attachable.withUnsafeBytes(for:_:) などで使われている UnsafeRawBufferPointer は、より安全な代替として RawSpan への置き換えが想定されています。ただし、Apple プラットフォームの最低デプロイメントターゲットの制約から、現時点では RawSpan をすべての対応環境で要求できないため、置き換えは将来の課題とされています。

Attachable への Metadata 関連型の追加

Attachable に関連型 Metadata を追加し、添付に対して任意のメタ情報を持たせる構想もあります。Encodable 経由のシリアライズ形式の細かな制御、画像の縮尺や回転といったメトリクス、添付ファイルやディレクトリの圧縮アルゴリズムなどがユースケースとして挙げられています。インターフェースの最終的な形はさらに検討が必要ですが、現在提案されている API を壊さずに後から追加できる見込みです。

issue や activity への添付

XCTest では XCTIssue に添付を付けられますが、Swift Testing では現状、issue を作って即座に記録する以外の方法がなく、issue に対して何かを添付する余地がありません。XCTest が持つ「activity」(テストを区切るサブセクション)の概念も Swift Testing にはまだなく、どちらも将来の検討対象として残されています。