Today Swift 5.9 is officially released, bringing macros to the language. Macros are a powerful feature that allow you to implement functionality in the language as if it was built directly into the language. However, they can be tricky to get right, and as such one needs to write an extensive test suite to make sure you have covered all of the subtle and nuanced edge cases that are possible.
Today we are excited to announce MacroTesting, a brand new tool for testing macros in Swift that is simple to use and powerful. It allows you to assert on every aspect of your macros, including expanded source, diagnostics, fix-its, and more.
Join us for a quick overview of the library, or watch this weekβs free episode to see what our library has to offer and how it greatly improves upon the tools Apple provides.
After adding MacroTesting to your project and importing it into your test file, there is one primary tool for testing: assertMacro
. This function is similar to the assertMacroExpansion
function that comes with SwiftSyntax, but our function does not require you to specify the source string that the macro expands to.
For example, suppose you had an @AddCompletionHandler
macro that can be applied to any async
method in order to generate an equivalent callback-based method. To test this we merely have to specify the input source string that we want to expand:
func testAddAsyncCompletionHandler() {
assertMacro {
"""
struct MyStruct {
@AddCompletionHandler
func f(a: Int) async -> String {
return b
}
"""
}
}
Just that little bit of code is already compiling with our library. But, the first time you run this test, the macro will be automatically expanded and inserted into the test for you:
func testAddAsyncCompletionHandler() {
assertMacro {
"""
struct MyStruct {
@AddCompletionHandler
func f(a: Int) async -> String {
return b
}
}
"""
} matches: {
"""
struct MyStruct {
func f(a: Int) async -> String {
return b
}
func f(a: Int, completionHandler: @escaping (String) -> Void) {
Task {
completionHandler(await f(a: a))
}
}
}
"""
}
}
You can then visually inspect the expanded source string in order to make sure it is correct.
This is pretty amazing, but static code snippets do not do it justice. Here is a GIF of what this looks like when you run the test in Xcode:
This is a remarkable improvement over the assertMacroExpansion
tool that SwiftSyntax gives us by default, which essentially requires us to run the test, get a test failure to see what the expanded source is, and then copy-and-paste that string back into our test file. That can be laborious and error prone.
But our assertMacro
goes even further for testing macros. It also renders diagnostics the macro emits directly into the source string so that it is crystal clear what line, column and even highlight range an error or warning is pointing to.
For example, the @AddCompletionHandler
macro can only be applied to functions. So, if we wanted to write a test to see what happens when it is erroneously applied to something else, say a struct, we can simply do the following:
func testNonFunctionDiagnostic() {
assertMacro {
"""
@AddCompletionHandler
struct Foo {}
"""
} matches: {
"""
@AddCompletionHandler
β¬ββββββββββββββββββββ
β°β π @addCompletionHandler only works on functions
struct Foo {}
"""
}
}
This helpfully shows that the macro will emit a diagnostic, in particular an error, and it will show the exact line, column and highlight range the error took place.
This is in stark contrast with Appleβs assertMacroExpansion
method, which only allows asserting against diagnostics by describing the numeric line and column number, which can be quite difficult to visualize exactly where the diagnostic points to in the source string.
But our assertMacro
goes even further for testing macros. Not only can macros emit diagnostics when being processed, but they can also emit βfix-itsβ, which allow you to provide quick actions to the user of your macro to fix the problem in their code.
For example, the @AddCompletionHandler
macro can only be added to functions that are marked as async
, and using it on a non-async
function is an error:
@AddCompletionHandler
func f(a: Int) -> String {
return b
}
can only add a completion-handler variant to an βasyncβ function
But the macro helpfully provides a βfix-itβ that allows the user to automatically add async
to their function with a single click in Xcode. Our assertMacro
helper allows us to test fix-its by expanding their definition directly inline where they can be applied:
assertMacro {
"""
@AddCompletionHandler
func f(a: Int) -> String {
return b
}
"""
} matches: {
"""
@AddCompletionHandler
func f(a: Int) -> String {
β°β π can only add a completion-handler variant to an 'async' function
βοΈ add 'async'
return b
}
"""
}
This very clearly shows that when the non-async
diagnostic is emitted it will come with an βadd βasyncββ diagnostic.
But we can also test how the fix-it is applied. Simply pass applyFixIts: true
to the assertMacro
function and all fix-its will be automatically applied in the expanded source:
assertMacro(applyFixIts: true) {
"""
@AddCompletionHandler
func f(a: Int) -> String {
return b
}
"""
} matches: {
"""
@AddCompletionHandler
func f(a: Int) async -> String {
return b
}
"""
}
This clearly shows that when the βadd βasyncββ fix-it is applied it inserts the async
keyword after the arguments of the function. This is absolutely amazing. This makes it possible for you to really see what the final, expanded source code looks like so that you can be sure you are generating valid code for your users.
This is only scratching the surface of what our MacroTesting is capable of. It is an essential tool for testing your macros and making sure you are providing the best experience to your users. Consider adding it to your project today!