90 张截图:六种语言(en、ru、es、ja、zh-Hans、ar),分为两套——网站和 App Store——每套各两种主题。手工完成 MeteoHealth 的拍摄清单要花好几天。而且问题不只是时间:人会忘记恰好在「分析」渲染出关联之后、而不是之前,进入「睡眠」详情页。
我基于 XCUITest 搭了一套 harness——四个测试类,合起来覆盖 90 张中的 86 张,全程没有一次人工点击。剩下四张是用摄像头测脉搏的画面:模拟器里物理上就没有摄像头。下面讲讲这座工厂是怎么运作的,以及除了截图本身它还发现了什么。
藏在 launch argument 背后的数据播种器#
跑真实用户的档案是行不通的:它没有回溯 30 天的历史,指标之间没有关联,而且最关键——每个人的档案都不一样。
演示档案 ScreenshotSeederSite 完全藏在 launch argument MH_SCREENSHOT_SEED 背后,在普通构建里什么都不做——它是一个 no-op,没有显式启动参数就物理上不会执行的代码。播种器写入周期、吸烟记录、带服药历史的药物、饮水、能量和压力的历史——另外,在第二个参数 MH_SCREENSHOT_SEED_HEALTH 背后,写入 30 天的 HealthKit 数据:带阶段的睡眠、HRV、静息心率、步数。
这样拆分并非偶然:「睡眠」和「压力」两个界面直接读取 HealthKit,在 Core Data 里没有自己的副本。没有它们,这两个界面在截图里就是空的——这一点不是从文档里学到的,而是从第一次跑出黑色卡片的截图里学到的。
func test_0_prepareDemoProfile() {
let app = XCUIApplication()
app.launchArguments += [
"SKIP_ONBOARDING",
"MH_SCREENSHOT_SEED",
"MH_SCREENSHOT_SEED_HEALTH", // 仅在此处:参见 ScreenshotSeederSite
"-AppleLanguages", "(en)",
"-AppleLocale", "en_US",
"-\(themeDefaultsKey)", Theme.light.rawValue
]
app.launch()
acceptAllHealthSheets(app)
sleep(25) // 将种子写入 Core Data 和 HealthKit
...
}整个测试集中唯一会看到系统「健康」访问授权对话框的测试就是它,而且按名称排在最前面(test_0_)。之后权限已经授予,通过非空数据库检查跳过重复播种,该类剩下的 340 行代码再也不会遇到这个对话框。
用路由取代按文本查找#
第 6–13 张截图是「今天」页面的详情弹层(睡眠、压力、周期、饮食、药物、吸烟)加上分析的各个板块。打开「今天」然后按文本点击找到卡片是行不通的:卡片是动态排序的,顺序因语言而异。一个按英文位置查找「睡眠」的测试,在日语截图上会打开别的东西。
解决办法是彻底绕开 UI 查找。界面直接通过 launch argument 打开:
private func captureTodaySheet(_ route: String, name: String, language: String, locale: String, theme: Theme) {
let app = launch(
language: language, locale: locale, theme: theme,
extraArguments: ["MH_SCREENSHOT_ROUTE", route]
)
sleep(4) // 弹层从 onAppear 打开,内容随后加载完成
shoot(name)
app.terminate()
}分析板块的机制相同,只是用自己的参数 MH_SCREENSHOT_ANALYTICS。「预报」这张截图有个微妙之处:拍摄之前,测试会先进入「分析」停留 14 秒,然后才切换到「预报」。
原因不是迷信——关联只计算一次,在打开分析时(CorrelationEngine.correlations 存活在引擎的内存里),而「预报」上的风险卡片读取的是已经算好的结果。没有这次预先访问,界面会诚实地渲染出「正在学习你的节律」,无论数据库里躺着多少历史数据。
SpringBoard——敌对领地#
第 5 张截图——主屏幕上的小组件——是整套截图中唯一不在应用里、而在 SpringBoard 里的部分,这改变了一切。462 行的 SiteWidgetsUITests 是工厂里最长的文件,其中大部分篇幅都是在防御 SpringBoard 不按预期回应的情况。
三个陷阱,每一个都不是在文档里发现的,而是靠一整套截图报废才发现的。
第一个——在模拟器以新语言重启之后,springboard.icons 立即返回空列表,尽管小组件物理上就在屏幕上:SpringBoard 还在重建主屏幕,而查询已经给出了应答。治它的不是超时,而是重试:
private func existingWidgetCount() -> Int {
var count = 0
for attempt in 0 ..< 6 {
count = springboard.icons
.matching(NSPredicate(format: "identifier == %@", "MeteoHealth"))
.allElementsBoundByIndex
.filter { $0.frame.width > 200 }
.count
if count >= 2 { return count }
if attempt < 5 { sleep(4) }
}
return count
}代码里的注释诚实地写明了第一版的代价:测试相信了空的应答,跑去删除并重新添加本来已经放好的小组件,然后卡在了删除确认上——而卡住它的正是第二个陷阱。
第二个——系统弹窗的按钮经常返回 isHittable == false,对它们做普通的 tap() 会悄无声息地什么都不做:不崩溃,不报错,循环就这么空转到超时。正是这个机制毁掉了 widgets-es 那套截图:删除弹窗一直挂着,整轮拍摄都撞在它上面。绕过的办法到处都一样——点按元素中心的坐标:
func tapCenter(_ element: XCUIElement) {
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}第三个——完全不依赖文本的寻址。打开主屏幕编辑菜单的「+」按钮,在俄语里叫「+」,在西班牙语里叫「Editar」——取决于系统语言,而系统语言每张截图都在变。唯一可行的路径是坐标式的,锚定在屏幕几何上,而不是文本上。
RTL 与 iPad:连页面顺序都会镜像的地方#
阿拉伯语带来了镜像问题。SpringBoard 小组件配置器里的样式轮播只从右向左滑动,而在 RTL 界面里这个方向意味着「后退」。连续八次尝试返回的都是同一个已添加的样式,函数诚实地报告「没找到」,于是 widgets-ar 那套截图只有一个小组件而不是两个。修法——两个方向都遍历:
let directions: [(from: CGFloat, to: CGFloat)] = [(0.85, 0.15), (0.15, 0.85)]
for direction in directions {
for _ in 0 ..< 8 {
// ...
}
}主屏幕页面同理:换语言之后,不知道往哪个方向滑动才能到达小组件,所以拍摄会遍历两个方向,而不是赌一个。
iPad 增加的维度不是语言,而是布局。那里标签栏在顶部而不是底部,为 iPhone 底部栏写的坐标 fallback 会打开错误的标签——比如打开能量卡片而不是目标界面。解决方案比坐标优雅:原生 TabView 会以 tabItem 里 SF Symbol 的名称作为标识符暴露标签按钮——一个鲜为人知但可靠的事实,在两种布局上都成立:
let order: [(symbol: String, id: String, slot: Int, name: String)] = [
("cloud.sun.fill", "tab_forecast", 1, "5_forecast"),
("chart.xyaxis.line", "tab_analytics", 3, "3_analytics"),
("book.fill", "tab_journal", 2, "4_journal")
]坐标只留作 iPhone 的 fallback。滚动也走了同一条路:swipeUp() 在 iPad 上沉重的分析仪表盘上会超时——它需要整个层级的快照;坐标拖拽不构建快照。两种设备的文件按 iPhone_/iPad_ 前缀区分,依据是 UIDevice.current.userInterfaceIdiom。
拍摄本身的陷阱到此为止。更难的问题不是「怎么拍下这一帧」,而是「这一帧上应该展示什么」。
演示数据必须通过统计检验#
工厂最不平凡的发现——演示数据也是代码,而且是背负义务的代码。「预报」界面应该展示关于气压与身体状态之间关联的有内容的结论,而不是「正在学习你的节律」。播种器的第一版把关联写成阶跃形式:「下降 → 2…4,否则 6…9」。
在 30 天数据上这给出 r ≈ 0.45,原始 p ≈ 0.012——看起来是显著的。但引擎会对所有被检验的因素对跑一遍 Benjamini-Hochberg 校正,这样的因素对大约有一百个,校正之后 p-value 就沉底了。界面诚实地把自己的结论标记为初步结论——就在给 App Store 的截图上。
修正用线性关系替换了阶跃:r ≈ 0.72,t = 5.5,个体评分的离散度保持完整——从 1 到 10。这样的关联能通过校正,给出确定的判定。
第二个案例性质相同但方向相反:关联出现在没人预订的地方。播种的能量历史在相位上与温度波动重合,于是「关联」页面开头的一行是「温度 → 能量 +0.96」——一个在真实数据上不会出现的系数。能量历史用一条独立的波动与温度解耦,让排在第一位的是预期的关联——「气压变化 → 身体状态 +0.72」。
给任何演示数据工厂的教训:如果应用展示统计结论,演示档案就有义务生成能诚实通过与真实用户数据同一套统计检验的数据。但统计并不是一张诚实的截图会和盘托出的唯一东西。
截图找到测试找不到的东西#
项目里有 900 多个绿色的测试,却没有一个抓到那八个真实缺陷——它们是在普通地翻看拍好的截图时发现的。测试验证代码做了设计的事;截图展示用户看到的东西——两者并不总是一回事。
发现的问题包括——日记列表里的症状渲染成了原始键 symptom.jointPain,而不是本地化后的「Joint Pain」:详情界面早就做了本地化,但列表行把数组原样拼接了出来。今天开始的药物疗程显示 0% 依从性——日期「原样」相减得到零个完整天数,这个零把分母里的百分比归零了。日语和中文的摘要小组件上挂着「Good Sleep」字符串,而不是翻译后的因素名称——传给快照的是英文 factor.name,而界面渲染用的是 localizedName。
这些 bug 没有一个属于拍摄逻辑——它们全在产品里。能注意到它们,只是因为截图在六种语言上同时存在,而不是因为有人对某个具体字符串写了 assert。
除了省时间,这一切还为了什么#
自动化截图的回报不只是时间,尽管把几天手工劳动压缩成跑一个测试 target 本身就是分量十足的理由。它迫使你用用户的眼睛同时在所有语言、两种主题、两种设备布局上审视应用——这个视角是逻辑测试在结构上覆盖不到的。用 launch argument 取代 UI 查找,消除了对屏幕元素顺序的依赖。坐标点按和两个方向的滑动是 SpringBoard 和 RTL 的必备下限——那里文本读不了,元素还会在 isHittable 上撒谎。还有一条专属于内置统计的应用的教训:演示数据是产品代码,它们必须通过与真人数据同样的显著性检验。



