A blog exploring advanced programming topics in Swift.

A new tool for testing macros in Swift

Monday Sep 18, 2023

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.

Using MacroTesting

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(["AddCompletionHandler": AddCompletionHandlerMacro.self]) {
    """
    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.

Testing diagnostics

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.

Testing fix-its

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 {  // 🛑 can only add a completion-handler variant to an 'async' function
  return b
}

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.

Get started today

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!


Subscribe to Point-Free

👋 Hey there! If you got this far, then you must have enjoyed this post. You may want to also check out Point-Free, a video series covering advanced programming topics in Swift. Consider subscribing today!