Article
Refining the Macro Expansion
August 13, 2025

Swift Macro Unit Testing
This is the third 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’re following a test-driven approach as we develop a Memoized Swift macro from scratch. In the previous article, “ Swift Macro Expansion Testing ,” we focused on “expansion” testing, using Pointfree’s assertMacro
tool to verify macro expansions and diagnostics. In this article, we’ll shift our focus to unit testing as we fine-tune our macro’s expansion.
At the end of the last article, the Memoized macro was very close to working, with only two issues left in the “happy path” expansion test:
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.
The test output provided a clear picture of what we need to fix, and we proposed the following helper functions:
memoFunctionName(for:)
to properly camel-case the memoized function name.invocationArguments(for:)
to translate function signature parameters into invocation arguments.
When these functions are ready — sometime before the end of this article — we’ll integrate them into our macro’s expansion function, giving us something like this:
// 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] {
guard let functionDeclaration = declaration.as(FunctionDeclSyntax.self) else {
throw functionsOnlyError(on: node)
}
guard let functionReturnClause = functionDeclaration.signature.returnClause else {
throw requiresReturnTypeError(on: functionDeclaration.signature)
}
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 memoFunctionName = memoFunctionName(for: functionName)
let invocationArguments = invocationArguments(for: parameters)
let memoFunctionDeclaration = """
func \(memoFunctionName)(\(parameters)) -> \(returnType) {
if let cached = \(cacheVarName)[n] {
return cached
}
let result = \(functionName)(\(invocationArguments)))
\(cacheVarName)[n] = result
return result
}
"""
return [
DeclSyntax(stringLiteral: memoCacheDeclaration),
DeclSyntax(stringLiteral: memoFunctionDeclaration),
]
}
}
The memoFunctionName(for:) Function
Deriving the camel-cased memoized function name looks straightforward, so let’s start with that. Continuing with our test-driven approach, let’s begin with a unit test. We’ll create a new file specifically for our helper function unit tests:
// Tests/MemoizedTests/MemoizedMacroHelperTests.swift
import Testing
@testable import MemoizedMacros
struct MemoizedMacroHelperTests {
@Test func memoFunctionName() {
let result = MemoizedMacro.memoFunctionName(for: "fibonacci")
#expect(result == "memoizedFibonacci")
}
}
Right on cue, the compiler complains that 🛑 Type ‘MemoizedMacro’ has no member ‘memoFunctionName’
, so let’s add that function. In the previous article, we correctly interpolated "memoized\(functionName)"
, but hadn’t yet uppercased the first letter in the function name. Writing a function to do this is straightforward:
// Sources/MemoizedMacros/MemoizedMacro.swift
// MARK: - Helpers
extension MemoizedMacro {
/// Generates a memoized function name by prepending a memoization prefix.
///
/// - Parameter functionName: The original function name.
///
/// - Returns: A new function name for the memoized function variant.
static func memoFunctionName(for functionName: String) -> String {
"""
memoized\
\(functionName.prefix(1).uppercased())\
\(functionName.dropFirst())
"""
}
}
Now the memoFunctionName
test compiles and passes, and we’re officially off to a good start. However, you might be wondering what we’ve gained by adding this test. Aren’t we already expecting the memoized function name for fibonacci
to be memoizedFibonacci
in our expansion test?
If expansion tests can overlap with unit tests, what’s the benefit of having both? We saw the power and utility of expansion tests in the last article, so why not just use those for all our macro testing?
Granular testing
Unit tests are indispensable because they test our code in granular detail. They demonstrate how code behaves in numerous different situations and are perfect for testing edge cases.
How will memoFunctionName(for:)
respond to a function called évaluer
? How about a function called URL
, or _secret
, or f
, or π
? Write a unit test and find out. What about a function with no name at all? This probably couldn’t happen in practice, but what if it did? Would our macro crash, or would it handle the situation gracefully? Again, unit tests can help us answer these questions.
// Tests/MemoizedTests/MemoizedMacroHelperTests.swift
import Testing
@testable import MemoizedMacros
struct MemoizedMacroHelperTests {
// MARK: - memoFunctionName tests
@Test func memoFunctionName() {
let result = MemoizedMacro.memoFunctionName(for: "fibonacci")
#expect(result == "memoizedFibonacci")
}
@Test func alreadyCapitalizedName() {
let result = MemoizedMacro.memoFunctionName(for: "Calculate")
#expect(result == "memoizedCalculate")
}
@Test func singleCharacterName() {
let result = MemoizedMacro.memoFunctionName(for: "f")
#expect(result == "memoizedF")
}
@Test func emptyString() {
let result = MemoizedMacro.memoFunctionName(for: "")
#expect(result == "memoized")
}
@Test func nameWithNumbers() {
let result = MemoizedMacro.memoFunctionName(for: "solve2DPuzzle")
#expect(result == "memoizedSolve2DPuzzle")
}
@Test func nameWithSpecialCharacters() {
let result = MemoizedMacro.memoFunctionName(for: "calculate_sum")
#expect(result == "memoizedCalculate_sum")
}
@Test func camelCaseName() {
let result = MemoizedMacro.memoFunctionName(for: "findMaxValue")
#expect(result == "memoizedFindMaxValue")
}
@Test func allUppercaseName() {
let result = MemoizedMacro.memoFunctionName(for: "URL")
#expect(result == "memoizedURL")
}
@Test func nonAlphabeticFirstCharacter() {
let result = MemoizedMacro.memoFunctionName(for: "_secret")
#expect(result == "memoized_secret")
}
@Test func unicodeCharacters() {
let result = MemoizedMacro.memoFunctionName(for: "π")
#expect(result == "memoizedΠ")
}
@Test func nonEnglishCharacters() {
let result = MemoizedMacro.memoFunctionName(for: "évaluer")
#expect(result == "memoizedÉvaluer")
}
}
Early Warning System
Beyond revealing how code behaves in various situations, unit tests provide another significant benefit: they allow you to detect when you’ve introduced a bug, even one seemingly unrelated to what you’re currently working on.
Imagine a year from now, you revisit the memoFunctionName(for:)
function and decide it could be written more simply:
static func memoFunctionName(for name: String) -> String {
"memoized\(name.capitalized)"
}
With this small refactor, the expansion tests continue to pass, since fibonacci
still yields memoizedFibonacci
. The first few unit tests also continue to pass. However, several other tests fail, alerting you that you’ve introduced a breaking change. This early warning prevents potential issues in production code.
The invocationArguments
(for:) Function
Let’s move on to the invocationArguments(for:)
function. To quickly review, here’s what the function needs to fix:
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
}
We need to translate the function signature’s parameters (_ n: Int
) into invocation argument form — n
— so that we can call fibonacci(n)
. Just as before, let’s start with a test that expresses our goal:
// Tests/MemoizedTests/MemoizedMacroHelperTests.swift
struct MemoizedMacroHelperTests {
// MARK: - invocationArguments tests
@Test("invocationArguments for one unnamed parameter")
func invocationArgumentsOneUnnamedParameter() throws {
let result = MemoizedMacro.invocationArguments(
for: FunctionParameterListSyntax(["_ arg: Int"])
)
#expect(result == "arg")
}
}
We discovered a strongly typed parameters
representation earlier in our macro’s expansion
function, so we’ll use that type when calling the invocationArguments(for:)
function from our test. Now we need to fix the 🛑 Type ‘MemoizedMacro’ has no member ‘invocationArguments’
error by adding the function:
// Sources/MemoizedMacros/MemoizedMacro.swift
extension MemoizedMacro {
/// Creates a string representing the arguments to be passed when invoking a function.
///
/// - Parameter parameters: The list of parameters extracted from a function's source code.
///
/// - Returns: A string representing the invocation arguments for the function call.
static func invocationArguments(for parameters: FunctionParameterListSyntax) -> String {
if let parameter = parameters.first?.secondName?.text {
parameter
} else {
"💥"
}
}
}
While there’s plenty of room for improvement, this implementation is enough to get the latest unit test passing. It also lets us integrate memoFunctionName(for:)
and invocationArguments(for:)
into our macro’s expansion function, as we planned at the beginning of the article:
let memoFunctionName = memoFunctionName(for: functionName)
let invocationArguments = invocationArguments(for: parameters)
let memoFunctionDeclaration = """
func \(memoFunctionName)(\(parameters)) -> \(returnType) {
if let cached = \(cacheVarName)[n] {
return cached
}
let result = \(functionName)(\(invocationArguments))
\(cacheVarName)[n] = result
return result
}
"""
All Tests Passing
For the first time in the series, all tests are passing, including the “happy path” expansion test we wrote in the last article. While this is a notable milestone, it’s concerning that our simplistic invocationArguments(for:)
function doesn’t trigger any test failures.
Our implementation isn’t designed to work with multiple parameters, and less obviously, it only works with unnamed parameters. Let’s add more unit tests to demonstrate these shortcomings:
// Tests/MemoizedTests/MemoizedMacroHelperTests.swift
struct MemoizedMacroHelperTests {
// MARK: - invocationArguments tests
@Test func invocationArgumentsOneUnnamedParameter() throws {
let result = MemoizedMacro.invocationArguments(
for: FunctionParameterListSyntax(["_ arg: Int"])
)
#expect(result == "arg")
}
@Test func invocationArgumentsOneNamedParameter() throws {
let result = MemoizedMacro.invocationArguments(
for: FunctionParameterListSyntax(["arg1: String"])
)
#expect(result == "arg1: arg1")
}
@Test func invocationArgumentsTwoNamedParams() throws {
let result = MemoizedMacro.invocationArguments(
for: FunctionParameterListSyntax(
[
"arg1: String",
"arg2: Int",
]
)
)
#expect(result == "arg1: arg1, arg2: arg2")
}
@Test func invocationArgumentsTwoUnnamedParams() throws {
let result = MemoizedMacro.invocationArguments(
for: FunctionParameterListSyntax(
[
"_ arg1: String",
"_ arg2: Int",
]
)
)
#expect(result == "arg1, arg2")
}
@Test func invocationArgumentsMixOfNamedAndUnnamedParams() throws {
let result = MemoizedMacro.invocationArguments(
for: FunctionParameterListSyntax(
[
"_ arg1: String",
"arg2: Int",
]
)
)
#expect(result == "arg1, arg2: arg2")
}
@Test func invocationArgumentsWithDefaultValueParams() throws {
let result = MemoizedMacro.invocationArguments(
for: FunctionParameterListSyntax(
[
#"_ arg1: String = "n/a""#,
"arg2: Int = 0",
]
)
)
#expect(result == "arg1, arg2: arg2")
}
@Test func invocationArgumentsZeroParams() throws {
let result = MemoizedMacro.invocationArguments(
for: FunctionParameterListSyntax([])
)
#expect(result == "")
}
}
Running these tests yields a collection of failures to address. These unit tests are focused, with each highlighting a specific scenario that the invocationArguments(for:)
function must handle. Together, they ensure we cover all requirements. The resulting implementation handles any number of parameters, any of which can be named or unnamed:
extension MemoizedMacro {
/// Creates a string representing the arguments to be passed when invoking a function.
///
/// - Parameter parameters: The list of parameters extracted from a function's source code.
///
/// - Returns: A string representing the invocation arguments for the function call.
static func invocationArguments(for parameters: FunctionParameterListSyntax) -> String {
parameters.map { param in
switch (param.firstName.text, param.secondName?.text) {
case (let firstName, .none):
"\(firstName): \(firstName)"
case ("_", .some(let secondName)):
"\(secondName)"
case (let firstName, .some(let secondName)):
"\(firstName): \(secondName)"
}
}
.joined(separator: ", ")
}
}
Swift Macro Client Testing
This concludes our exploration of Swift Macro Unit Testing, where we’ve seen the value of unit tests in handling complex aspects of macro development. By writing tests first, we established clear expectations for our macro’s behavior. These tests guided us in building our macro methodically and confidently.
Although we focused on unit tests in this article, we reached a significant milestone when the “happy path” expansion test from the previous article began to pass. With all unit and expansion tests now passing, this is an ideal time to experience our Memoized macro from a client’s perspective.
The Swift Macro Testing series concludes with “ Swift Macro Client Testing ,” where we’ll try out our Memoized macro as a client. We’ll do this in the context of high-level client tests, the third approach in our testing triad. In that final article, we’ll see how expansion tests, unit tests, and client tests work together to ensure our macro is robust and reliable.