Article
Swift Macro Expansion Testing
August 13, 2025

This is the second article in the Swift Macro Testing series. The purpose of the series is to explore a useful triad of testing approaches we use at Livefront when building Swift macros.
Over the course of this series, we’ll follow a test-driven approach to develop a custom Swift macro from scratch. Before we dive in, let’s make sure that everyone understands what macros are.
About Swift Macros
Macros are a form of metaprogramming , allowing code to generate other code. Swift macros excel at making code more expressive and removing boilerplate.
Here are some fundamentals to understand about Swift macros:
- Swift macros are evaluated at compile time, unlike normal code that is compiled and then evaluated at runtime.
- The code that a Swift macro generates is called its expansion.
- Swift macro expansions are compiled alongside the normal source code, and the combined result is evaluated at runtime.
- Swift macros are additive, meaning they add code to your program without modifying existing code directly.
- Swift macros have access to a tree representation of any Swift source code they’re attached to, and can also take parameters.
Swift macros come in two flavors, each with several types:
Freestanding Macros
- #expression macros generate code that can be used as an expression, replacing the macro invocation with the generated code.
- #declaration macros create new declarations, such as functions or types, at the location where the macro is used.
Attached Macros
- @member macros add new members to the declaration they’re attached to, such as properties or methods.
- @memberAttribute macros apply attributes to existing members of the declaration to which they’re attached.
- @extension macros add new conformances or nested types to the declaration to which they’re attached.
- @peer macros generate new declarations adjacent to the one they’re attached to, creating “peer” declarations.
- @accessor macros generate custom accessor methods for properties.
History
Swift macros arrived in Swift 5.9, and despite some ongoing challenges , they’ve seen widespread adoption. Apple uses native macros extensively in SwiftUI and Swift Testing , and third-party libraries like Pointfree’s Composable Architecture have created and leveraged custom macros to streamline their APIs.
Introducing the @Memoized Macro
This article explores expansion testing, the first of a useful triad of testing approaches that we use at Livefront when developing Swift macros. Expansion testing simply means verifying that a macro’s expansion matches an expected string literal.
Exploring expansion testing requires a test subject, so we need an idea for a Swift macro. As luck would have it, we already came up with one in the first article in this series, “ Exploring Memoization in Swift .” In that article’s conclusion, we imagined a Memoized macro:
struct Sample {
@Memoized
func fibonacci(_ n: Int) -> Int {
if n < 0 {
0
} else if n < 2 {
n
} else {
memoizedFibonacci(n - 1) + memoizedFibonacci(n - 2)
}
}
}
Sample().fibonacci(42) // 267,914,296
The Memoized macro’s job is to memoize a pure function to which it’s attached. Anyone wishing to know more about this optimization technique should read “ Exploring Memoization in Swift .” In that article, we began with a prohibitively slow recursive Fibonacci function and developed a memoization solution that radically improved its performance.
In this article, we’ll use that memoization solution as the basis for a new Memoized macro. Following a test-driven development approach, we’ll begin by using the solution’s source code as the expected output in a test. From there, we’ll build the macro to fulfill that expectation.
Preparing the Macro Package
Let’s create a macro package. We’ll start with Xcode’s Swift Macro template, following these steps:

File > New > Package…

Multiplatform > Swift Macro > Next

Next

Memoized
What’s in the Package
- Memoized: A library that exposes a macro as part of its API, which is used in client programs.
- MemoizedClient: A client of the library, which is able to use the macro in its own code.
- MemoizedMacros: The macro implementation that performs the source transformation.
- MemoizedTests: A test target used to develop the macro implementation.
The macro template provides a sensible structure to get started. The targets contain a sample macro implementation — Apple’s #stringify
example — which we’ll replace with our own code.
Starting with the Memoized target, replace the #stringify
declaration with a Memoized
macro declaration. This will be an attached peer macro, meaning it will generate new declarations alongside the function that we memoize.
// Sources/Memoized/Memoized.swift
@attached(peer, names: arbitrary)
public macro Memoized() = #externalMacro(
module: "MemoizedMacros",
type: "MemoizedMacro"
)
Moving on to the MemoizedClient target, we can remove everything but the import statement for now:
// Sources/MemoizedClient/main.swift
import Memoized
In the MemoizedMacros target, replace Apple’s StringifyMacro
sample with a bare MemoizedMacro
definition. Each macro type has its own expansion
function, and below is the one necessary for PeerMacro
conformance. For now we’ll return an empty array, which results in an empty expansion.
// Sources/MemoizedMacros/MemoizedMacro.swift
import SwiftDiagnostics
import SwiftSyntax
import SwiftSyntaxMacros
public struct MemoizedMacro: PeerMacro {
public static func expansion(
of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
return []
}
}
The template places the compiler plugin in the same file, but for better organization, let’s move MemoizedPlugin
into its own file:
// Sources/MemoizedMacros/MemoizedPlugin.swift
import SwiftCompilerPlugin
import SwiftSyntaxMacros
@main
struct MemoizedPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
MemoizedMacro.self,
]
}
Now we can turn to the MemoizedTests target, where we’ll articulate our vision for the Memoized macro.
Expansion Testing: assertMacroExpansion
In the MemoizedTests target, let’s clear out the sample tests and add an initial expansion test using assertMacroExpansion
. This is one of the nice things about developing Swift macros: they lend themselves well to a test-driven approach, and assertMacroExpansion
provides a helpful starting point.
While we’re at it, you may have noticed a lack of testing framework choices when using Xcode’s Swift Macro template. At the time of writing, XCTest is the only option it provides. But in an article about macros and testing, the Swift Testing framework is a more appropriate choice:
// Tests/MemoizedTests/MemoizedTests.swift
import MemoizedMacros
import SwiftSyntaxMacros
import Testing
let testMacros: [String: Macro.Type] = [
"Memoized": MemoizedMacro.self,
]
struct MemoizedTests {
@Test("Memoized Fibonacci function")
func memoizedFibonacciFunction() {
assertMacroExpansion(
"@Memoized private func _fibonacci(_ n: Int) -> Int {…}",
expandedSource: "…",
macros: testMacros
)
}
}
Unfortunately, the switch to Swift Testing results in a 🛑 Cannot find ‘assertMacroExpansion’ in scope
error, since this test helper only works with XCTest at the time of writing. While we could revert to XCTest, let’s explore a better option.
Expansion Testing:
assertMacro
The assertMacro
tool in Pointfree’s Macro Testing library makes expansion testing incredibly easy, and it works with Swift Testing. It’s a substantial quality-of-life improvement that simplifies the test code, so we’ll use it here.
For those sticking with XCTest and assertMacroExpansion
, everything works similarly but with more boilerplate. For everyone else, here are the package updates to install the Macro Testing library:
let package = Package(
name: "Memoized",
// ...
dependencies: [
.package(url: "https://github.com/pointfreeco/swift-macro-testing", from: "0.6.0"),
// ...
],
targets: [
// ...
.testTarget(
name: "MemoizedTests",
dependencies: [
// ...
.product(name: "MacroTesting", package: "swift-macro-testing"),
]
),
// ...
]
)
Now we need the following replacements in MemoizedTests.swift
:
- Replace the
import SwiftSyntaxMacros
withimport MacroTesting
. - Remove the
testMacros
declaration and add aSuite
attribute onMemoizedTests
. The suite contains a trait declaring the macro(s) we intend to test. - Replace
assertMacroExpansion
withassertMacro
.
Finally, copy the fibonacci
function declaration that we imagined above and drop it into the assertMacro
closure as a string.
// Tests/MemoizedTests/MemoizedTests.swift
import MacroTesting
import MemoizedMacros
import Testing
@Suite(.macros(
macros: ["Memoized": MemoizedMacro.self]
))
struct MemoizedTests {
@Test("Memoized Fibonacci function")
func memoizedFibonacciFunction() {
assertMacro {
#"""
@Memoized
func fibonacci(_ n: Int) -> Int {
if n < 0 {
0
} else if n < 2 {
n
} else {
memoizedFibonacci(n - 1) + memoizedFibonacci(n - 2)
}
}
"""#
}
}
}
The test fails when you run it for the first time, but you’ll see an expansion
closure appear in the code automatically. The closure contains a string literal with the current macro expansion. Run the test again and it passes:
// Tests/MemoizedTests/MemoizedTests.swift
@Test("Memoized Fibonacci function")
func memoizedFibonacciFunction() {
assertMacro {
#"""
@Memoized
func fibonacci(_ n: Int) -> Int {
if n < 0 {
0
} else if n < 2 {
n
} else {
memoizedFibonacci(n - 1) + memoizedFibonacci(n - 2)
}
}
"""#
} expansion: {
"""
func fibonacci(_ n: Int) -> Int {
if n < 0 {
0
} else if n < 2 {
n
} else {
memoizedFibonacci(n - 1) + memoizedFibonacci(n - 2)
}
}
"""
}
}
Defining the Macro Expansion
So far the expansion doesn’t include any additional code because we haven’t given the macro anything to do yet. Before implementing the functionality, let’s specify what we want the macro expansion to look like when we’re finished.
In the “ Exploring Memoization in Swift ” article, we wrote a memoization implementation for the fibonacci function. Let’s grab the final source code from that article and drop it into the test’s expansion closure:
@Test("Memoized Fibonacci function")
func memoizedFibonacciFunction() {
assertMacro {
#"""
@Memoized
func fibonacci(_ n: Int) -> Int {
if n < 0 {
0
} else if n < 2 {
n
} else {
memoizedFibonacci(n - 1) + memoizedFibonacci(n - 2)
}
}
"""#
} expansion: {
"""
func fibonacci(_ n: Int) -> Int {
if n < 0 {
0
} else if n < 2 {
n
} else {
memoizedFibonacci(n - 1) + memoizedFibonacci(n - 2)
}
}
private var _fibonacciCache = [Int: Int]()
func memoizedFibonacci(_ n: Int) -> Int {
if let cached = _fibonacciCache[n] {
return cached
}
let result = fibonacci(n)
_fibonacciCache[n] = result
return result
}
"""
}
}
This is the essence of expansion testing: the first closure contains an input, the expansion
closure contains the expected output. Running the test again outputs the difference between the actual and expected output, telling us what we need to implement:
Suite MemoizedTests started.
Test "Memoized Fibonacci function" started.
Test "Memoized Fibonacci function" recorded an issue at MemoizedTests.swift:24:21: Issue recorded
Expanded output (+) differed from expected output (−). Difference: …
func fibonacci(_ n: Int) -> Int {
if n < 0 {
0
} else if n < 2 {
n
} else {
memoizedFibonacci(n - 1) + memoizedFibonacci(n - 2)
}
}
-
- private var _fibonacciCache = [Int: Int]()
-
- func memoizedFibonacci(_ n: Int) -> Int {
- if let cached = _fibonacciCache[n] {
- return cached
- }
- let result = fibonacci(n)
- _fibonacciCache[n] = result
- return result
- }
Test "Memoized Fibonacci function" failed after 0.386 seconds with 1 issue.
Defining the Cache Variable
Let’s return to the MemoizedMacros target and start implementing the missing expansion elements. We’ll begin with the _fibonacciCache
variable declaration.
The test expects a variable called _fibonacciCache
, but this risks causing name collisions. Fortunately, MacroExpansionContext
provides a makeUniqueName
function that plays nicely with tests, so let’s use that.
We’ll create a string literal representation of the source code we want added in the macro expansion, and then return that as a DeclSyntax
node:
public struct MemoizedMacro: PeerMacro {
public static func expansion(
of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
let cacheVarName = context.makeUniqueName("Cache")
let memoCacheDeclaration = "private var \(cacheVarName) = [Int: Int]()"
return [
DeclSyntax(stringLiteral: memoCacheDeclaration),
]
}
}
Running tests again after these changes reveals that context.makeUniqueName(“Cache”)
resolved to __macro_local_5CachefMu_
. This is a guaranteed unique name, and importantly, not a random name that would make our tests brittle:
@Test("Memoized Fibonacci function")
func memoizedFibonacciFunction() {
assertMacro {
// ...
} expansion: {
"""
func fibonacci(_ n: Int) -> Int {
if n < 0 {
0
} else if n < 2 {
n
} else {
memoizedFibonacci(n - 1) + memoizedFibonacci(n - 2)
}
}
private var __macro_local_5CachefMu_ = [Int: Int]()
func memoizedFibonacci(_ n: Int) -> Int {
if let cached = __macro_local_5CachefMu_[n] {
return cached
}
let result = fibonacci(n)
__macro_local_5CachefMu_[n] = result
return result
}
"""
}
}
Running the tests again shows our progress:
Expanded output (+) differed from expected output (−). Difference: …
func fibonacci(_ n: Int) -> Int {
if n < 0 {
0
} else if n < 2 {
n
} else {
memoizedFibonacci(n - 1) + memoizedFibonacci(n - 2)
}
}
private var __macro_local_5CachefMu_ = [Int: Int]()
−
− func memoizedFibonacci(_ n: Int) -> Int {
− if let cached = __macro_local_5CachefMu_[n] {
− return cached
− }
− let result = fibonacci(n)
− __macro_local_5CachefMu_[n] = result
− return result
− }
A Word about Swift Syntax
Before continuing, a quick note on Swift Syntax might be helpful. Working with Swift Syntax in depth is beyond the scope of this article, but it’s what macro expansion functions use to analyze and generate code. According to its documentation: “The swift-syntax package is a set of libraries that work on a source-accurate tree representation of Swift source code, called the SwiftSyntax tree.”
At the time of writing, the documentation is somewhat sparse, so you’ll need to rely on IDE features like Jump to Definition, tools like the Swift AST Explorer , and AI assistants to navigate its complexities.
Defining the Memoized Function
Moving on to the memoizedFibonacci
definition, let’s examine what we need. Generating func memoizedFibonacci(_ n: Int) -> Int
will require the function name, parameters, and return type of the original fibonacci
function. Once we have these, we’ll create something like:
func memoized\(functionName)(\(parameters)) -> \(returnType) {…}
The expansion
function receives a SwiftSyntax tree representation of the element to which we attached the macro — in this case, the fibonacci
function. It arrives in the declaration
parameter, so that’s where we’ll look for the elements we need.
Here’s our initial approach to generating the memoized function declaration:
public static func expansion(
of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard let functionDeclaration = declaration.as(FunctionDeclSyntax.self) else {
fatalError("Throw some error: functionDeclaration failed")
}
guard let functionReturnClause = functionDeclaration.signature.returnClause else {
fatalError("Throw some error: functionReturnClause failed")
}
let cacheVarName = context.makeUniqueName("Cache")
let functionName = functionDeclaration.name.text
let parameters = functionDeclaration.signature.parameterClause.parameters
let returnType = functionReturnClause.type.trimmed.description
let memoCacheDeclaration = "private var \(cacheVarName) = [Int: Int]()"
let memoFunctionDeclaration = """
func memoized\(functionName)(\(parameters)) -> \(returnType) {
if let cached = \(cacheVarName).storage[key] {
return cached
}
let result = \(functionName)(\(parameters)))
\(cacheVarName).storage[key] = result
return result
}
"""
return [
DeclSyntax(stringLiteral: memoCacheDeclaration),
DeclSyntax(stringLiteral: memoFunctionDeclaration),
]
}
The first guard
statement attempts to cast declaration
as a FunctionDeclSyntax
element. The SwiftSyntax tree representation is flexible like the language itself, and it’s common to receive a node with only a general type. Before we can work with such a node effectively, we need to cast it to a more specific type.
Once we have a strongly typed functionDeclaration
, finding everything else is relatively straightforward. In most cases, we can use Xcode’s code completion to navigate to the appropriate nodes.
Running tests again reveals significant progress:
Test "Memoized Fibonacci function" recorded an issue at MemoizedTests.swift:24:21: Issue recorded
Expanded output (+) differed from expected output (−). Difference: …
func fibonacci(_ n: Int) -> Int {
if n < 0 {
0
} else if n < 2 {
n
} else {
memoizedFibonacci(n - 1) + memoizedFibonacci(n - 2)
}
}
private var __macro_local_5CachefMu_ = [Int: Int]()
− func memoizedFibonacci(_ n: Int) -> Int {
+ func memoizedfibonacci(_ n: Int) -> Int {
if let cached = __macro_local_5CachefMu_[n] {
return cached
}
− let result = fibonacci(n)
+ let result = fibonacci(_ n: Int))
__macro_local_5CachefMu_[n] = result
return result
}
Test "Memoized Fibonacci function" failed after 0.410 seconds with 1 issue.
There are two issues we need to fix:
- We need to properly camel-case the memoized function name.
- When calling the
fibonacci
function, we’re using the function signature parameters instead of function invocation arguments. While this was correct in the memoizedFibonacci
declaration, we need to translate these into the appropriate format for the function call.
We’ll address these issues by creating two helper functions:
-
memoFunctionName(for:)
to compute the camel-cased memoized function name. -
invocationArguments(for:)
to translate parameters into invocation arguments.
The resulting memoFunctionDeclaration would look like this:
let memoFunctionName = memoFunctionName(for: functionName)
let invocationArguments = invocationArguments(for: parameters)
let memoFunctionDeclaration = """
func \(memoFunctionName)(\(parameters)) -> \(returnType) {
let key = "\(keyExpression(for: parameters))"
if let cached = \(cacheVarName).storage[key] {
return cached
}
let result = \(functionName)(\(invocationArguments))
\(cacheVarName).storage[key] = result
return result
}
"""
The memoFunctionName(for:)
function should be straightforward, but the invocationArguments(for:)
function will need to handle various edge cases. We’ll want comprehensive unit tests to ensure it handles all situations correctly.
We’ll explore these functions and their unit tests in “ Swift Macro Unit Testing ,” the next article in this series. But before we conclude, let’s look at another important feature that expansion testing offers.
Macro Diagnostics
So far, we’ve focused on the happy path: applying the Memoized macro to a pure function. Now let’s consider situations where things might go wrong. What if, for example, a developer tries to memoize a struct?
@Memoized struct Foo {} // Memoized only applies to pure functions!
Since macros are evaluated at compile time, runtime responses like alerts aren’t applicable. Instead, we need to consider how to present errors at compile time in Xcode.

Compiler errors appear inline in the source code, highlighting the problematic line with an error message describing what went wrong. You can trigger such an error from your macro by throwing a DiagnosticsError
.
Let’s replace the fatalError
placeholders in our expansion function’s guard statements with proper diagnostic errors:
// Sources/MemoizedMacros/MemoizedMacro.swift
public struct MemoizedMacro: PeerMacro {
public static func expansion(
of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard let functionDeclaration = declaration.as(FunctionDeclSyntax.self) else {
throw functionsOnlyError(on: node)
}
guard let functionReturnClause = functionDeclaration.signature.returnClause else {
throw requiresReturnTypeError(on: functionDeclaration.signature)
}
// ...
}
}
extension MemoizedMacro {
static func functionsOnlyError(on node: AttributeSyntax) -> DiagnosticsError {
DiagnosticsError(
diagnostics: [
Diagnostic(
node: Syntax(node),
message: MemoizedError.functionsOnly
)
]
)
}
static func requiresReturnTypeError(on signature: FunctionSignatureSyntax) -> DiagnosticsError {
DiagnosticsError(
diagnostics: [
Diagnostic(
node: Syntax(signature),
message: MemoizedError.requiresReturnType
)
]
)
}
}
enum MemoizedError: DiagnosticMessage {
case functionsOnly
case requiresReturnType
var message: String {
switch self {
case .functionsOnly: "Memoized can only be applied to functions"
case .requiresReturnType: "Memoized requires a declared return type"
}
}
var diagnosticID: SwiftDiagnostics.MessageID {
.init(domain: "MemoizedMacro", id: "\(self)")
}
var severity: SwiftDiagnostics.DiagnosticSeverity {
.error
}
}
Now let’s test out our first DiagnosticsError
:
// Tests/MemoizedTests/MemoizedTests.swift
@Test("Memoized Foo struct")
func memoizedFooStruct() {
assertMacro {
"@Memoized struct Foo {}"
}
}
When we run this test, instead of seeing an expansion
closure as before, we get a diagnostics
closure showing how an inline error will appear in Xcode:
// Tests/MemoizedTests/MemoizedTests.swift
@Test("Memoized Foo struct")
func memoizedFooStruct() {
assertMacro {
"@Memoized struct Foo {}"
} diagnostics: {
"""
@Memoized struct Foo {}
┬────────
╰─ 🛑 Memoized can only be applied to functions
"""
}
}
Fix-it Diagnostics
Swift macros can also emit “fix-it” diagnostics that suggest corrections to the source code. A common example of this is when writing a switch
statement, where Xcode offers to add the missing enum cases.

While there isn’t a helpful fix to suggest for applying the Memoized macro to a struct, we’ve at least provided a clear error message explaining the issue.

Swift Macro Unit Testing
This concludes our exploration of Swift Macro Expansion Testing, where we’ve examined Swift macros from the ground up and followed a test-driven approach to develop a custom Memoized macro.
We’ve focused on expansion testing, which verifies the string output generated by our macro. We’ve made significant progress, but we’ve reached a point where it makes sense to add more granular unit tests for the individual components of our macro.
The Swift Macro Testing series continues in “ Swift Macro Unit Testing ,” where we’ll refine our Memoized macro. We’ll continue following a test-driven approach while focusing on unit testing specific helper functions. This will demonstrate how unit tests complement the expansion tests we’ve explored in this article, providing a comprehensive testing strategy for macro development.