4
3

[Swift] swift-macro-testingでマクロのテストをより効率的に

Last updated at Posted at 2023-10-05

先月、pointfreeから新たなライブラリである, swift-macro-testingが出ました。このライブラリを使ってみたところ、かなり便利だったので、今回は従来のマクロのテストコードで起こっていた不便な点と、swift-macro-testingを使用することでどういった点が便利になるのかなどを紹介していきたいと思います。

また、今回は@PublicInitマクロを例にしていきたいと思います。
@PublicInitは私のライブラリであるswift-dependencies-macroの中に入っているので、よかったらコードを見てみてください。

従来のマクロのテスト

まずは従来のマクロのテストフレームワークであるSwiftSyntaxMacrosTestSupportを使用して,マクロのテストを書いてみます。

let macros: [String: Macro.Type] = [
    "PublicInit": PublicInitMacro.self
]

func testPublicInit() {
    assertMacroExpansion(
        """
        @PublicInit
        public struct Test {
            let a: String
            let b: () -> Void
            let c: @Sendable () -> Void
        }
        """,
        expandedSource:
        """

        public struct Test {
            let a: String
            let b: () -> Void
            let c: @Sendable () -> Void

            public init(
                a: String,
                b: @escaping () -> Void,
                c: @Sendable @escaping () -> Void
            ) {
                self.a = a
                self.b = b
                self.c = c
            }
        }
        """,
        macros: macros
    )
}

上記のコードでビルドするとしっかりとテストが通ります.

Diagnosticのテスト

では、Diagnosticのテストはどうでしょうか。コードを書いてみます。

assertMacroExpansion(
    """
    @PublicInit
    public class Test {
    }
    """,
    expandedSource:
    """
    public class Test {
    }
    """,
    diagnostics: [
        DiagnosticSpec(
            message: "PublicInit Macro can only be applied to struct.",
            line: 2,
            column: 8
        )
    ],
    macros: macros
)

DiagnosticのテストをするにはDiagnosticSpecを使用します。DiagnosticSpecのinitializerには、エラーメッセージ, エラーが出るであろう該当箇所の行数とカラムを指定できます。
今回のコードでは、このように2行目の左から8個目の箇所でエラーが発生していることがわかります。
スクリーンショット 2023-10-05 20.32.27.png
なので、lineには2, columnには8を指定します。
そして、このコードを実行するとテストが通ります。

FixItのテスト

次にFixItのテストを書いてみましょう。
FixItとは, エラーメッセージに付属してあるFixオプションのことで、Syntaxの修正をすることができるものです。
スクリーンショット 2023-10-05 20.35.28.png

こちらをテストするには以下のようなコードを書きます。

assertMacroExpansion(
    """
    @PublicInit
    public struct Test {
        let test = ""
    }
    """,
    expandedSource:
    """
    public struct Test {
        let test = ""

        public init(

        ) {

        }
    }
    """,
    diagnostics: [
        DiagnosticSpec(
            message: "PublicInit Macro required stored properties provide explicit typed annotations.",
            line: 3,
            column: 9,
            fixIts: [
                FixItSpec(
                    message: "Insert type annotation."
                )
            ]
        )
    ],
    macros: macros
)

Diagnosticのテストと同じく, DiagnosticSpecを使用してテストを書きます。
しかし、FixItSpecはFixItを挿入後の構文のテストをすることができず、現在はメッセージのテストのみのサポートのようです。ただし、公式のドキュメントの方には将来的にサポートする予定と書いてあったので、今後のサポートに期待しましょう。

不便な点

従来のテストAPIを使用していくと、いくつか不便な点に直面します。
1つ目は、先ほど述べたとおりFixItを挿入後の構文のテストをすることができないと言う点です。

2つ目は、絶望的にDiagnosticのテストがわかりずらいと言う点です。lineとcolumnをみて該当の行数まで数えて, さらに左から○番目まで数えて, エラーを出したい箇所まで辿り着かないといけません。非常にめんどくさいですし、視覚的にわかりずらいです。

そして3つ目は、マクロに変更があった際にテストコードの変更を毎回しないといけないと言う点です。

仮に何個もテストコードを書いていた際のことを考えます。
@PublicInitマクロでは、struct以外の宣言にはマクロを適用することができません。
なので、以下のテストコードがclass以外にもactor, enum, extension, protocolと5つ必要です。

assertMacroExpansion(
    """
    @PublicInit
    public class Test {
    }
    """,
    expandedSource:
    """
    public class Test {
    }
    """,
    diagnostics: [
        DiagnosticSpec(
            message: "PublicInit Macro can only be applied to struct.",
            line: 2,
            column: 8
        )
    ],
    macros: macros
)

そして、Diagnosticのメッセージを、「PublicInit Macro can only be applied to struct.」ではなく、
@PublicInit can only be applied to struct.
に変更したいとなったら、5つのテストコードすべての変更を手作業でしなければならなく、非常に面倒です。

swift-macro-testingでは以上の3つの不便な点を解決してくれます。

swift-macro-testingを使用したテスト

swift-macro-testingではなんと!!expandedSourceを自動で生成してくれます!!
まずは以下のように書きます。

final class PublicInitMacroTests: XCTestCase {
    override func invokeTest() {
        withMacroTesting(
            macros: ["PublicInit": PublicInitMacro.self]
        ) {
            super.invokeTest()
        }
    }

    func testMacro() {
        assertMacro {
            """
            @PublicInit
            public struct Test {
                let a: String
                let b: () -> Void
                let c: @Sendable () -> Void
            }
            """
        }
    }
}

そしてテストを実行してみると、

assertMacro {
    """
    @PublicInit
    public struct Test {
        let a: String
        let b: () -> Void
        let c: @Sendable () -> Void
    }
    """
} matches: {
    """
    public struct Test {
        let a: String
        let b: () -> Void
        let c: @Sendable () -> Void

        public init(
            a: String,
            b: @escaping () -> Void,
            c: @Sendable @escaping () -> Void
        ) {
            self.a = a
            self.b = b
            self.c = c
        }
    }
    """
}

なんと!matches:以下がテスト実行後に自動で追加されます。 便利すぎますね。
そして、マクロに変更が加わった際は、テストケース一つ一つmatchesのクロージャを消して再実行するのではなく、
recordモードをtrueにすることで、matchesが書きかわります。

final class PublicInitMacroTests: XCTestCase {
    override func invokeTest() {
        withMacroTesting(
            isRecording: true,
            macros: ["PublicInit": PublicInitMacro.self]
        ) {
            super.invokeTest()
        }
    }
    ...
}

これで、破壊的変更があった際にテストケースを一つ一つ直す作業がなくなりますね!!

Diagnosticのテスト

Diagnosticのテストも同じです。まずこのようなコードを書きます。

assertMacro {
    """
    @PublicInit
    public final class Test {}
    """
}

これが、テスト実行後...

assertMacro {
    """
    @PublicInit
    public final class Test {}
    """
} matches: {
    """
    @PublicInit
    public final class Test {}
                 ┬────
                 ╰─ 🛑 PublicInit Macro can only be applied to struct.
    """
}

自動生成されます。
このように、どのnodeにエラーが出ているのかがより視覚的にわかりやすくなっていて良いですね!

FixItのテスト

FixItのテストは、applyFixItsをtrueにすることで有効になります。

assertMacro(applyFixIts: true) {
    """
    @PublicInit
    public struct Test {
        var a = true
    }
    """
} 

これがテスト実行後に、

assertMacro(applyFixIts: true) {
    """
    @PublicInit
    public struct Test {
        var a = true
    }
    """
} matches: {
    """
    @PublicInit
    public struct Test {
        var a : <#Type#> = true
    }
    """
}

このように自動生成されます。
従来のFixItのテストではFixItを挿入後の構文はテストできなかったので、非常に画期的ですね!!

おわりに

従来のテスト方法ではいろいろと不便な点がありましたが、swift-macro-testingを使用することで、
より視覚的で、快適なテストライフを送れそうですね!

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3