BookaMac 工程记录

云端 Mac 的 Swift 宏展开回归测试:锁定生成代码与工具链

云端 Mac 的 Swift 宏展开回归测试:锁定生成代码与工具链

一个宏实现合并后,本地测试全部通过,云端 Mac 的下一次构建却生成了不同的成员顺序,诊断位置也偏了一行。最终产物仍能编译,但代码审查看到的已不是同一套 API。Swift 宏运行在编译阶段,结果同时受宏实现、SwiftSyntax 依赖和当前工具链影响,因此不能只把它当普通函数测。

先定义要锁定的结果

回归测试应覆盖三层。第一层比较宏展开后的完整源码,捕捉成员、访问级别、属性和格式变化。第二层验证错误、警告与修复建议,尤其是诊断所指向的 token。第三层才是编译和运行测试,用来确认生成代码可被真实调用。

层级 检查对象 典型失败
展开层 生成源码与声明顺序 属性遗漏、访问级别改变
诊断层 消息、位置、修复建议 错误落在错误节点
行为层 编译与运行结果 生成实现可编译但行为错误

不要把“测试能编译”当成“宏输出没有变化”。宏的公开契约通常就是它生成的源码形状。

先挑选最小输入建立基线,再补空声明、泛型、嵌套类型、已有同名成员和非法参数。每个用例只验证一种规则,失败时才能快速判断是实现回归还是工具链变化。

用展开测试固定生成源码

宏包的测试目标可引入 SwiftSyntaxMacrosTestSupport,通过 assertMacroExpansion 同时给出输入和期望源码。下面的用例假设 TraceIDMacro 为结构体补充一个字符串属性:

import SwiftSyntaxMacros
import SwiftSyntaxMacrosTestSupport
import XCTest
@testable import TraceIDMacros

private let macros: [String: Macro.Type] = [
    "TraceID": TraceIDMacro.self
]

final class TraceIDMacroTests: XCTestCase {
    func testAddsTraceID() {
        assertMacroExpansion(
            """
            @TraceID
            struct Job {}
            """,
            expandedSource:
            """
            struct Job {
                let traceID: String
            }
            """,
            macros: macros
        )
    }
}

期望文本应保持完整,不要只搜索 traceID 是否出现。局部字符串断言会漏掉重复成员、错误缩进和意外增加的接口。若宏允许参数,还应为默认值、显式值和非法表达式分别建例。

把错误路径单独拆开

非法输入不要和成功展开混在同一个测试中。分别断言诊断文本、行列位置及修复建议,并给测试名写清触发条件。这样调整一条错误信息时,不会迫使团队重新接受整份成功快照。

记录工具链指纹

BookaMac 上的云端 Mac 可以长期执行同一条流水线,但稳定节点不等于工具链永远不变。每次测试都应把 Xcode 选择路径、Swift 版本、主机架构和依赖锁文件一起归档。

set -euo pipefail
export LANG=C
export LC_ALL=C

mkdir -p artifacts
xcode-select -p | tee artifacts/xcode-path.txt
xcrun swift --version | tee artifacts/swift-version.txt
uname -m | tee artifacts/host-arch.txt
cp Package.resolved artifacts/Package.resolved
xcrun swift test 2>&1 | tee artifacts/swift-test.log

LANGLC_ALL 固定后,诊断文本不易因运行账户的语言环境发生变化。xcrun 则确保测试使用当前选中的 Xcode 工具链,而不是 shell 路径里偶然出现的另一个 swift。如果仓库没有 Package.resolved,脚本应明确跳过复制,而不是创建空文件冒充锁定结果。

隔离并发与共享状态

宏实现若读取环境变量、当前目录、时间或临时文件,测试很容易在并行执行时漂移。更稳妥的原则是让宏只依据语法树和显式参数生成结果。确实需要外部数据时,把读取逻辑抽到独立类型,并在测试中注入固定值。

先以串行模式跑通最小集合,再开启并行测试。若并行后才失败,优先检查固定文件名、共享缓存目录、全局可变变量和测试清理顺序,不要直接增加重试次数。重试会把竞态隐藏成偶发绿灯。

宏测试也不应修改真实工程源码。临时输入写入每个用例独立的目录,完成后清理;失败时只保留脱敏后的输入、展开结果和日志。这样既能复现,也不会让上一轮残留影响下一轮。

安全升级工具链

升级 Xcode 或 SwiftSyntax 前,先在旧工具链运行全套测试并保存基线,再切换工具链执行同一命令。审查差异时按以下顺序处理:

  1. 确认实际选中的 Xcode 路径与 Swift 版本。
  2. 检查 Package.resolved 是否发生预期内变化。
  3. 区分空白、缩进等格式变化与成员、类型、访问级别等语义变化。
  4. 重新运行行为测试,确认生成接口可被调用。
  5. 只有完成审查后,才更新展开期望值。

如果所有用例同时出现相似格式差异,通常应先调查打印规则或工具链变化;如果只有一个边界用例变化,则更可能是宏实现自身的问题。不要用批量替换直接刷新快照,否则真正的 API 回归会混在大量文本变化里。

最终应保留三类产物:机器可读的测试结果、展开失败的实际与期望差异、工具链指纹。下一次云端 Mac 构建失败时,排查起点就不再是“环境可能不一样”,而是可以明确回答哪一层契约、从哪个版本开始发生了变化。

常见问题

Swift 宏测试是否只需要比较最终编译结果?

不够。最终编译通过只能证明生成代码可编译,还应直接比较展开源码,并单独验证错误、警告和修复建议,才能发现 API 形状或诊断位置的意外变化。

工具链升级后所有展开快照都变化怎么办?

先记录升级前后的 Xcode 路径、Swift 版本和依赖锁文件,再区分纯格式变化与语义变化。只有确认公开 API、诊断和运行行为均符合预期后,才批量更新基线。

独享物理 Mac mini

把已验证的工作流放到持续在线的云端 Mac

按用途选择 BookaMac M4 或 BookaMac M4 Pro,并在下单时核对地区、租期与存储附加项。

选择租用方案