给自己定了个目标:把一个 macOS 录音转写应用的测试覆盖率提到 80%。当时覆盖率是 21%,想着写几十个测试应该能搞定。
结果发现,这道题根本无解。
起点:21% 覆盖率
项目是一个 macOS 原生应用,用 SwiftUI + SwiftData 构建,功能包括实时语音识别、音频录制/回放、LLM 摘要生成和录音历史管理。总共 11,663 行可执行代码,已有的测试覆盖了 2,459 行。
先跑一遍带覆盖率的测试看看现状:
xcodebuild -scheme notetaker -configuration Debug \ -only-testing:notetakerTests test \ -enableCodeCoverage YES \ -resultBundlePath /tmp/notetaker-coverage.xcresult \ -parallel-testing-enabled NO然后用 xccov 查看报告:
xcrun xccov view --report /tmp/notetaker-coverage.xcresultnotetaker.app 21.08% (2459/11663)看到 21%,第一反应是”差距不大,写一批测试就行”。然后打开逐文件覆盖率报告,傻眼了。
51% 的代码根本测不了
拉出文件列表一看,大量 SwiftUI View 文件占了代码库的半壁江山:
SessionDetailView.swift 0.00% (0/1311)SettingsView.swift 0.00% (0/1137)ScheduleView.swift 0.00% (0/851)SessionListView.swift 46.83% (229/489)RecordingControlView.swift 0.00% (0/468)SummaryCardView.swift 0.00% (0/419)TranscriptView.swift 0.00% (0/369)ScheduleEditorView.swift 0.00% (0/332)LiveRecordingView.swift 0.00% (0/202)PrivacyDisclosureView.swift 0.00% (0/176)# ... 还有更多粗算一下:View 文件总计约 5,960 行,占 11,663 行的 51%。全部 0% 覆盖率。
这意味着什么?即使我把所有非 View 代码(约 5,700 行)测到 100%,总覆盖率也只有:
5700 / 11663 = 48.9%加上 SessionListView 和 ContentView 已有的部分覆盖(约 400 行),撑死 52%。
80% 从数学上就不可能达到。
为什么 SwiftUI View 无法单元测试
这不是我一个人的问题。SwiftUI 的 View 是声明式的值类型结构体,没有公开 API 可以在单元测试中检查 view 层级的内容。Apple 官方的建议是用 UI Automation Tests 和 SwiftUI Previews 替代 View 的单元测试。
社区有一个第三方库 ViewInspector 可以在运行时 introspect SwiftUI view 层级,但这是社区维护的,不是官方方案。Apple 测试团队承认过他们在和 SwiftUI 团队合作开发未来的单元测试支持,但没有给出时间表。
正确的架构策略是:把所有逻辑从 View 里抽出来,放到可单元测试的 ViewModel/Service 层。这个项目已经这么做了(三层架构:Views → ViewModels → Services),但 View 文件本身仍然包含大量 SwiftUI 布局、动画、条件渲染逻辑,这些全部计入总行数。
能测的部分:从 21% 到 23%
目标不可能达到,但能测的还是应该测。我批量写了 13 个新测试文件,覆盖了:
- Model 层:LLMConfig、LLMModelProfile、LLMProvider、SummarizerConfig、SummaryBlock、RecordingSession、ScheduledRecording、RepeatRule
- Service 层:LLMEngineFactory、LLMHTTPHelpers、NoopASREngine、NoopLLMEngine、AudioExportError、KeychainMigration、CalendarService、CrashLogService
- ViewModel 层:RecordingViewModel 扩展测试(ElapsedTimeClock、AudioLevelMeter、状态机、配置变体)
写测试本身不难。用 Swift Testing 框架(@Test、#expect),每个文件大概这个模式:
import Testingimport Foundation@testable import notetaker
@Suite("LLMConfig Tests")struct LLMConfigTests {
@Test func encodingExcludesApiKey() throws { let config = LLMConfig(apiKey: "super-secret") let data = try JSONEncoder().encode(config) let json = String(data: data, encoding: .utf8)! #expect(!json.contains("apiKey")) #expect(!json.contains("super-secret")) }
@Test func codableRoundTrip() throws { let original = LLMConfig(provider: .ollama, model: "mistral") let data = try JSONEncoder().encode(original) let decoded = try JSONDecoder().decode(LLMConfig.self, from: data) #expect(decoded.provider == .ollama) #expect(decoded.apiKey == "") // CodingKeys 排除了 apiKey }}最终把 15 个以上源文件推到了 100% 覆盖率:NoopASREngine、NoopLLMEngine、LLMProvider、LLMEngineFactory、SummarizerConfig、SummaryBlock、VADConfig、RepeatRule、TimeInterval+Formatting、TranscriptExporter、AudioConfig、TranscriptSegment。
整体覆盖率从 21.08% 到 23.39%。提升不大,但能测的都测了。
路上踩的三个坑
坑一:并行测试 + AVAudioEngine = 崩溃
跑全量测试时,大量测试随机失败。一开始以为是代码问题,排查后发现是 xcodebuild 默认并行执行测试导致的。
多个测试套件同时创建 AudioCaptureService(内部持有 AVAudioEngine),而 AVAudioEngine 有严重的线程安全问题 — 并发访问甚至 isRunning getter 都可能死锁。Apple Developer Forums 上很多人报告过这个问题。
解法:测试命令加 -parallel-testing-enabled NO,涉及音频的测试套件加 .serialized trait:
@Suite("RecordingViewModel Extended Tests", .serialized)struct RecordingViewModelExtendedTests { // ...}坑二:Swift Testing 结构体名必须全局唯一
我在新文件里定义了 AudioLevelMeterTests,结果编译报错:
error: invalid redeclaration of 'AudioLevelMeterTests'原来已有一个同名的测试结构体在另一个文件里。Swift Testing 的 @Suite 结构体在整个测试 target 里共享命名空间,不像 XCTest 的类可以在不同文件里同名(因为有模块前缀)。
坑三:EKEvent.title 永远不为 nil
写 CalendarService 测试时,想测 event.title 为 nil 的场景:
let event = EKEvent(eventStore: store)event.title = nil// event.title 实际上是 "",不是 nilEKEvent.title 的 setter 接受 nil 但 getter 返回空字符串。所以 event.title ?? "Untitled Meeting" 永远不会走到 fallback 分支。这种 Apple framework 的隐式行为只有写测试才会发现。
正确的覆盖率指标
既然 51% 的代码是不可测的 SwiftUI View,用总覆盖率作为指标就是在自欺欺人。更合理的做法是计算 “逻辑覆盖率” — 只统计非 View 文件:
xcrun xccov view --report /tmp/coverage.xcresult \ | grep -E "^ /Users" \ | grep -v "Views/" \ | grep -v "Schemas/"按这个口径算,非 View/Schema 代码约 4,700 行,已覆盖约 2,300 行,逻辑覆盖率大概 49%。这个数字才反映真实的测试质量。
TIP如果你的 SwiftUI 项目也在追求覆盖率指标,先算一下 View 文件占总代码的比例。如果超过 40%,那传统的 80% 总覆盖率目标基本不现实。要么调整指标为逻辑覆盖率,要么引入 ViewInspector 或 UI 测试框架。
总结
| 指标 | 值 |
|---|---|
| 总代码行数 | 11,663 |
| View 文件行数 | ~5,960 (51%) |
| 总覆盖率 | 23.39% |
| 新增测试文件 | 13 个 |
| 新增 100% 覆盖文件 | 15+ 个 |
| 理论最大覆盖率(不测 View) | ~52% |
这次经历最大的收获:覆盖率是手段不是目的。对于 SwiftUI 应用,逻辑层的高覆盖率比总覆盖率的数字更有意义。把精力花在测试 ViewModel、Service、Model 的边界条件和错误处理上,比想办法凑一个好看的总覆盖率数字要实在得多。
而 Apple,如果你在听的话 — 请给 SwiftUI View 一个官方的单元测试方案吧。