Swift Digest
ST-0022 | Swift Evolution

テスト時のカスタム reflection

Custom reflection during testing

Proposal
ST-0022
Authors
Jonathan Grynspan
Review Manager
Maarten Engels
Status
Implemented (Swift 6.4)

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

01 何が問題だったのか

Swift Testing は、#expect() の比較が失敗したときに、比較対象となった値の中身を分解して出力します。たとえば次のようなテストでは、

struct MonsterTruck: Equatable {
  var color: Color
  var numberOfWheels: Int
}

@Test func `Monster trucks`() {
  let crushinator = MonsterTruck(color: .red, numberOfWheels: 4)
  let truckasaurusRex = MonsterTruck(color: .green, numberOfWheels: 5)
  #expect(crushinator == truckasaurusRex)
}

Xcode のテスト結果や swift test のコンソール出力に、比較に使われた両辺の値とその stored property までが展開されて表示されます。

✘ Test "Monster trucks" recorded an issue at [...]: Expectation failed: crushinator == truckasaurusRex
↳ crushinator == truckasaurusRex → false
↳   crushinator → MonsterTruck(color: Color.red, numberOfWheels: 4)
↳     color → .red
↳     numberOfWheels → 4
↳   truckasaurusRex → MonsterTruck(color: Color.green, numberOfWheels: 5)
↳     color → .green
↳     numberOfWheels → 5

このような分解は、#expect() の式をコンパイル時に解析しておき、実行時に部分式を Mirror.init(reflecting:) に渡すことで実現されています。Mirror の出力は、型が CustomReflectable に適合していればその実装に従って差し替えることができ、Swift Testing もこの適合を尊重します。

しかし、CustomReflectable の実装は本番コードでの一般的な reflection 用途、たとえば実装詳細を隠したり最低限の情報だけを見せたりする目的で書かれていることが多くあります。テストの失敗を調べるときには、本番コードでは隠されている実装詳細まで見たい、あるいは逆にテストログを読みやすくするために情報量を絞ったり整形したりしたい、という要求が出てきます。

そうしたテスト時固有の要求は、本番向けに書かれた CustomReflectable をそのまま使い回すのでは満たせません。#expect() の失敗時に表示する内容だけを、本番コードの reflection と独立してカスタマイズする手段が必要だ、というのが本 Proposal の出発点です。

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

Swift Testing に、テスト出力専用の Mirror を提供するための新しいプロトコル CustomTestReflectable が追加されます。型がこのプロトコルに適合していれば、Swift Testing は #expect() 失敗時の分解表示にその Mirror を使い、適合がなければ従来どおり Mirror.init(reflecting:) を呼び出します。CustomReflectable への適合がある場合の挙動も変わりません。

CustomTestReflectable プロトコル

プロトコルの定義は次のとおりです。テスト出力に使う MirrorcustomTestMirror プロパティで返すだけのシンプルな形になっています。

/// A protocol describing types with a custom reflection when presented as part
/// of a test's output.
///
/// ## See Also
///
/// - ``Swift/Mirror/init(reflectingForTest:)``
public protocol CustomTestReflectable {
  /// The custom mirror for this instance.
  ///
  /// Do not use this property directly. To get the test reflection of a value,
  /// use ``Swift/Mirror/init(reflectingForTest:)``.
  var customTestMirror: Mirror { get }
}

命名は、本番コード向けの CustomStringConvertible に対するテスト出力向けの CustomTestStringConvertible と同じ関係にならっています。

たとえば、本番コードでは内部状態を隠したい型でも、テストの失敗時にはすべての stored property を見たい、という場合には次のように書けます。

import Testing

struct AccessToken: CustomReflectable, CustomTestReflectable {
  var userID: String
  var rawValue: String
  var expiresAt: Date

  // 本番コードの reflection では機微な値を隠す。
  var customMirror: Mirror {
    Mirror(self, children: ["userID": userID])
  }

  // テスト出力では中身をすべて見せる。
  var customTestMirror: Mirror {
    Mirror(self, children: [
      "userID": userID,
      "rawValue": rawValue,
      "expiresAt": expiresAt,
    ])
  }
}

逆に、stored property が多すぎてテストログが読みにくくなる型では、customTestMirror で表示する子要素を絞り込んだり整形したりすることもできます。

Mirror.init(reflectingForTest:)

併せて、Mirror にテスト出力向けの初期化子が追加されます。CustomTestReflectable に適合した値を直接受け取るオーバーロードと、任意の値を受け取るオーバーロードの 2 つです。

extension Mirror {
  /// Initialize this instance so that it can be presented in a test's output.
  public init(reflectingForTest subject: some CustomTestReflectable)

  /// Initialize this instance so that it can be presented in a test's output.
  public init(reflectingForTest subject: some Any)
}

任意の値を受け取るオーバーロードは、対象の値が CustomTestReflectable に適合していればその customTestMirror を返し、そうでなければ Mirror.init(reflecting:) の結果(つまり CustomReflectable への適合があればそれ、なければデフォルトの reflection)を返します。Swift Testing が内部で行う分解と同じ手順を、テストコードからも明示的に呼び出せます。

これらの新しい API は test target でのみ利用可能で、本番コードにはコンパイル時・実行時のいずれのコストも持ち込みません。

JSON event stream への反映

JSON event stream のスキーマも拡張され、issue が記録された際にその原因となった式の情報を含められるようになります。<issue> に省略可能な expression フィールドが追加され、<expression>sourceCoderuntimeValueruntimeTypeName、子の式の配列 children を持ちます。

 <issue> ::= {
   "isKnown": <bool>, ; is this a known issue or not?
   ["sourceLocation": <source-location>,] ; where the issue occurred, if known
+  ["expression": <expression>,] ; the expression that generated the issue, if any
 }
+
+<expression> ::= {
+  "sourceCode": <string>, ; the source code of this expression
+  ["runtimeValue": <string>,] ; a description of this expression's runtime
+                              ; value, if available
+  ["runtimeTypeName": <string>,] ; the name of the type of "runtimeValue"
+  ["children": <array:expression>,] ; any available child expressions within
+                                    ; this expression
+}

これにより、JSON event stream を読むツールからも、#expect() 失敗時に Swift Testing が捕捉した値の構造を取り出せるようになります。Swift Testing 自身が CustomTestReflectable への適合を自動的に尊重するため、ツール側で追加の対応は必要ありません。