Article

Refining the Macro Expansion

Chris Sessions

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:

  1. ⁠memoFunctionName(for:) to properly camel-case the memoized function name.
  2. ⁠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.