Swift macros inspect syntax and produce new syntax during compilation. They are effective at removing repetition that can be derived mechanically, but a macro can easily put every domain rule inside a compiler-plugin entry point. Tests then become large snapshots of expanded source. A more durable design keeps the plugin thin: parse the declaration, call pure rules, construct SwiftSyntax nodes, and report diagnostics. Naming, member selection, and conflict policy live in an ordinary Swift module.

Confirm that the problem needs a macro

A protocol extension, generic abstraction, property wrapper, or ordinary source generator is often easier to read and debug. A macro earns its complexity when source structure is genuinely part of the input and the compiler can provide useful diagnostics at the call site. Expansion should not depend on the network, current time, mutable filesystem state, or details of the build machine. Those dependencies undermine reproducible builds, caching, and code review.

A user should be able to predict the generated surface from the macro declaration and documentation. Document its role, accepted inputs, generated declarations, naming rules, and failures. If someone must expand the source to discover an unexpected network call or business behavior, the interface hides too much.

Make syntax adaptation narrow

Imagine @MemberwiseInit generates an initializer from stored properties. The plugin can translate SwiftSyntax nodes into a small description and pass it to an ordinary rule:

struct StoredProperty: Equatable {
    let name: String
    let type: String
    let hasDefault: Bool
}

struct InitializerPlan: Equatable {
    let parameters: [StoredProperty]
    let accessLevel: String?
}

func makePlan(
    properties: [StoredProperty],
    existingInitializer: Bool,
    accessLevel: String?
) throws -> InitializerPlan {
    guard !existingInitializer else { throw MacroRuleError.conflict }
    return InitializerPlan(
        parameters: properties.filter { !$0.hasDefault },
        accessLevel: accessLevel
    )
}

This function does not import compiler-plugin APIs. Fast unit tests can cover an empty type, defaults, access levels, and conflicts. Expansion is responsible only for recognizing stored properties, calling makePlan, and constructing declarations. A SwiftSyntax API change then affects the adapter rather than rewriting every rule test.

Do not flatten all syntax into strings merely to make tests convenient. A planning layer may retain a type spelling where that is sufficient, but generation should construct syntax nodes and let formatting handle whitespace. Raw concatenation is fragile around escaping, comments, generic constraints, and attributes.

Treat diagnostics as public behavior

A macro error is more than a thrown value. Attach a diagnostic to the most useful source node, give it a stable identifier and actionable message, and provide a fix-it only when the change is safe. Reject unsupported input instead of guessing and producing code that is almost valid. Test diagnostic text and location because they are how a user understands a failed build.

Separate an invalid macro use from a plugin defect. The former receives a domain-oriented explanation. The latter needs enough context for maintainers without crashing the compiler process. When the adapter encounters an unfamiliar syntax form, a conservative diagnostic is safer than a forced cast.

Warnings should also be rare and specific. A macro that emits broad warnings on accepted code trains users to ignore its diagnostics. If there is a safe default, document and apply it; if ambiguity changes semantics, require an explicit argument.

Use three layers of tests

First, test pure planning functions across combinations. These tests are fast and compare small semantic values. Second, provide short source inputs to macro expansion and compare formatted output plus diagnostics. This layer verifies syntax adaptation. Third, compile and run generated code in an example target to prove access control, type checking, overload behavior, and runtime semantics.

Not every case needs an expensive integration test, but each macro role needs representative success and failure cases. Snapshot updates require human review. A changed expansion may be harmless formatting, or it may add public API and create overload ambiguity. Automatically accepting every snapshot change is not a substitute for evaluating the generated contract.

Pin a toolchain and compatible SwiftSyntax release in continuous integration, then build the supported Swift-version matrix. Keep fixtures short enough that a reviewer can see why each generated declaration exists.

Control the generated surface

Names must be predictable and must account for declarations supplied by the user. Define access level, generic constraints, attribute propagation, interaction with private members, and behavior when another macro touches the same declaration. Prefer a small amount of readable output. Hundreds of generated lines or recursive macro application will affect build time and make diagnostics harder to connect to source.

Record rule changes in release notes. Macro expansion is part of source compatibility: even when the invocation text stays the same, a new generated member can change overload resolution or protocol conformance. When possible, offer an explicit option or migration period rather than silently changing the expansion.

A testable Swift macro does not rely on a larger golden file. Ordinary Swift rules decide what should be generated, a SwiftSyntax adapter reads and constructs syntax, and the plugin boundary reports diagnostics. Remove side effects, constrain the generated surface, and test rules, expansion, and compilation separately. The result is a maintainable compile-time tool instead of opaque code magic.