架子上有一台配备 16 GB 内存的 Apple M4 Mac mini,跑着 macOS 15、Xcode 26.6 和 Swift 6.3.3,只能在局域网内通过 SSH 访问。2026 年 9 月 5 日,它花了一个下午,前后相继跑完我们两款 Mac 应用的界面测试,全程录屏。 MeatPad,我们的 macOS 笔记与代码编辑器,是一个用 XCUITest 测试的 SwiftUI 应用:50 个用例,从 12:58 开始。 MailVault,我们本地优先的邮件归档工具,是一个用 WebdriverIO 测试的 Tauri 应用:539 个用例,从 14:43 开始。也就是一台机器、一个下午,589 个测试用例全程入镜,而这两次运行的表现毫不相像。
第一幕:MeatPad,405 秒内的五十次启动
屏幕左侧的一条命令,针对 MeatPad 0.15.0:
xcodebuild test -scheme MeatPad -destination "platform=macOS" \
-only-testing:MeatPadUITests
那段录屏里的其余一切,都是这一行命令的后果。Xcode 构建出应用本身,以及另一个叫作测试运行器(test runner)的程序包,然后把两者一起交给机器。对每一个测试用例,运行器都会从零启动应用,等它的窗口出现,移动指针,按下按键,读取返回的结果,再终止应用。然后对下一个用例重复一遍。窗口一个接一个地出现又消失,不是什么视觉效果,那就是这个循环本身。
为什么一个 XCUITest 用例要花上几秒
这次运行在 405.032 秒内跑完了 50 个用例。其中 1 个是故意自我跳过的,所以真正跑起来的 49 个平均每个大约 8.3 秒。相比之下,界面底层的框架 MeatPadKit,一个测试平均只要十九毫秒。
这个差距由三件事撑起来。应用每个用例都要重新启动一次,所以每个用例都要为冷启动和窗口稳定下来付费。测试跑在独立的进程里,只能透过 macOS 的辅助功能树(accessibility tree)看到应用,所以每一次对屏幕的询问都是一次跨进程边界的查询,而不是一次内存读取。而且指针和键盘都是真实的,所以一次拖拽花的时间,就跟真的拖拽一样长。
五十个用完即弃的世界
要点击一张卡片的用例,得先有一张卡片可点。没有任何东西会被注入到运行中的进程里,所以状态必须在应用启动之前就已经存在。每个用例都会在系统临时文件夹里写出一个全新的存储目录,往里面预置一个看板,然后启动指向它的应用:
app.launchArguments = [
"-meatpad.storageRootOverride", storageRoot.path,
"-meatpad.revealBoard", boardID.uuidString,
"-hasSeenFirstRunIntro", "YES",
]
这三个参数都是发布版二进制文件里真实存在的代码。它们存在的理由,是让测试能够到达某个界面,而且它们会随着应用发给每一个人。这就是从外部测试原生界面的真实代价,我们愿意再付一次,但这件事应该被大声说出来,而不是等别人读源码时自己发现。好处是这次运行不会留下任何痕迹:每个用例都会在收尾时删除自己的目录,所以你自己的看板永远不会被碰到,一次失败也不会连累下一个用例。
从一条笔记开始。 因编辑器而留下。 MeatPad 是一个 macOS 原生的笔记本兼代码编辑器:你 Mac 上的普通文件,无账户,无同步引擎,无遥测。
了解 MeatPad →按套件拆开看时钟
MeatPad 0.15.0 · MeatPadUITests · Apple M4 Mac mini · 2026-09-05
BoardCardDisplay 4 cases 49.973 s
BoardCardEditor 9 cases 68.393 s
BoardCardFace 5 cases 31.313 s
BoardCardLink 2 cases 14.380 s
BoardDrop 3 cases 30.463 s
BoardLabel 8 cases 102.638 s
BoardNewline 5 cases 26.818 s
BoardSearch 5 cases 36.664 s
BoardShot 1 skipped 0.020 s
NamePrompt 1 case 6.943 s
NoteLink 1 case 17.615 s
OpenWith 6 cases 19.812 s
total 50 cases 405.032 s
十二个测试套件,每一个都以它守护的东西命名:三种卡片显示模式、带颜色色板和截止日期的卡片编辑器、原地编辑标题与在列之间拖动卡片、卡片备注里的链接、拖放目标、标签与标签筛选、快速添加输入框里的换行、搜索、新建看板弹层的键盘焦点、笔记编辑器的链接提示,以及那 6 个用例:向 Launch Services 询问 MeatPad 是否为它所声明的每种文件类型都提供了打开方式。
BoardLabel 是最贵的一个,8 个用例花了 102.638 秒,因为标签筛选是在按键之后一两帧才把卡片从视图里移除,这些用例得坐在一个轮询循环里等那一列稳定下来。这次运行里最慢的单个用例是 27.992 秒,检查一个过长的卡片标题在紧凑显示下是否裁切、在另外两种显示下是否换行,也就是要在三次启动里测量同一个标题三遍。最快的用例是 1.507 秒,向 Launch Services 询问它注册的每种文件类型是否都提供了 MeatPad,这需要应用在运行,但完全不会碰它的窗口。
会对你说谎的那行摘要
MeatPad 的 Xcode 项目是从一份清单文件生成的,并不提交进版本库。这很方便,直到有一天你添加了一个测试文件,忘了重新生成,却照样跑了这套测试。在 xcodebuild 看来,什么都没错。它编译了拿到的项目,跑了里面零个测试,然后打印出所有人都在找的那两个词:
** TEST SUCCEEDED **
一次什么都没跑的运行,看起来和一次跑了全部内容的运行一模一样。所以值得读的那一行,从来不是最后一行,而是它上面那一行,写着到底跑了多少个用例。下面是上面那段录屏里运行的最后几行。视频里的终端把 xcodebuild 的输出接了一道 grep 和 sed,所以打印出来的是同一份输出的缩短版本,行宽刚好能挤在应用窗口旁边。命令本身没有变:
[OpenWithUITests]
testAMultiSelectionOpensEveryFileAsATab passed 3.799s
testASecondFileJoinsTheWindowThatIsAlreadyOpen passed 4.066s
testLaunchServicesOffersMeatPadForEveryFileTypeItClaims passed 1.507s
testOpeningAFileShowsItAsATabInAProjectWindowForItsFolder passed 3.825s
testOpeningAFolderOpensItAsTheProjectWithNoTabs passed 3.950s
testOpeningAnExtensionlessFileOpensItToo passed 2.665s
Executed 6 tests, with 0 failures in 19.812 s
Executed 50 tests, with 1 test skipped and 0 failures in 405.032 s
All tests passed
Executed 50 tests, with 1 test skipped and 0 failures in 405.032 s
** TEST SUCCEEDED **
五十个用例,一个故意跳过,零失败,405.032 秒。数目和最后一行对得上,这才是唯一值得相信的组合。
在那条绿色结果之前发生了什么
录下来的这次运行,并不是当天第一次跑。12:19 那次没录的运行是红的:同样 50 个用例,1 个跳过,2 个失败,都在 OpenWith 里:多选用例和第二个文件用例,也就是上面摘录里能看到分别在 3.799 秒和 4.066 秒通过的那两个。崩溃报告显示,应用在启动后 0.75 秒中止,位置在 SwiftUI 自己的启动路径里,是 applicationWillFinishLaunching 期间、Dock 还在通知应用时,一次 AttributeGraph 前置条件失败引发的。这时候什么都还没交给应用处理,所以这不是打开文件那部分代码的问题。同样这 6 个 OpenWith 用例,紧接着单独跑了 3 次,3 次都过,也就是 18 个用例里 18 个都过,录下来的那次运行里这 6 个也全部通过。看起来像是一种只在这套测试连续重新启动应用时才会出现的启动时序间歇性失败(flake)。目前仍在调查中,也没有从任何地方剪掉。
搭建过程里发现的一件事值得单独写一段。为了让应用能和终端并排放下,我们一开始把 MeatPad 保存的窗口尺寸从 1440x900 点缩小到了 1010x900。结果一下子失败了四个用例:卡片编辑器里的颜色样块、一个标签筛选,还有一次搜索,原因都很单纯,是它们要够到的元素跑出了屏幕外。换回原来的 1440x900 尺寸再跑一次,同样这批用例 9 个全部通过。窗口尺寸也是 UI 测试的一项输入,而这套测试默默假设了应用的默认尺寸。这就是为什么录屏里 MeatPad 以默认尺寸出现在右边,一个窄窄的终端在左边。
两次更早的录制都因为跟 MeatPad 无关的原因被扔进了垃圾桶:一次是屏幕上还留着 12:19 那次崩溃遗留的陈旧崩溃对话框,另一次是另一个任务的 MailVault 套件跑到一半,把自己的窗口叠了上来,因为这台 mini 是一台共享机器。这倒是给下午后半段做了个不错的开场。
第二幕:MailVault,十六分钟内的 81 个 spec 文件
MailVault 2.11.3 用另一种方式构建:一个 Rust 核心加一个网页前端,由 Tauri 打包。它的端到端套件由 WebdriverIO 驱动,14:43 那条命令要的是它三个套件里的两个:
npx wdio run wdio.conf.js --suite ui-headless --suite connected-ci
ui-headless 套件是 7 个 spec 文件,覆盖没有配置任何帐户时的欢迎界面。connected-ci 套件是 74 个 spec 文件,针对预置好的模拟 IMAP 帐户运行。合计 81 个 spec 文件。第三个套件 local-manual 还有 6 个 spec 文件,覆盖备份、迁移、归档和视觉检查,这次运行没有包含它。
Spec Files: 80 passed, 1 failed, 81 total (100% completed) in 00:16:03
80 个 spec 文件通过,1 个失败,共 81 个,按 wdio 自己的计时是 16 分 3 秒。录像跑了 16:18,因为它在命令开始前就开始录,命令结束后才停。这些文件底下是 539 个测试用例:533 个通过,5 个跳过,加上那一个失败。ui-headless 那一半贡献了 55 个通过、3 个跳过,测试用时约 56 秒;connected-ci 贡献了 478 个通过、1 个失败、2 个跳过,用时约 811 秒。
应用是按每个 spec 文件启动一次,而不是按每个用例启动一次,所以这个下午的 539 个 MailVault 用例只花了 81 次启动。一个 spec 文件平均约 10.7 秒,中位数是 6 秒,这说明平均值是被少数几个耗时长的文件拉高的:connected-custody-claims 的 9 个测试花了 54.3 秒,connected-performance 的 7 个花了 53.4 秒,connected-compose-editor 的 19 个花了 39.6 秒,connected-compose-autosave 的 15 个花了 38.7 秒。最快的文件 connected-backup-partial-failure,5 个测试只用了 32 毫秒。最忙的 connected-email-viewer 有 21 个测试。这一切都建立在一个大得多、也便宜得多的基础之上:216 个文件里的 2,683 个 vitest 单元测试在 6.77 秒内跑完,318 个 Rust 测试覆盖了核心部分,而这两个被录下来的套件在源码里合计是 518 个 it() 用例。
录屏里有两件事值得说明一下。左边的终端只打印 wdio 按文件输出的 RUNNING、PASSED 和 FAILED 几行,因为 wdio 把详细报告留到整次运行的最后才输出。快结束时,导出那个 spec 会在 Preview 里打开一封导出的邮件,把终端遮住了最后一分钟;这是测试在做它该做的事,不是意外。
测试框架搭建了什么
每次运行都会启动自己的模拟 IMAP 服务器,预置了三个账户:mock.test 域下的 luke、vader 和 yoda,其中 vader 的 INBOX 装着 700 封邮件,比应用的加载窗口宽裕得多,所以分页是被真正跑到的,而不是假设出来的。第四个账户由一个测试现场输入添加,你可以在录屏里看到它被打出来的过程。被测试的应用是一个启用了 webdriver 特性的构建,由 tauri-wd(即 tauri-webdriver-automation crate)启动,运行时指向一个用完即弃的 HOME 目录,真正的归档永远不会被碰到。这套测试框架的搭建方式,在 现场笔记 004.
唯一的失败,尚未定性
539 个用例里有一个失败:spec connected-storage-matrix,测试 "a row deleted in unified mode stays gone across account churn and a reload"。它的辅助函数 switchToUnified 一上来就断言侧栏的「所有收件箱」按钮存在且可见,完全不等待,而它恰好紧跟在上一个测试重新加载应用之后运行。期望值为 true,实际收到 false。我们后来没能重跑这一个 spec,因为在那个检出状态下,每一次尝试开启新的 driver 会话都在到达 driver 之前就失败了,会话请求上报了一个 undici 的 UND_ERR_INVALID_ARG。所以它还没能被定性。看起来更像是重新加载之后少了一处等待,而不是产品本身的 bug,而它依然留在录像里。
每个用例 1.8 秒,对比 8.3 秒
539 个 MailVault 用例在 963 秒的实际用时里跑完,平均每个大约 1.8 秒。MeatPad 实际执行的 49 个用例在 405.032 秒内跑完,平均每个大约 8.3 秒。同一台机器,同一个下午,两者之间大约相差五倍。
是两个结构性原因,不是某一个聪明的优化。WebDriver 在正在运行的应用内部执行 JavaScript,直接读取 DOM,所以一次检查落在端到端测试里 494 个 data-testid 挂钩之一上,而不用跨进程边界钻进 macOS 的辅助功能树。启动次数的算法也不一样:539 个 MailVault 用例只花了 81 次应用启动,而 50 个 MeatPad 用例要花 50 次。这个差距,我们几周前已经认真测过一次,写在 现场笔记 006.
这些都不代表哪个应用测得更好。两套测试检查的是不同的产品,而每个用例 8.3 秒,就是从进程外部驱动原生界面这条 macOS 唯一提供的路所要付的代价。
自己跑一遍
git clone https://github.com/GraphicMeat/MeatPad
cd MeatPad
brew install xcodegen
xcodegen generate
xcodebuild test -scheme MeatPad -destination "platform=macOS" \
-only-testing:MeatPadUITests
别跳过那行 xcodegen 命令,理由上面已经说过。MeatPad 源码在 GitHub。MailVault 的套件只是再多克隆一个仓库,跑的是录屏里那同一条命令:
git clone https://github.com/GraphicMeat/mail-vault-app
cd mail-vault-app
npm install
npx wdio run wdio.conf.js --suite ui-headless --suite connected-ci
这一切都是公开的,MeatPad 那次间歇性失败和 MailVault 那一次失败都不例外,MailVault 的源码就放在 MeatPad 旁边的 GitHub.
本地优先的 Mac 软件,附带测量数据。 MailVault 把你的邮件保存在你自己磁盘上的一个归档库里,每次发布前都会有一台机器把整个应用点一遍。
了解 MailVault →