1471 字
7 分钟
SwiftUI 应用测试覆盖率的数学困境

给自己定了个目标:把一个 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 查看报告:

Terminal window
xcrun xccov view --report /tmp/notetaker-coverage.xcresult
notetaker.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),每个文件大概这个模式:

LLMConfigTests.swift
import Testing
import 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 实际上是 "",不是 nil

EKEvent.title 的 setter 接受 nil 但 getter 返回空字符串。所以 event.title ?? "Untitled Meeting" 永远不会走到 fallback 分支。这种 Apple framework 的隐式行为只有写测试才会发现。

正确的覆盖率指标#

既然 51% 的代码是不可测的 SwiftUI View,用总覆盖率作为指标就是在自欺欺人。更合理的做法是计算 “逻辑覆盖率” — 只统计非 View 文件:

Terminal window
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 一个官方的单元测试方案吧。

SwiftUI 应用测试覆盖率的数学困境
https://blog.lishuyu.top/posts/swiftui应用测试覆盖率的数学困境/
作者
猫猫魔女
发布于
2026-03-24
许可协议
CC BY-NC-SA 4.0