Testing Swift Macros: Unit Tests with swift-syntax and MacroTesting

Testing Swift Macros: Unit Tests with swift-syntax and MacroTesting

Swift macros (introduced in Xcode 15 / Swift 5.9) transform code at compile time. But how do you test them? Testing a macro means testing code transformation — given this input syntax, does the macro produce the expected output syntax?

Apple's SwiftSyntaxMacrosTestSupport library and Point-Free's MacroTesting library provide the tools. This guide covers both approaches.

What You're Testing

A Swift macro is a function that takes a SwiftSyntax node and returns transformed syntax. Testing it means:

  1. Expansion testing — given this input, does the macro produce the correct expanded code?
  2. Diagnostic testing — does the macro emit the correct error/warning when input is invalid?
  3. Fix-it testing — does the macro suggest the correct fix when it emits a diagnostic?

Setting Up a Test Target for Macros

Swift macros live in their own target (they run in a compiler plugin):

// Package.swift
import PackageDescription
import CompilerPluginSupport

let package = Package(
    name: "MyMacros",
    platforms: [.macOS(.v14), .iOS(.v17)],
    products: [
        .library(name: "MyMacros", targets: ["MyMacros"]),
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0"),
        .package(url: "https://github.com/pointfreeco/swift-macro-testing", from: "0.3.0"),
    ],
    targets: [
        // Library that exposes macros to users
        .target(
            name: "MyMacros",
            dependencies: ["MyMacrosMacros"]
        ),
        // Compiler plugin — runs the macro transformations
        .macro(
            name: "MyMacrosMacros",
            dependencies: [
                .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
                .product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
            ]
        ),
        // Tests
        .testTarget(
            name: "MyMacrosTests",
            dependencies: [
                "MyMacrosMacros",
                .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
                .product(name: "MacroTesting", package: "swift-macro-testing"),
            ]
        ),
    ]
)

A Simple Macro: @AddInit

Let's test a macro that generates a memberwise initializer:

// Sources/MyMacrosMacros/AddInitMacro.swift
import SwiftSyntax
import SwiftSyntaxMacros
import SwiftSyntaxBuilder

public struct AddInitMacro: MemberMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        guard let structDecl = declaration.as(StructDeclSyntax.self) else {
            throw MacroExpansionErrorMessage("@AddInit can only be applied to structs")
        }
        
        let storedProperties = structDecl.memberBlock.members
            .compactMap { $0.decl.as(VariableDeclSyntax.self) }
            .filter { $0.bindingSpecifier.tokenKind == .keyword(.var) || 
                      $0.bindingSpecifier.tokenKind == .keyword(.let) }
            .flatMap { $0.bindings }
            .filter { $0.accessorBlock == nil }
        
        let parameters = storedProperties.compactMap { binding -> String? in
            guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self),
                  let type = binding.typeAnnotation?.type else { return nil }
            return "\(pattern.identifier.text): \(type)"
        }
        
        let assignments = storedProperties.compactMap { binding -> String? in
            guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else { return nil }
            return "self.\(pattern.identifier.text) = \(pattern.identifier.text)"
        }
        
        let initBody = assignments.joined(separator: "\n        ")
        let paramList = parameters.joined(separator: ", ")
        
        let initDecl = """
        init(\(paramList)) {
            \(initBody)
        }
        """
        
        return [DeclSyntax(stringLiteral: initDecl)]
    }
}

Point-Free's MacroTesting library provides a clean assertMacroExpansion function:

// Tests/MyMacrosTests/AddInitTests.swift
import MacroTesting
import XCTest
@testable import MyMacrosMacros

final class AddInitMacroTests: XCTestCase {
    override func invokeTest() {
        // Set the macros you're testing
        withMacroTesting(macros: ["AddInit": AddInitMacro.self]) {
            super.invokeTest()
        }
    }
    
    func testBasicInitGeneration() {
        assertMacroExpansion(
            """
            @AddInit
            struct User {
                let id: String
                let name: String
                var email: String
            }
            """,
            expandedSource: """
            struct User {
                let id: String
                let name: String
                var email: String
            
                init(id: String, name: String, email: String) {
                    self.id = id
                    self.name = name
                    self.email = email
                }
            }
            """
        )
    }
    
    func testEmptyStructGeneratesEmptyInit() {
        assertMacroExpansion(
            """
            @AddInit
            struct Empty {}
            """,
            expandedSource: """
            struct Empty {
            
                init() {
                }
            }
            """
        )
    }
    
    func testAppliedToClassEmitsDiagnostic() {
        assertMacroExpansion(
            """
            @AddInit
            class NotAStruct {
                var name: String = ""
            }
            """,
            expandedSource: """
            class NotAStruct {
                var name: String = ""
            }
            """,
            diagnostics: [
                DiagnosticSpec(
                    message: "@AddInit can only be applied to structs",
                    line: 1,
                    column: 1,
                    severity: .error
                )
            ]
        )
    }
}

The assertMacroExpansion function:

  • Shows the input source
  • Shows the expected expanded output
  • Optionally verifies diagnostics
  • On failure, shows a diff of actual vs. expected expansion

Updating Snapshots

Like snapshot tests, assertMacroExpansion can record the expected output automatically:

func testMacroExpansion() {
    assertMacroExpansion(
        """
        @AddInit
        struct Point {
            let x: Double
            let y: Double
        }
        """,
        expandedSource: """
        // Leave empty on first run — MacroTesting will record it
        """
    )
}

On first run, the test fails and prints the actual expansion. Copy it into expandedSource.

Testing with SwiftSyntaxMacrosTestSupport (Apple's Library)

Apple's test library provides assertMacroExpansion as a standalone function:

import SwiftSyntaxMacrosTestSupport
import XCTest

final class AddInitAppleTests: XCTestCase {
    let testMacros: [String: Macro.Type] = [
        "AddInit": AddInitMacro.self,
    ]
    
    func testBasicExpansion() throws {
        assertMacroExpansion(
            """
            @AddInit
            struct User {
                let id: String
                let name: String
            }
            """,
            expandedSource: """
            struct User {
                let id: String
                let name: String
            
                init(id: String, name: String) {
                    self.id = id
                    self.name = name
                }
            }
            """,
            macros: testMacros
        )
    }
    
    func testDiagnosticOnClass() throws {
        assertMacroExpansion(
            """
            @AddInit
            class NotAStruct {}
            """,
            expandedSource: """
            class NotAStruct {}
            """,
            diagnostics: [
                DiagnosticSpec(
                    message: "@AddInit can only be applied to structs",
                    line: 1,
                    column: 1
                )
            ],
            macros: testMacros
        )
    }
}

Testing Expression Macros

For # expression macros (like #stringify):

// The macro
public struct StringifyMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) -> ExprSyntax {
        guard let argument = node.argumentList.first?.expression else {
            fatalError("Expected argument")
        }
        return "(\(argument), \(StringLiteralExprSyntax(content: argument.description)))"
    }
}
// Tests
func testStringifyExpansion() {
    assertMacroExpansion(
        """
        let result = #stringify(1 + 2)
        """,
        expandedSource: """
        let result = (1 + 2, "1 + 2")
        """,
        macros: ["stringify": StringifyMacro.self]
    )
}

Testing Fix-Its

Macros can suggest code fixes. Test them:

func testMacroSuggestsFixIt() throws {
    assertMacroExpansion(
        """
        @AddInit
        class ShouldBeStruct {
            let id: String
        }
        """,
        expandedSource: """
        class ShouldBeStruct {
            let id: String
        }
        """,
        diagnostics: [
            DiagnosticSpec(
                message: "@AddInit can only be applied to structs",
                line: 1,
                column: 1,
                fixIts: [
                    FixItSpec(message: "Change 'class' to 'struct'")
                ]
            )
        ],
        macros: testMacros
    )
}

CI Configuration

# .github/workflows/swift-macros.yml
name: Swift Macro Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      - name: Select Xcode 15+
        run: sudo xcode-select -s /Applications/Xcode_15.4.app
      - name: Run macro tests
        run: swift test --filter MyMacrosTests -v

MacroTesting vs. SwiftSyntaxMacrosTestSupport

Feature MacroTesting SwiftSyntaxMacrosTestSupport
Source Point-Free Apple
Snapshot-style updates Yes (record mode) No
Diff output Better (inline diff) Basic
Indentation handling Automatic Manual
Setup withMacroTesting{} once Pass macros: to each call

Recommendation: Use MacroTesting for a better developer experience. Use SwiftSyntaxMacrosTestSupport if you want zero external dependencies.

Conclusion

Testing Swift macros is about testing code transformation: does this input expand to the expected output? assertMacroExpansion from either library handles this cleanly. Write one test per expansion behavior, explicitly test error cases with diagnostics:, and add macro tests to your CI pipeline from the start — macro bugs are subtle and hard to debug without a test that shows exactly what expanded vs. what was expected.

Read more

Start now free