---
title: "Swift Macro Client Testing"
source: "/writing/swift-macro-client-testing/"
---

Article

# Swift Macro Client Testing

Chris Sessions

August 15, 2025

This is the fourth and final 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've followed a test-driven approach to develop a Memoized Swift macro from scratch. The second article introduced expansion testing, and the third focused on unit testing. In this article, we'll explore testing from a client perspective.

Client testing allows us to step outside our macro and experience it as users would. It's a chance to "eat our own dog food" while it's still in development. We know the macro intimately from the inside, but there's more we can learn when we approach it from the outside.

### Revisiting the MemoizedClient

In " [Swift Macro Expansion Testing](https://medium.com/p/3271df1744c2/edit) ," we created the Memoized macro using Xcode's Swift Macro template. The template produced an executable target in our package called **MemoizedClient**, which we've largely ignored until now. It's time to put it to work.

The target's description in the package manifest is "A client of the library, which is able to use the macro in its own code." This describes exactly what we want for client testing. However, testing executable targets can be finicky, so for simplicity we'll convert it to a library. We'll also add a dedicated client testing target. With these changes, the `Package.swift` manifest should look like this:

// swift-tools-version: 6.0

import PackageDescription
import CompilerPluginSupport

let package = Package(
    name: "Memoized",
    platforms: [.macOS(.v15)],
    products: [
        .library(name: "Memoized", targets: ["Memoized"]),
        .library(name: "MemoizedClient", targets: ["MemoizedClient"]),
    ],
    dependencies: [
        .package(url: "https://github.com/pointfreeco/swift-macro-testing", from: "0.6.0"),
        .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.0-latest"),
    ],
    targets: [
        // Macro implementation that performs the source transformation of a macro.
        .macro(
            name: "MemoizedMacros",
            dependencies: [
                .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
                .product(name: "SwiftCompilerPlugin", package: "swift-syntax")
            ]
        ),

        // Library that exposes a macro as part of its API, which is used in client programs.
        .target(name: "Memoized", dependencies: ["MemoizedMacros"]),

        // A client of the library, which is able to use the macro in its own code.
        .target(name: "MemoizedClient", dependencies: ["Memoized"]),

        // A test target used to develop the macro implementation.
        .testTarget(
            name: "MemoizedTests",
            dependencies: [
                "MemoizedMacros",
                .product(name: "MacroTesting", package: "swift-macro-testing"),
                .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
            ]
        ),

        // A test target used to test the macro client.
        .testTarget(name: "MemoizedClientTests", dependencies: ["MemoizedClient"]),
    ]
)

We also need to make a few filesystem changes to accommodate the Swift Package Manager:

mv Sources/MemoizedClient/main.swift Sources/MemoizedClient/Samples.swift
mkdir -p Tests/MemoizedClientTests
touch Tests/MemoizedClientTests/SamplesTests.swift

### A First Client Test

Continuing with our test-driven approach, let's create a simple test in our new client testing file:

// Tests/MemoizedClientTests/SamplesTests.swift

import Testing

@testable import MemoizedClient

struct SamplesTests {
    @Test func fibonacciValues() {
        let sample = Samples()

        #expect(sample.fibonacci(1) == 1)
        #expect(sample.fibonacci(3) == 2)
        #expect(sample.fibonacci(5) == 5)
        #expect(sample.fibonacci(7) == 13)
    }
}

As expected, Xcode complains with a `🛑 ⁠Cannot find 'Samples' in scope` error. Let's add the code we've been envisioning since the start of the series to the `Samples.swift` file:

// Sources/MemoizedClient/Samples.swift

import Memoized

struct Samples {
    @Memoized
    func fibonacci(_ n: Int) -> Int {
        if n < 0 {
            0
        } else if n < 2 {
            n
        } else {
            memoizedFibonacci(n - 1) + memoizedFibonacci(n - 2)
        }
    }
}

### Reasonable Restrictions

The Memoized macro has been thoroughly tested, so we'd expect our new test to pass. Surprisingly, it doesn't. In fact, the macro expansion won't even compile:

An unexpected error in the macro expansion

This is unexpected, given that we based the macro expansion on a working example from the first article, " [Exploring Memoization in Swift](https://medium.com/p/88c359779bd3/edit) ." However, the error isn't difficult to understand. Looking back at our working example, the memoized function was in a class, but our ⁠`Samples` is a struct. In Swift, functions that modify properties of a struct must be labeled as ⁠`mutating`.

There are several ways we could fix this:

1.  Make both of the functions `mutating`
2.  Change `Sample` from a struct to a class
3.  Find a solution that doesn't place unreasonable restrictions on the client

The first two options illustrate approaches to avoid. As a rule, macros should keep restrictions on the downstream client to an absolute minimum.

For the Memoized macro, it's reasonable to restrict it to functions that return a value, since that's a basic requirement of memoization. It's also somewhat reasonable that the macro won't work in an extension, since Swift doesn't allow adding stored properties in extensions. While this is less than ideal, it's only a restriction on placement.

Option one above is unreasonable not only because it's conceptually wrong -- labeling a pure function as 'mutating' -- but because it would simultaneously prevent the macro from being used in classes. Option two demonstrates a clear overreach by placing restrictions on code outside the macro's scope. Neither restriction is reasonable to impose on a client.

### Non-Mutating Mutations

Fortunately, implementing the third option is straightforward. With a dictionary cache, we mutate a struct property with each key-value write. However, a reference-type cache like the one below would let us store keys and values without mutating the struct itself:

// Sources/Memoized/MemoizedCache.swift

import Foundation

public final class MemoizedCache {
    public var storage: [Int: Int]

    public init() {
        self.storage = [:]
    }
}

The ⁠`MemoizedCache` file needs to live in the **Memoized** target, since it's the library that exposes the macro. By placing it there as a public class, it's available when the macro expansions need it.

Now we need to update the ⁠`MemoizedMacro.expansion` function:

// 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] {
        // ...
        let memoCacheDeclaration = "private var \(cacheVarName) = MemoizedCache()"
        // ...
        let memoFunctionDeclaration = """
            func \(memoFunctionName)(\(parameters)) -> \(returnType) {
                if let cached = \(cacheVarName).storage[n] {
                    return cached
                }
                let result = \(functionName)(\(invocationArguments))
                \(cacheVarName).storage[n] = result
                return result
            }
            """
        // ...
    }
}

This gets our client test compiling and passing. It also causes the ⁠`memoizedFibonacciFunction` expansion test to fail, which is expected since we've updated the expansion function. We should delete the test's stale `expansion` closure and re-run tests to get the updated version.

### Proof of Work

The client test demonstrates that ⁠`Samples.fibonacci(_:)` returns correct results. However, it doesn't prove that memoization is working.

To gather that proof, let's add a ⁠`CountingSamples` class where we can track how many times our function gets called. The class will contain a memoized `⁠fibonacci(_:)` function and an un-memoized ⁠`slowFibonacci(_:)` function:

// Sources/MemoizedClient/CountingSamples.swift

import Memoized

class CountingSamples {
    private(set) var numberOfFunctionCalls = 0

    @Memoized
    func fibonacci(_ n: Int) -> Int {
        numberOfFunctionCalls += 1

        return if n < 0 {
            0
        } else if n < 2 {
            n
        } else {
            memoizedFibonacci(n - 1) + memoizedFibonacci(n - 2)
        }
    }

    func slowFibonacci(_ n: Int) -> Int {
        numberOfFunctionCalls += 1

        return if n < 0 {
            0
        } else if n < 2 {
            n
        } else {
            slowFibonacci(n - 1) + slowFibonacci(n - 2)
        }
    }
}

With a corresponding test file, we can compare the number of calls to each function. We even know what values to expect from the first article in our series:

// Tests/MemoizedClientTests/CountingSamplesTests.swift

import Testing

@testable import MemoizedClient

struct CountingSamplesTests {
    @Test func fibonacciFunctionCalls() {
        let sample = CountingSamples()

        #expect(sample.fibonacci(20) == 6_765)
        #expect(sample.numberOfFunctionCalls == 21)
    }

    @Test func slowFibonacciFunctionCalls() {
        let sample = CountingSamples()

        #expect(sample.slowFibonacci(20) == 6_765)
        #expect(sample.numberOfFunctionCalls == 21_891)
    }
}

The tests pass, providing proof that memoization is working as expected in the client code.

### Signature Shake-Up

One thing that may have intrigued readers since " [Swift Macro Unit Testing](https://medium.com/@chris_91497/swift-macro-unit-testing-eb71ccecccda) " is the comprehensive test suite for the ⁠`invocationArguments(for:)` function, which demonstrates that it can handle any combination of parameters.

So far, however, we've only applied Memoized to the ⁠`fibonacci(_:)` function, with its single unnamed parameter and integer return value. What happens when we apply it to a function with a very different signature?

// Sources/MemoizedClient/Samples.swift

struct Samples {
    @Memoized
    func nonsensicalAES(_ key: Int128 = -1, vector: String) -> String {
        // Imagine a very expensive operation
        "\(key)::\(vector)"
    }
}

When we add ⁠`nonsensicalAES` to ⁠`Samples` with the Memoized macro, we discover at least two problems in our implementation.

Expansion won't compile

### Time for More Tests

Since the ⁠`nonsensicalAES` function's macro expansion isn't compiling, our first step is to comment out the Memoized attribute. Next, let's create a new expansion test using the function definition:

// Tests/MemoizedTests/MemoizedTests.swift

struct MemoizedTests {
    // ...
    @Test func memoizedNonsensicalAESFunction() {
        assertMacro {
            #"""
            @Memoized
            func nonsensicalAES(_ key: Int128 = -1, vector: String) -> String {
                // Imagine a very expensive operation
                "\(key)::\(vector)"
            }
            """#
        }
    }
}

Running tests generates the full macro expansion. Let's copy the generated code and paste it verbatim into ⁠`Samples.swift`. Like the real macro expansion, this code doesn't compile, but it gives us code to experiment with as we work toward a solution:

// Sources/MemoizedClient/Samples.swift

struct Samples {
    @Memoized
    func fibonacci(_ n: Int) -> Int {
        // ...
    }

    // @Memoized
    // func nonsensicalAES(_ key: Int128 = -1, vector: String) -> String {
    //     // Imagine a very expensive operation
    //     "\(key)::\(vector)"
    // }

    func nonsensicalAES(_ key: Int128 = -1, vector: String) -> String {
        // Imagine a very expensive operation
        "\(key)::\(vector)"
    }

    private var __macro_local_5CachefMu_ = MemoizedCache()

    func memoizedNonsensicalAES(_ key: Int128 = -1, vector: String) -> String {
        if let cached = __macro_local_5CachefMu_.storage[n] {
            return cached
        }
        let result = nonsensicalAES(key, vector: vector)
        __macro_local_5CachefMu_.storage[n] = result
        return result
    }
}

### Correcting Past Assumptions

The ⁠`memoizedNonsensicalAES` function reveals two problems:

1.  We hard-coded the ⁠`fibonacci` function's ⁠`n` parameter as the key when accessing the ⁠`MemoizedCache.storage[n]` cache.
2.  We assumed cached values would always be integers, but this function returns a string.

The results cache needs to be keyed by a unique representation of all parameter values. A simple solution might be:

let memoizedCacheKey = "\(key),\(vector)"

We'll need to revisit `MemoizedCache.swift` to make two changes.

1.  We need to change the `storage` dictionary's key from an integer to a string.
2.  We need to make `⁠MemoizedCache` generic on the result type.

// Sources/Memoized/MemoizedCache.swift

public final class MemoizedCache<T> {
    public var storage: [String: T]

    public init() {
        self.storage = [:]
    }
}

Now let's update our working code in ⁠`Samples.swift`:

func nonsensicalAES(_ key: Int128 = -1, vector: String) -> String {
    // Imagine a very expensive operation
    "\(key)::\(vector)"
}

private var __macro_local_5CachefMu_ = MemoizedCache<String>()

func memoizedNonsensicalAES(_ key: Int128 = -1, vector: String) -> String {
    let memoizedCacheKey = "\(key),\(vector)"
    if let cached = __macro_local_5CachefMu_.storage[memoizedCacheKey] {
        return cached
    }
    let result = nonsensicalAES(key, vector: vector)
    __macro_local_5CachefMu_.storage[memoizedCacheKey] = result
    return result
}

This resolves the compilation errors in our experimental code, suggesting we have a viable approach. Let's copy this code back into our expansion test to record the specification we need to implement:

@Test("Memoized Nonsensical AES function")
func memoizedNonsensicalAESFunction() {
    assertMacro {
        #"""
        @Memoized
        func nonsensicalAES(_ key: Int128 = -1, vector: String) -> String {
            // Imagine a very expensive operation
            "\(key)::\(vector)"
        }
        """#
    } expansion: {
        #"""
        func nonsensicalAES(_ key: Int128 = -1, vector: String) -> String {
            // Imagine a very expensive operation
            "\(key)::\(vector)"
        }

        private var __macro_local_5CachefMu_ = MemoizedCache<String>()

        func memoizedNonsensicalAES(_ key: Int128 = -1, vector: String) -> String {
            let memoizedCacheKey = "\(key),\(vector)"
            if let cached = __macro_local_5CachefMu_.storage[memoizedCacheKey] {
                return cached
            }
            let result = nonsensicalAES(key, vector: vector)
            __macro_local_5CachefMu_.storage[memoizedCacheKey] = result
            return result
        }
        """#
    }
}

### Putting It Back Together

Now we need to update the ⁠`MemoizedMacro.expansion` function to make everything compile again. The ⁠`memoCacheDeclaration` definition needs a generic argument for ⁠`MemoizedCache<T>()`, which we already have in ⁠`returnType`.

The ⁠`memoFunctionDeclaration` definition needs the ⁠`memoizedCacheKey` declaration and its replacement for ⁠`n` as the storage key. For now, let's use ⁠`\(n)` as a placeholder:

// 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] {
        // ...
        let returnType = functionReturnClause.type.trimmed.description

        let memoCacheDeclaration = "private var \(cacheVarName) = MemoizedCache<\(returnType)>()"
        // ...
        let memoFunctionDeclaration = """
            func \(memoFunctionName)(\(parameters)) -> \(returnType) {
                let memoizedCacheKey = "\\(n)"
                if let cached = \(cacheVarName).storage[memoizedCacheKey] {
                    return cached
                }
                let result = \(functionName)(\(invocationArguments))
                \(cacheVarName).storage[memoizedCacheKey] = result
                return result
            }
            """
        // ...
    }
}

When we run the tests, we'll see failures in response to these changes. The ⁠`memoizedFibonacciFunction` test fails first, with differences that match our intended changes. We can delete its ⁠`expansion` closure and re-run tests to get the updated version.

The ⁠`memoizedNonsensicalAESFunction` test fails next, this time because of the ⁠`memoizedCacheKey` value:

Test "Memoized Nonsensical AES function" recorded an issue at MemoizedTests.swift:87:21: Issue recorded
Expanded output (+) differed from expected output (−). Difference: ...
    // ...
    func memoizedNonsensicalAES(_ key: Int128 = -1, vector: String) -> String {
  −     let memoizedCacheKey = "\(key),\(vector)"
  +     let memoizedCacheKey = "\(n)"
        // ...
    }
Test "Memoized Nonsensical AES function" failed after 0.386 seconds with 1 issue.

### Universal Key-Maker

As the test shows, we need a function that takes parameters and returns a unique key. Let's specify how a ⁠`keyExpression(for:)` function should behave through unit tests:

// Tests/MemoizedTests/MemoizedMacroHelperTests.swift

extension MemoizedMacroHelperTests {
    @Test func keyExpressionZeroParams() throws {
        let result = MemoizedMacro.keyExpression(
            for: FunctionParameterListSyntax([])
        )
        #expect(result == "__no_args__")
    }

    @Test func keyExpressionOneNamedParameter() throws {
        let result = MemoizedMacro.keyExpression(
            for: FunctionParameterListSyntax(["arg1: String"])
        )
        #expect(result == #"\(arg1)"#)
    }

    @Test func keyExpressionOneUnnamedParameter() throws {
        let result = MemoizedMacro.keyExpression(
            for: FunctionParameterListSyntax(["_ arg1: String"])
        )
        #expect(result == #"\(arg1)"#)
    }

    @Test func keyExpressionTwoNamedParams() throws {
        let result = MemoizedMacro.keyExpression(
            for: FunctionParameterListSyntax(
                [
                    "arg1: String",
                    "arg2: Int"
                ]
            )
        )
        #expect(result == #"\(arg1),\(arg2)"#)
    }

    @Test func keyExpressionTwoUnnamedParams() throws {
        let result = MemoizedMacro.keyExpression(
            for: FunctionParameterListSyntax(
                [
                    "_ arg1: String",
                    "_ arg2: Int"
                ]
            )
        )
        #expect(result == #"\(arg1),\(arg2)"#)
    }

    @Test func keyExpressionMixOfNamedAndUnnamedParams() throws {
        let result = MemoizedMacro.keyExpression(
            for: FunctionParameterListSyntax(
                [
                    "_ arg1: String",
                    "arg2: Int"
                ]
            )
        )
        #expect(result == #"\(arg1),\(arg2)"#)
    }
}

The implementation of ⁠`keyExpression(for:)` is as follows:

// Sources/MemoizedMacros/MemoizedMacro.swift

extension MemoizedMacro {
    /// Creates a cache key expression based on the function's parameter names.
    ///
    /// - Parameter parameters: The list of function parameters from which to derive the key.
    ///
    /// - Returns: A string representing the key expression suitable for caching results.
    static func keyExpression(for parameters: FunctionParameterListSyntax) -> String {
        let parameterNames = parameters
            .map { "\\(\($0.secondName?.text ?? $0.firstName.text))" }
            .joined(separator: ",")

        return parameterNames.isEmpty ? "__no_args__" : parameterNames
    }
}

Now we need to integrate this into our expansion function:

// 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] {
        // ...
        let keyExpression = keyExpression(for: parameters)

        let memoFunctionDeclaration = """
            func \(memoFunctionName)(\(parameters)) -> \(returnType) {
                let memoizedCacheKey = "\(keyExpression)"
                if let cached = \(cacheVarName).storage[memoizedCacheKey] {
                    return cached
                }
                let result = \(functionName)(\(invocationArguments))
                \(cacheVarName).storage[memoizedCacheKey] = result
                return result
            }
            """
        // ...
    }
}

### Two More Client Tests

Now that everything is building and passing, let's revisit the ⁠`nonsensicalAES` function that exposed flaws in our implementation. Let's add some client tests to verify the macro is now working correctly:

// Tests/MemoizedClientTests/SamplesTests.swift

extension SamplesTests {
    @Test func nonsensicalAES() {
        let result = Samples().nonsensicalAES(42, vector: "qwerty")
        #expect(result == "42::qwerty")
    }

    @Test func nonsensicalAESDefaultKey() {
        let result = Samples().nonsensicalAES(vector: "asdfg")
        #expect(result == "-1::asdfg")
    }
}

Finally, let's remove any experimental code and then uncomment the memoized function in the `Samples` file:

// Sources/MemoizedClient/Samples.swift

struct Samples {
    // ...

    @Memoized
    func nonsensicalAES(_ key: Int128 = -1, vector: String) -> String {
        // Imagine a very expensive operation
        "\(key)::\(vector)"
    }
}

After one final test run, all tests are passing.

### All Together Now

Throughout this series, we began by exploring a basic optimization technique -- memoization -- and moved on to implementing it as a robust, reusable Swift macro. Along the way, we've explored three complementary testing approaches that together provide comprehensive coverage:

1.  **Expansion Testing**: Our first approach allowed us to specify the macro's overall behavior by declaring the expected output for a given input. This high-level testing gave us confidence that the macro would generate the correct code structure.
2.  **Unit Testing**: As our implementation grew more complex, we moved to testing individual components in isolation. This approach enabled us to handle edge cases and ensure that each piece of our macro functioned correctly.
3.  **Client Testing**: Finally, we stepped outside our implementation to experience the macro as users would. This revealed issues that weren't apparent from the inside, leading to a more robust and flexible implementation.

Each testing approach provided unique insights that would have been difficult to discover with the others alone. Expansion testing gave us the big picture, unit testing ensured the details were correct, and client testing verified the practical usability.

The result is a ⁠`@Memoized` macro that transforms a computationally expensive function like this:

func fibonacci(_ n: Int) -> Int {
    if n < 0 {
        return 0
    } else if n < 2 {
        return n
    } else {
        return fibonacci(n - 1) + fibonacci(n - 2)
    }
}

Into an optimized version with just a single line of code:

@Memoized
func fibonacci(_ n: Int) -> Int {
    if n < 0 {
        return 0
    } else if n < 2 {
        return n
    } else {
        return memoizedFibonacci(n - 1) + memoizedFibonacci(n - 2)
    }
}

This implementation handles various parameter types, works in both classes and structs, and dramatically improves performance for computationally expensive functions.

Beyond the specific macro we've created, this series demonstrates a powerful approach to Swift macro development that you can apply to your own projects:

1.  Start with a clear understanding of the problem you're solving
2.  Use expansion testing to outline the macro's desired behavior
3.  Develop component functionality with targeted unit tests
4.  Verify real-world usability through client testing

By following this testing triad, you can create macros that are robust, flexible, and ready for production use. Whether you're building tools for your team or developing libraries for the broader Swift community, this approach will help you create macros that developers will love to use.

We hope this series has inspired you to explore the possibilities of Swift macros in your own projects. Happy coding!

-   Chris Sessions

    Software Engineer

    [More from Chris](/team/chris-sessions/)

    Chris Sessions

### Tags

[Digital Engineering](/insights/all/?topic=digital-engineering) [Mobile Apps](/insights/all/?topic=mobile-apps)

### Share

-   Copied

## View these next.

[See all](/insights/all/?type=writing)

-   [

    Article

    How To Sabotage Your Project Using Inconsistency

    Collin Flynn

    ](/writing/how-to-sabotage-your-project-using-inconsistency/)
-   [

    Article

    Unit Testing race conditions by creating chaos (Swift)

    Sean Berry

    ](/writing/unit-testing-race-conditions-by-creating-chaos-swift/)
-   [

    Article

    UIApplicationDelegate call sequence reference

    Sean Berry

    ](/writing/uiapplicationdelegate-call-sequence-reference/)
