Swift TestingでSwift Macrosのテストを書くために
TL;DR
Swift TestingでSwift Macrosのテストを書く場合、assertMacroExpansion をそのまま使うと失敗ケースでもテストが失敗にならない問題があります。
これを回避するにはassertMacroExpansion
をラップしたヘルパーメソッドを用意します。
なぜ assertMacroExpansion が正しく動作しないのか?
Swift Macros のパッケージを作成すると、以下のようなテストが自動生成されます(簡略化のために #if canImport() の分岐は省略)。これは XCTest を使ったテストコードです。
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
import SwiftSyntaxMacrosTestSupport
import XCTest
import MyMacroMacros
let testMacros: [String: Macro.Type] = [
"stringify": StringifyMacro.self,
]
final class MyMacroTests: XCTestCase {
func testMacro() throws {
assertMacroExpansion(
"""
#stringify(a + b)
""",
expandedSource: """
(a + b, "a + b")
""",
macros: testMacros
)
}
...
}
これをSwift Testingで書き直すと、例えば以下のようになります。
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
import SwiftSyntaxMacrosTestSupport
import Testing
import MyMacroMacros
let testMacros: [String: Macro.Type] = [
"stringify": StringifyMacro.self,
]
final class MyMacroTests {
@Test
func testMacro() throws {
assertMacroExpansion(
"""
#stringify(a + b)
""",
expandedSource: """
(a + b, "a + b")
""",
macros: testMacros
)
}
...
}
これを実行するとテストが成功するので「よしよし」と開発を進めてしまったのですが、ここにはまりポイントがあります。
expandedSource
で失敗するケースを書いても失敗しないのです😂
原因はSwiftSyntaxMacrosTestSupport
のassertMacroExpansion
がXCTestのXCTFail
を呼んでいるためです。swift-testing は内部的にIssue.record
を利用しますが、swift-syntax が swift-testing に依存するわけにはいかず、逆の依存関係も作れないため、Issue.record をそのまま使うことはできません。
この問題はフォーラムでも投稿されて上記の課題がポストされています。[1]
どんなヘルパーメソッドを書けばいいのか?
swift-syntax
の600.0.0からSwiftSyntaxMacrosGenericTestSupport
が追加されました。これを利用することで、以下のように自前のヘルパーメソッドを用意できます。
#if canImport(SwiftSyntax600)
import SwiftSyntaxMacrosGenericTestSupport
func assertMacroExpansion(
_ originalSource: String,
expandedSource: String,
diagnostics: [DiagnosticSpec] = [],
macros: [String: Macro.Type],
conformsTo conformances: [TypeSyntax] = [],
testModuleName: String = "TestModule",
testFileName: String = "test.swift",
indentationWidth: Trivia = .spaces(4),
fileID: StaticString = #fileID, filePath: StaticString = #filePath,
file: StaticString = #file, line: UInt = #line, column: UInt = #column
) {
assertMacroExpansion(
originalSource, expandedSource: expandedSource,
diagnostics: diagnostics,
macroSpecs: macros.mapValues { value in
return MacroSpec(type: value, conformances: conformances)
},
testModuleName: testModuleName, testFileName: testFileName,
indentationWidth: indentationWidth
) { spec in
#if swift(>=6)
Issue.record(
.init(rawValue: spec.message),
sourceLocation: .init(
fileID: String(describing: fileID), filePath: String(describing: filePath),
line: Int(line), column: Int(column)
)
)
#else
Issue.record(
.init(rawValue: spec.message),
sourceLocation: .init(
fileID: fileID, filePath: filePath, line: line, column: column
)
)
#endif
}
}
#endif
この実装はフォーラムの投稿[2]を参考に、少し改変したものです。
このようにヘルパーメソッド内でIssue.record
を呼び出すようにすれば、Swift Testingでも失敗すべきテストケースもテストが失敗し、正しくエラーメッセージが表示されます 👍
Discussion