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:
- Expansion testing — given this input, does the macro produce the correct expanded code?
- Diagnostic testing — does the macro emit the correct error/warning when input is invalid?
- 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)]
}
}Testing with MacroTesting (Recommended)
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 -vMacroTesting 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.