はじめに
SwiftTestingではTestマクロのarguments引数にコレクションを渡すことでパラメータ化テストを実施することができます。
arguments引数がどのようにマクロの中で変換されるのか、arguments引数には何が入れられるのか調べた際のメモです。
swift-tesitngのバージョンは6.0.3です。
公式ドキュメント:
Implementing parameterized tests | Apple Developer Documentation
概要
- arguments引数の型は C: Collection & Sendable, C.Element: Sendable であるため、コレクションのみ渡すことができる
- arguments引数に入れたコレクションはクロージャにラップされる
- クロージャにラップされたことでコレクションの値の計算は実行時まで遅延される。そのため値はコンパイル時に確定している必要はない
背景
以下のようなテストがあるとき、
@Test(arguments: [1, 2, 3])
func testmethod(_ n: Int) async throws {
#expect(n > 0)
}
この関数のTestマクロを展開すると、以下のようなコードに展開されることが確認できます。
@available(*, deprecated, message: "This type is an implementation detail of the testing library. Do not use it directly.")
enum $s16AppTestsAAV10testmethod4TestfMp_63__🟠$test_container__function__functestmethod__n_Int_asyncthrowsfMu_: Testing.__TestContainer {
static var __tests: [Testing.Test] {
get async {
return [
.__function(
named: "testmethod(_:)",
in: AppTests.self,
xcTestCompatibleSelector: nil,
traits: [], arguments: {
[1, 2, 3]
}, sourceLocation: Testing.SourceLocation(fileID: "AppTests/AppTests.swift", filePath: "...", line: 133, column: 6),
parameters: [(firstName: "_", secondName: "n", type: (Int).self)],
testFunction: $s16AppTestsAAV10testmethod4TestfMp_33functestmethod__n_Int_asyncthrowsfMu0_
)
]
}
}
}
ここでargumentsに着目すると波括弧がついていますが、このように変換して扱われる理由とマクロ内での処理の流れが気になります。
arguments: { [1, 2, 3] }
swift-testingのソースコードを追う
まずどのように展開されるのかを調べるため、1つの配列を受け取るパターンを例にswift-testingのソースコードを追っていきます。
@Test(arguments: [1, 2, 3])
func testmethod(_ n: Int) async throws {
#expect(n > 0)
}
- まず、@Testマクロには1つのarguments引数が渡されているので、これに対応するマクロが呼ばれます
@attached(peer) public macro Test<C>(
_ displayName: _const String? = nil,
_ traits: any TestTrait...,
arguments collection: C
) = #externalMacro(module: "TestingMacros", type: "TestDeclarationMacro") where C: Collection & Sendable, C.Element: Sendable
ドキュメント: Test(::arguments:) | Apple Developer Documentation
ソースコード: https://github.com/swiftlang/swift-testing/blob/18c42c19cac3fafd61cab1156d4088664b7424ae/Sources/Testing/Test%2BMacro.swift#L197-L202
- 実体であるTestDeclarationMacroに処理が移ります。
_createTestContainerDecls
メソッドでnodeが処理されていることがわかります。
public struct TestDeclarationMacro: PeerMacro, Sendable {
public static func expansion(
of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard _diagnoseIssues(with: declaration, testAttribute: node, in: context) else {
return []
}
let functionDecl = declaration.cast(FunctionDeclSyntax.self)
let typeName = context.typeOfLexicalContext
return _createTestContainerDecls(for: functionDecl, on: typeName, testAttribute: node, in: context)
}
-
_createTestContainerDecls
メソッドの中では解析が行われて__functionの生成がされています。
特にtraitやargumentsを処理して出力をしているのはAttributeInfo.functionArgumentListです。
private static func _createTestContainerDecls(
for functionDecl: FunctionDeclSyntax,
on typeName: TypeSyntax?,
testAttribute: AttributeSyntax,
in context: some MacroExpansionContext
) -> [DeclSyntax] {
var result = [DeclSyntax]()
...
// Parse the @Test attribute.
let attributeInfo = AttributeInfo(byParsing: testAttribute, on: functionDecl, in: context)
if attributeInfo.hasFunctionArguments != !functionDecl.signature.parameterClause.parameters.isEmpty {
...
var testsBody: CodeBlockItemListSyntax = """
return [
.__function(
named: \(literal: functionDecl.completeName.trimmedDescription),
in: \(typeNameExpr),
xcTestCompatibleSelector: \(selectorExpr ?? "nil"),
\(raw: attributeInfo.functionArgumentList(in: context)),
parameters: \(raw: functionDecl.testFunctionParameterList),
testFunction: \(thunkDecl.name)
)
]
"""
}
- functionArgumentListの中では、swift-syntax パッケージで定義されているClosureExprSyntaxが呼ばれ、
ClosureExprSyntax
が生成されます。この関数の内部で元のargumentsが{}
で囲われます。ここまででarguments配列が波括弧で囲われるまでの流れが追えました。
func functionArgumentList(in context: some MacroExpansionContext) -> LabeledExprListSyntax {
...
if let testFunctionArguments {
arguments += testFunctionArguments.map { argument in
var copy = argument
copy.expression = .init(ClosureExprSyntax { argument.expression.trimmed })
return copy
}
}
return LabeledExprListSyntax(arguments)
}
- 最後に
_createTestContainerDecls
メソッドによって出来上がる__function
メソッドの型を確認します。argumentsの型は@escaping @Sendable () async throws -> C
でありクロージャです。
public static func __function<C>(
named testFunctionName: String,
in containingType: (any ~Copyable.Type)?,
xcTestCompatibleSelector: __XCTestCompatibleSelector?,
displayName: String? = nil,
traits: [any TestTrait],
arguments collection: @escaping @Sendable () async throws -> C,
sourceLocation: SourceLocation,
parameters paramTuples: [__Parameter],
testFunction: @escaping @Sendable (C.Element) async throws -> Void
) -> Self where C: Collection & Sendable, C.Element: Sendable {...}
arguments引数を追っていくと、配列で入力されたものが最終的に__functionに渡すタイミングではクロージャとして扱えるように処理されるという流れが見えました。
autoclosureのようなことをやっているのかな、と思えます。
クロージャにすることで評価が遅延される
クロージャの形にラップすることでコレクション内の値の評価を実行時まで遅延させているようです。
具体例
マクロが展開されてクロージャとして扱われる過程を追うことができたため、コンパイル時に型が決まっていればarguments引数にわたすことができる、と言えそうです。
具体的にどのようなコレクションが引数に渡せるのか整理します。
⭕️: 通常の配列
渡せます。
@Test(arguments: [1, 2, 3])
❌: インスタンス変数で定義した配列
マクロ展開時(コンパイル時)には値を取得できず、クロージャに含めることができないため渡せません。
let list = [1, 2, 3]
@Test(arguments: list) // ❌: コンパイル時に値が取得できないため、クロージャにできない
⭕️: クラス変数で定義した配列
コンパイル時に値が確定しているため渡せます。
static let list = [1, 2, 3]
@Test(arguments: list)
⭕️: クラス変数の中に計算プロパティが含まれる配列
値自体はコンパイル時に必要なく、実行時に評価されます。
そのため以下のような配列でも渡すことができ、実行するたびに値も変わります。
static func random() -> Int {
return Int.random(in: 1...10)
}
static let list = [random(), random()]
@Test(arguments: list)
⭕️: CaseIterableのallCases
公式ドキュメントにもある使用例です。
AllCases型はCollectionに適合しており、enumもSendableに適合しているため渡せます。
enum Food: CaseIterable {
case burger, iceCream, burrito, noodleBowl, kebab
}
@Test("All foods available", arguments: Food.allCases)
func foodAvailable(_ food: Food) async throws {
let foodTruck = FoodTruck(selling: food)
#expect(await foodTruck.cook(food))
}
おわりに
配列をarguments引数に渡した際、その値がマクロの内部でクロージャにラップされるまでの流れを追いました。
クロージャにすることで実行時まで評価が遅延できようになり、コンパイル時に値が確定していない動的なデータでもテストが可能になるほか、allCasesなどの様々な入力や多様なテストシナリオへの対応が実現できているようです。
複数のテストでargumentsを使いまわしたいときや動的に引数を生成する必要があるとき、クラス変数として定義しておくとテストコードの可読性を高く維持できそうです。