📝

Swift TestingでSwift Macrosのテストを書くために

2025/02/13に公開

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で失敗するケースを書いても失敗しないのです😂

原因はSwiftSyntaxMacrosTestSupportassertMacroExpansionが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でも失敗すべきテストケースもテストが失敗し、正しくエラーメッセージが表示されます 👍

脚注
  1. https://forums.swift.org/t/swift-testing-support-for-macros/72720 ↩︎

  2. https://forums.swift.org/t/can-swift-macro-be-tested-with-swift-testing/75109 ↩︎

Discussion