桌子底下有一台 16 GB 内存的 Apple M4 Mac mini,它的工作是查明我们的应用是不是还能用。 MailVault 用 Tauri 构建。 MeatPad 用 SwiftUI 构建。这台 mini 两套都跑:同一台机器,同一个下午,同一个人写测试。

两套测试的成本并不相同。差得远。下面的一切都测量于 2026 年 8 月 25 日,每一条命令你都可以自己跑一遍。

今天跑绿了的东西

MailVault 2.10.2 · Tauri (Rust core + web frontend)

  unit and component   npm test               1,218 tests   86 files    2.28 s
  integration          npm run test:integration   64 tests    8 files    2.91 s
  core and daemon      npm run test:imap         158 tests               1.4 s
  end to end           npm run test:e2e           53 spec files
                         7 headless · 41 seeded with mock IMAP · 5 local only

MeatPad 0.13.0 · SwiftUI (AppKit, TextKit 2)

  MeatPadKit           swift test                601 tests   41 files   11.47 s
  interface            xcodebuild test            31 tests    5 files

把每个代码块的最后两行对着读,因为整篇文章就在那里。MailVault 在不到七秒的真实时间里拿到 1,440 项无窗口检查,另外还能用 53 个端到端规格驱动它自己的真实二进制文件。MeatPad 在十一秒里拿到 601 项无窗口检查,以及 31 个真正触碰界面的测试。

还有一个塞不进表格的数字。在 mini 上跑那 31 个界面测试中的八个,花了 98.186 秒。单个用例在 6.9 到 17.2 秒之间。就算每个测试十二秒吧。

Executed 8 tests, with 0 failures (0 unexpected) in 98.186 seconds

同一台机器上,MeatPadKit 的单元测试平均十九毫秒。MailVault 的一个 vitest 用例平均不到两毫秒。在 SwiftUI 视图上模拟点击一次,代价约等于六百个单元测试。

八月,两条曲线

MailVault 这个月都在被人补测试,而这件事的形状,只要一周一周去数仓库里的测试用例就很清楚:

MailVault, JavaScript test cases and end-to-end spec files

  1 March      440 cases
  1 August     850 cases     22 e2e specs
  8 August     972 cases     28 e2e specs
 15 August   1,111 cases     30 e2e specs
 22 August   1,393 cases     43 e2e specs
 25 August   1,604 cases     53 e2e specs

用例数在二十五天里几乎翻倍,端到端规格则不止翻倍。这种增长在应用里并不是均匀分布的。它去了有缺陷的地方:撰写与草稿、批量选择、每次改动操作之后必须与磁盘上的内容保持一致的存储矩阵,以及账户切换。那段时间的提交标题读起来像一份认罪清单,而测试日志本来就该这么读。

MeatPad 的曲线更短,因为这个应用更年轻,而且里面有一级台阶值得诚实地讲出来:

MeatPad, Swift test functions

 18 July      327 tests      0 through the interface
 21 July      517 tests      0 through the interface
 19 August    548 tests      0 through the interface
 25 August    632 tests     31 through the interface

MeatPad 七月发布时带着 517 个测试,其中没有一个会打开窗口。第一个界面测试写于 8 月 24 日,发布整整一个月之后。这段空白不是懒惰。本文余下的部分讲的就是它。

一个你真的能核对的邮件归档。 MailVault 在本地备份并归档你的邮箱,而在这一切送到你手上之前,有 53 个端到端规格驱动着真实的应用程序。

访问MailVault

能解释其余所有差别的那一个差别

Tauri 应用的界面是一个网页。测试框架用 WebDriver 跟它对话,这意味着框架可以在正在运行的应用程序内部执行代码:

const searchExists = await browser.execute(() => {
  const input = document.querySelector('input[placeholder*="Search"]');
  return input !== null;
});

这个箭头函数不是由测试来求值的。它被送进应用自己的 webview,在那里对着活的文档执行,然后把结果带回来。测试可以用和应用程序自身完全相同的方式盘问界面。

XCUITest 做不到这件事,将来也做不到。它是一个独立进程,从外面经由 macOS 的辅助功能层操作你的应用。没有任意代码的通道,也没有办法向视图提出辅助功能树尚未回答的问题。后面的一切,都是这一条边界带来的结果。

在文档里,什么都有把手

MailVault 的端到端套件通过测试 ID 属性选中元素 305 次。加一个不花什么成本,也丝毫不改变应用的运行方式。应用渲染出来的东西都是可寻址的,不管有没有人事先这么打算。

MeatPad 的整个源码树里有 34 个辅助功能标识符,而其中每一个都必须在会被发布的生产代码里,刻意挂到某个视图上:

TextField("Filter", text: $query)
    .accessibilityIdentifier("board.labelFilter")

没有标识符的东西,从测试的角度看就是不存在。SwiftUI 应用的可测表面不是「界面」。它是「界面中有人记得去命名的那些部分」,而这份名单只有在有人正在写测试的时候才会变长。

而且你还没法问,你命名的到底是个什么东西

就算有标识符,SwiftUI 的视图也不会向你保证它最后变成了什么。MeatPad 的测试是这样够到看板的搜索框的:

app.descendants(matching: .any).matching(identifier: "board.search").firstMatch

对任意后代类型做匹配是一种投降。源码里写的是 TextField,但它究竟以文本框、搜索框还是别的什么面目出现,取决于 SwiftUI 那天选择怎么把它搭起来,而这可能随一次系统更新而改变。遍历每一种元素类型,比直接点名要你写下的那个东西更慢也更含糊,可这是唯一可靠的选项。

把值读回来也是同样的故事。卡片标题回来时,是对一个类型未知的值所做的可选转换,因为辅助功能层给的就只有这些,而猜对是你的活儿。

没有人会等你

WebDriver 内建了等待。XCUITest 会等某一个元素出现,对跨越多个元素的条件则什么都不提供。于是 MeatPad 的测试自己手搓了一个:

private func waitForCardTitles(_ expected: [String]) -> Bool {
    let deadline = Date().addingTimeInterval(10)
    while Date() < deadline {
        if visibleCardTitles == expected.sorted() { return true }
        usleep(200_000)
    }
    return false
}

在把卡片过滤掉的那次按键之后,卡片要再过一两帧才离开视图层级,所以刚敲完就断言一次,纯属掷硬币。每一个在交互之后检查列表的测试都需要这个循环,而每一个这样的循环,都是一个让真实故障有十秒钟看起来像「慢一点的成功」的地方。

把应用弄到一个已知状态

MailVault 的套件为每个账户启动一个模拟 IMAP 服务器,里面预置了一个装着 700 封邮件的收件箱,把应用指向它们,并给整次运行一个用完即弃的主目录。被测试的应用程序完全没有改动。它根本不知道自己正在被测试。

MeatPad 则必须被告知。它的 UI 测试先在磁盘上写出一个存储目录,然后用发布版二进制文件认识的参数启动应用:

app.launchArguments = [
    "-meatpad.storageRootOverride", storageRoot.path,
    "-meatpad.revealBoard", boardID.uuidString,
    "-hasSeenFirstRunIntro", "YES",
]

这些标志是已发布应用程序里货真价实的代码,存在的目的就是让测试能够到某个界面。这是笔公平的交易,我们还会再做一次,但值得把话说明白:用 XCUITest,你的测试脚手架有一部分会随产品交到用户手里。

测试被允许在哪里跑

MailVault 那套无窗口的端到端测试,跑在一台普通的 Ubuntu GitHub Actions runner 上。真实的 Tauri 二进制文件为 Linux 构建,在一个虚拟帧缓冲和一个消息总线会话里启动,由 WebDriver 在按分钟计费为零的硬件上一路点过去。确切的咒语在下面。

dbus-run-session -- xvfb-run --auto-servernum \
  --server-args="-screen 0 1920x1080x24" \
  npx wdio run wdio.conf.js --suite ui-headless

XCUITest 没有对应的一行。它需要 macOS、一个窗口服务器,以及一个真实的用户会话,因为它是真的在移动指针、按下按键。MeatPad 的仓库里恰好只有一个 GitHub 工作流,而它是用来构建发行版的。测试住在那台 mini 上,这是对「一个小工作室在哪里跑 Mac 的 UI 测试」这个问题的诚实回答,也正是那台机器存在的理由。它如何为 MailVault 挣出自己的口粮,我们写在 更早的一篇笔记.

MeatPad 中任何 UI 测试都永远够不到的那一部分

再看一眼 MeatPad 那 31 个界面测试都在哪儿:卡片显示、卡片编辑器、标签、换行、搜索。全都是看板。没有一个碰到编辑器,而编辑器才是真正的产品。

编辑器是把 TextKit 2 之上的 STTextView 包进一个 NSViewRepresentable,在辅助功能树看来,它就是一整块装着文本的不透明区域。多光标、代码片段占位符、代码折叠、基于 tree-sitter 的增量高亮、括号匹配:这些都不作为可查询的元素存在。你没法断言第二个光标落在第四十行,因为根本不存在一个叫「第二个光标」的元素。

所以那部分工作不是通过界面来测的。它是在界面底下测的。MeatPadKit 是一个独立的 Swift 包,47 个源文件,装着折叠扫描器、多光标模型、代码片段解析器、高亮器、模糊匹配器、LSP 位置桥接和项目符号索引。里面没有任何视图代码,而这正是 601 个测试能在十一秒里跑完、屏幕上连一个窗口都不出现的原因。

MeatPad 某个 UI 测试开头的那段注释,把这种分工讲得比我们转述得更好:匹配器本身由 MeatPadKit 做单元测试,而它够不到的,是那个输入框到底有没有接到列上。这就是一个 SwiftUI UI 测试的全部岗位职责。不是「逻辑对不对」,那个快速的无窗口测试早就回答了,而是「有没有接上」,这个问题别的什么都答不了。

从一条笔记开始。 因编辑器而留下。 MeatPad 是一个 macOS 原生的笔记本兼代码编辑器:你 Mac 上的普通文件,无账户,无同步引擎,无遥测。

了解 MeatPad

这实际上会改变你构建东西的方式

教训不是 SwiftUI 不好。而是验证一个原生界面的成本高到足以成为架构的输入项,而不是事后才想起来的东西。

  • 把逻辑往视图外面推,推到视图变得无聊为止。 MeatPad 有 51 个界面源文件和 47 个框架源文件,而框架那边扛着 95% 的测试。留在 SwiftUI 视图里的任何东西,检查起来都很贵,所以留在那儿的合适分量就是布局。
  • 把界面测试花在接线上,别花在行为上。 每个十二秒,它们是验证边界情况的错误工具,却是验证「搜索框接到了这一列」的正确工具。
  • 在写视图的时候加标识符,别等到写测试的时候。 事后补加意味着为了让一个测试成为可能而去改生产代码文件,而那正是人们不再写测试的时刻。
  • 如果你的界面是一个 webview,就把这顿免费午餐吃下去。 能在自己正在运行的应用程序内部跑一次查询,是 Tauri 这套技术栈实打实的优势,也是 MailVault 能在一个月里补上 31 个端到端规格的原因。

哪些没有覆盖到

两个如实交代的窟窿,因为一篇只报喜的测试文章就是一份宣传册。

MeatPad 的编辑器完全没有界面层面的覆盖。底下的逻辑测得很扎实,而这套逻辑与文本视图之间的胶水由人来检查。那里出现的回归会一路进到发行版。用少量昂贵的测试,针对那些值十二秒一次的交互,把这个问题一点点啃掉,是下一步的事。

在 MailVault 这边,只有无窗口的那套跑在持续集成里。41 个预置规格和 5 个本地规格跑在 mini 上,按需运行,也就是说它们是在发布之前而不是在合并之前抓住问题。而一套绿灯依旧只能证明有人想到要去检查的那些事,所以那张表里的数字,其实不如「这个数字还在动」这件事重要。

以上任何一项都可以自己复现

# MailVault
git clone https://github.com/GraphicMeat/mail-vault-app
npm install
npm test                    # 1,218 unit and component tests
npm run test:integration    # 64 integration tests
npm run test:imap           # 158 Rust tests, core and daemon
npm run test:e2e            # builds the app, drives it through WebDriver

# MeatPad
cd MeatPadKit && swift test # 601 tests, no window required
xcodebuild test -project MeatPad.xcodeproj -scheme MeatPad \
  -destination "platform=macOS" -only-testing:MeatPadUITests

最后那条命令就是要花两分钟的那一条。现在你知道为什么了。

两个应用,一台让它们保持诚实的机器。 来自一家会公布自己测量数据、包括不好看的那些数据的工作室的本地优先 Mac 软件。

看看我们做的东西