尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

iOS快照测试实战指南:从原理到CI集成与踩坑记录

iOS快照测试实战指南:从原理到CI集成与踩坑记录 先说个每天都在发生的场景你改了一个按钮的圆角或者调了一个 cell 的间距代码 review 时没人发现问题因为差异在视觉层面实在太细微了。用户却在版本更新后说“这个界面看起来怪怪的”。这种问题你想靠什么手段拦住人工回归看心情XCUITest 只能断言“控件存在”“页面跳转”颜色、圆角、字重、阴影这些像素级差异它完全无感。这时候就该轮到你真正认识iOS 快照测试了。快照测试的思路有点像给菜拍照留底——把 UI 渲染结果保存成一张参考图以后每次改动都用新渲染图跟参考图做像素级对比超差就失败。它解决的是“视觉回归没人盯”这个老大难问题特别适合有设计规范、组件库、多业务线并行迭代的团队。这篇内容是我在多个项目里落地快照测试的经验总结从原理、工具选型到 CI 集成、踩坑实录都会讲到。不管你是刚接触自动化测试的 iOS 开发还是已经在用 XCUITest 但觉得覆盖不够的同学这篇都能直接给你一套可抄的作业。1. 快照测试到底在测什么1.1 原理层面的拆解渲染结果对比的本质先别把快照测试想得太玄。本质上它做的就是三件事把某个UIView或UIViewController放到内存里完成布局和渲染。用系统渲染管线把它画成一张UIImage再转成 PNG 数据。和工程里预先存放的“基线图片”做逐像素对比超过阈值就报错。所以它测的不是业务逻辑而是“这段代码组合出来到底长什么样”。我举个例子LoginButton有一个cornerRadius属性如果某天有人在初始化方法里顺手写成了layer.cornerRadius 0你会发现代码逻辑完全没坏UIButton也能点但视觉上按钮变成了直角。普通单元测试和 XCUITest 都测不出这种问题快照测试却在跑测试的第一秒就会把这张残废的按钮图甩到你面前。从渲染链路看快照测试越接近真实用户看到的效果价值就越大。这也是为什么我建议快照的对象尽量用“完整页面”而不是“纯视图代码”——UIViewController的view会触发viewDidLoad、layoutSubviews、trait collection 变化等一整套生命周期比单独拿个UIView硬拼更接近线上表现。当然隔离单个组件也有它的用途后面我会说到。1.2 快照测试能解决的三大痛点在团队里推快照测试我觉得它最大的价值集中在三个点这也是说服同事和领导最有用的“卖点”第一视觉回归的自动化覆盖。UI 回归测试最尴尬的地方在于“改完代码不知道有没有影响其他地方”。你改了一个全局主题色理论上所有用到UIColor.primary的地方都会变但你没法手动把所有页面都跳一遍。快照测试可以因为每个关键页面都有基线主题色一变第一批挂掉的就是测试用例而不是用户反馈。第二设计规范的“带薪监督员”。组件库、设计系统这类东西特别容易在实际迭代中走样。按钮原来 8pt 圆角后来某个业务方为了“突出”改成了 12pt如果没人发现这就是规范的一次无声溃败。把组件全部纳入快照测试后任何偏离基线的改动都会在 MR 阶段弹出来逼着人面对“你是故意改变视觉还是不小心动了全局样式”这个问题。第三跨版本回归的存量保护。iOS 系统升级、Xcode 版本升级、字体库更新这些外部变化经常带来“无感的视觉漂移”。靠人眼是盯不住的但快照测试会在这种时候狂刷红线逼着团队全面评估这次升级对 UI 的影响。1.3 什么场景不建议用快照测试快照测试不是银弹。我见过不少团队一上来就铺几百个控件结果维护成本爆炸最后整个模块被废弃。有几类场景我强烈不建议硬上高频变动的页面。比如带轮播、带倒计时、带大量网络图片的首页。你当然可以通过 mock 数据把所有变量变得可控但如果这个页面每个迭代都在大改维护基线的成本会远超收益。依赖系统私有 API 的复杂控件。地图、WKWebView、视频播放器这类东西渲染结果受外部因素影响太大快照容易三天两头假失败。尚未稳定的新功能。功能还在疯狂改交互的阶段你花一晚上录的基线第二天可能就作废了。我习惯在新功能视觉冻结之后再补快照而不是边画边测。快照测试适合用来“守成”不适合用来“探路”。明白了它的边界后面搭起来心里才有底。2. 工具选型两个主流方案的取舍iOS 生态里做快照测试绕不开两个库老牌的iOSSnapshotTestCase原 FBSnapshotTestCaseUber 维护和新兴的Swift Snapshot TestingPoint-Free 出品。两个我都用过各有各的脾气下面做个对比。2.1 iOSSnapshotTestCase老牌稳重的像素对比FBSnapshotTestCase 从 Facebook 时代就开始用后来 Uber 接手改名iOSSnapshotTestCase。它是 Objective-C 时代的产物但完全支持 Swift用起来非常直接测试类继承FBSnapshotTestCase调一个FBSnapshotVerifyView(view)就完事了。它的特点一是稳多年沉淀下来处理了很多边缘情况二是有**全局容差tolerance**设计默认允许 0.0 的像素差异你可以在用例级别调tolerance 0.02这类值让系统帮你在一定百分比内容忍渲染误差。这对抗锯齿、字体 hinting 造成的轻微差异非常有用。一个比较典型的用例长这样import FBSnapshotTestCase final class LoginButtonSnapshotTests: FBSnapshotTestCase { override func setUp() { super.setUp() // 需要记录新基线时临时打开这个开关 // recordMode true } func testLoginButtonDefaultState() { let button LoginButton() button.frame CGRect(x: 0, y: 0, width: 200, height: 48) button.title 登录 FBSnapshotVerifyView(button) } }注意FBSnapshotVerifyView默认会以函数名 设备类型作为图片文件名。同一份测试跑到 iPhone 15 和 iPhone SE 上会生成不同的参考图因为它觉得不同屏幕下的渲染结果就该分开管。这逻辑没毛病但也意味着你得处理好参考图目录否则很容易出“多出一堆不知道哪台设备用的图片”的混乱。2.2 Swift Snapshot Testing灵活声明式的后起之秀Swift Snapshot Testing 是 Point-Free 那一帮人的风格——能用协议抽象解决的绝不用基类。它不限定你只能截 UI字符串、Data、JSON、WKWebView都能做快照再加上一个巨大优势通过策略模式Snapshotting协议你可以自己定义任何类型的快照方式。UI 快照的用法如下import SnapshotTesting import XCTest final class LoginButtonSnapshotTests: XCTestCase { func testLoginButtonDefaultState() { let button LoginButton() button.frame CGRect(x: 0, y: 0, width: 200, height: 48) button.title 登录 assertSnapshot( matching: button, as: .image(precision: 0.99) ) } }precision参数表示“至少 99% 的像素一致才算过”也支持perceptualPrecision感知精度这个选项对细微抗锯齿差异更宽容。我个人很喜欢它的record模式设计通过测试用例侧的环境变量或isRecording属性控制而不是改一堆代码。Swift Snapshot Testing 还有一个好处是失败信息更清晰。它会在测试失败时自动生成一张对比图把基线、实际渲染、差异区域显示得明明白白。这在多人协作时特别重要——打开 CI 的失败报告就能直接看到问题图不用谁专门去找IMAGE_DIFF_DIR下的文件。2.3 两个方案怎么选这里给一张对比表格方便你按团队情况对号入座对比维度iOSSnapshotTestCaseSwift Snapshot Testing维护方Uber历史久、稳定Point-Free社区活跃快照类型基本限 UIView/UIViewController任意类型字符串、Data 都行容差控制tolerance百分比precision/perceptualPrecision失败信息diff 图 文件名自动对比图信息更直观动态类型支持需自己处理 trait支持 traitCollection 策略语言风格Objective-C 底蕴Swift 可用纯 Swift协议化设计我的建议如果你们团队有老牌代码库、测试代码存量多我建议选 iOSSnapshotTestCase毕竟它在成熟度和稳定性上更让人放心如果是新项目、纯 Swift 团队、追求代码简洁度Swift Snapshot Testing 会更顺手。如果两边都在观望那我的私心是 Swift Snapshot Testing 对现代 Swift 支持更舒服而且 Point-Free 一直在迭代文件的 diff 和管理也做得更细。测试库本身最终都会跟工程深度耦合所以别频繁换。选定之后让全团队统一认知把这套作为 UI 测试的固定基础设施来维护。3. 实操全流程从零搭建一套可用的快照测试很多人以为快照测试就是“装个库、写个用例、跑一下”这么简单但真正落地到 CI 上、让团队长期持有坑多得能让你怀疑人生。下面我把完整流程拆开逐步说清楚。3.1 工程配置与依赖安装以 Swift Package Manager 安装 Swift Snapshot Testing 为例直接在Package.swift里加dependencies: [ .package(url: https://github.compointfreeco/swift-snapshot-testing.git, from: 1.17.0) ]CocoaPods 用户则在Podfile里加pod SwiftSnapshotTesting然后pod install。重要的是测试 target 的配置。我强烈建议单独建一个SnapshotTests的 bundle而不是把快照测试混进普通单元测试里。理由很简单快照测试需要独立的目录管理参考图而且经常按模块拆分混在一起日志和失败信息都非常难找。然后是参考图的目录结构。推荐放到SnapshotTests/ReferenceImages或跟随项目目录名。Swift Snapshot Testing 默认会在__Snapshots__目录下按测试模块、测试类、测试方法三级结构存放你基本不用手动干预。而 iOSSnapshotTestCase 则依赖环境变量FB_REFERENCE_IMAGE_DIR来定位参考目录需要在 scheme 里设置好。在 scheme 的 Test Action —— Arguments —— Environment Variables 中添加FB_REFERENCE_IMAGE_DIR $(SOURCE_ROOT)/SnapshotTests/ReferenceImages IMAGE_DIFF_DIR $(SOURCE_ROOT)/SnapshotTests/DiffImagesSOURCE_ROOT是 Xcode 的内置变量指向工程根目录。这样无论谁在本机跑还是 CI 跑只要 checkout 出来的目录结构一致参考图路径就不会飘。3.2 编写第一个快照用例从最小闭环开始。我选一个纯组件——自定义登录按钮。先建测试文件LoginButtonSnapshotTests.swift代码如下import SnapshotTesting import XCTest final class LoginButtonSnapshotTests: XCTestCase { func testLoginButtonDefaultState() { let button LoginButton() button.frame CGRect(x: 0, y: 0, width: 200, height: 48) button.updateState(.normal) button.layoutIfNeeded() assertSnapshot( matching: button, as: .image(precision: 0.99) ) } func testLoginButtonDisabledState() { let button LoginButton() button.frame CGRect(x: 0, y: 0, width: 200, height: 48) button.isEnabled false button.layoutIfNeeded() assertSnapshot( matching: button, as: .image(precision: 0.99) ) } }这里有两个细节容易被新手忽略第一必须调用layoutIfNeeded()。快照测试在你没有主动触发布局时拿到的可能是一张空白或者布局没完成的视图。尤其是用 Auto Layout 约束的控件不强制刷新布局渲染出来的东西完全不对。第二状态要显式给定。你看到我调用了updateState(.normal)而不是直接依赖默认状态。这是为了避免前一个用例改过的属性影响当前用例——测试用例之间默认共享着同一个进程环境状态污染的问题在快照测试里特别常见最好每个组件都有一个“定义良好”的初始状态。3.3 记录基线第一次生成参考图的完整流程新用例第一次运行时不会做对比而是提示缺少参考图。所以你需要先走一遍“记录基线”的流程。以 Swift Snapshot Testing 为例在测试方法里临时加上func testLoginButtonDefaultState() { ... isRecording true assertSnapshot(matching: button, as: .image(precision: 0.99)) }或者通过环境变量SNAPSHOT_TEST_ARTIFACT_ID/ 直接设record true各版本略有差异具体看文档跑一次测试就会在__Snapshots__目录下生成对应的 PNG。记录完基线最重要的一步是人工检查这张 PNG。我见过太多新手直接“记录完就提交”后来基线里混进一张开发中的半成品图害得整条流水线在 CI 上挂了一周。你要做的是把每一张新生成的 PNG 打开按 100% 缩放看一遍布局对不对、字体渲染对不对、有没有动态内容泄漏。确认无误后再提交参考图到 Git。iOSSnapshotTestCase 也类似设置recordMode true再跑测试会在FB_REFERENCE_IMAGE_DIR目录下生成基线。跑完记得把recordMode改回false否则以后每个测试都是“只记录、不校验”整个快照测试就形同虚设了。提交后推荐用git diff看一眼参考图变更。如果一张基线图莫名其妙地变了那一定是代码里有什么全局影响到了它这时候不要直接接受新基线先去定位改动原因。很多时候你新写的代码会影响多个页面的渲染你会立刻看到一堆“旧的正常图”变成了“新的被改过的图”这种时候很容易产生“要么全改要么全不改”的心态。我的建议是一律逐个看 diff确认这个改动是真的预期行为后再批量接受。3.4 在 CI 上跑起来环境变量与模拟器细节本地跑通了只是第一步快照测试真正的价值在 CI。拿 GitHub Actions 举例一个典型的工作流片段- name: Run snapshot tests run: | export FB_REFERENCE_IMAGE_DIR$(pwd)/SnapshotTests/ReferenceImages export IMAGE_DIFF_DIR$(pwd)/SnapshotTests/DiffImages xcodebuild test \ -workspace MyApp.xcworkspace \ -scheme MyApp \ -destination platformiOS Simulator,nameiPhone 15,OS17.5 \ -only-testing:SnapshotTests这里有个门道模拟器的型号和 iOS 版本必须跟基线生成时严格保持一致。快照测试对渲染链路极度敏感iPhone 15 和 iPhone 14 Pro 虽然屏幕逻辑尺寸接近但像素密度、渲染抗锯齿算法都可能不同一旦换模拟器轻则大面积 diff 重则全部失败。所以在 CI 上用match锁死两台构建机的模拟器版本是一个值得做的工程决策。我建议在 CI 配置里固定好destination并在 README 里写清楚“基线生成规格”什么 Xcode 版本、什么 iOS 模拟器版本、什么机型。这些信息八字没一撇的时候你觉得啰嗦等到全团队的人各自用 iPhone 15、iPhone 16、iPhone SE 录了一遍基线你就会明白什么叫“混乱是阶梯”。CI 上另一个问题是性能。快照测试比普通单元测试慢得多因为要做真实的视图渲染和图片编码。几十个用例可能就要跑一分钟以上。所以你可以在-only-testing里只跑被改动的模块或者在推送分支时不跑完整快照集PR 阶段只跑影响模块合并主干时再跑全量。4. 快照测试常见问题与排查技巧实录这一节的内容才是真正的价值所在。以下每一个问题都是我或同事在真实项目里踩过坑、翻过车的我会把现象、原因和解决手法都写明白你直接拿去用就行。4.1 同一套代码在不同机器上结果不同现象本地跑全绿CI 上一堆失败同事跑绿了你跑却挂了一半。原因渲染环境差异。最可能是模拟器型号/系统版本不一致其次可能是 Xcode 版本差异带来的 UIKit 渲染变化甚至连系统“文字大小”偏好、辅助功能设置被改了都能影响结果。解决方式优先级从高到低统一 CI 和本地的模拟器规格机型、iOS 版本、Xcode 版本。在测试setUp里强制固定 trait collection遮盖多态字体的影响override func setUp() { super.setUp() UIView.setAnimationsEnabled(false) // 可选固定动态字体大小 let app UIApplication.shared // 快照测试通常跑在模拟器默认字体大小下 }给对比参数加一点余量Swift Snapshot Testing 的precision从 0.999 降到 0.99或者 iOSSnapshotTestCase 的tolerance设置为 0.02。别小看这个调整抗锯齿差异往往就是那 1% 像素的问题。另外可以尝试关闭“模拟器 - 设置 - 辅助功能 - 增强对比度”这类系统级 UI 调整模拟器恢复出厂设置后再跑。如果还是偶发失败可以在测试环境变量里设置SIMULATOR_DEVICE_NAME等确保机型统一。实在不行用真机跑也是办法但真机型号要固定并且不要让真机处于低电量和极端温度下。4.2 基线图片体积飙升仓库越来越胖现象__Snapshots__目录里 PNG 越堆越多Git clone 越来越慢Git LFS 配额紧张。原因每次新加一个系统版本或新机型就多出一整套参考图。一个 750x1334 的截图可能有 500KB几百个用例就是几百 MB。解决方式我用过有效的方案用 Git LFS 管理参考图目录。常见托管平台都支持设置.gitattributes时把*.png走 LFS。定期清理“孤儿基线”——已经删除的测试方法对应的 PNG。iOSSnapshotTestCase 有一个隐藏技能在测试过程中会把 no-reference 的测试提示出来你可以据此删除不再使用的图片。Swift Snapshot Testing 也建议定期把整个__Snapshots__目录删掉重新录一遍前提是所有基线都是已知且需要的这个操作能显著瘦身。只在主干分支上保存历史基线PR 分支的参考图不落库。快照测试的参考图属于“生成物”可以接受一个脚本批量生成不必每个分支都带图。考虑放弃多机型全量基线。只保留一个“主力机型 主力系统版本”的完整基线其他机型跑冒烟级别数量的快照。像素级差异本来就与机型强相关真正要做全量机型回归时再临时批量记录基线跑完即弃。这里还有一个个人意见不要用压缩过的 JPEG 当基线。某些库确实支持 JPEG 来省空间但压缩对像素对比影响极大一旦有轻微差异就会被无限放大省下来的空间远不够你 Debug 耗费的时间。4.3 动态内容导致的必现失败从源头屏蔽不稳定因素现象同一个测试一会儿绿一会儿红重新跑结果还不一样或者每次跑必然挂因为页面渲染的时间和图片加载的时机每次都不同。原因页面里有时间、进度条、网络图片、随机数、系统定位正在加载等动态内容。快照测试要求“相同的输入产生相同的输出”不稳定的输入自然带来不稳定的输出。解决方式时间类注入一个“假时钟”。在 App 里定义DateProvider协议生成快照时传入固定日期或者在测试 target 里 swizzleNSDate.date但强烈不建议全局改动容易污染其他用例。网络图片换成固定占位图。用本地 bundle 图片替换URLSession响应或给图片加载器设置UIImage(named: placeholder)并在网络请求完成前就触发渲染。定时器/动画关闭 Core Animation 的隐式动画并在测试里明确禁用UIView动画。你可以加一句UIView.setAnimationsEnabled(false) // 或 UIView.performWithoutAnimation { view.layoutIfNeeded() }随机颜色/形状如果组件有“随机打乱”之类的行为给它一个固定 seed。我的建议是把这些“不稳定因素”全部收敛到 App 自己的依赖注入体系里不要在测试里通过UserDefaults或环境变量开关去硬切。这样快照测试和生产环境的切换成本会降到最低也方便以后加其他测试类型时统一复用 mock 方案。4.4 新增 Xcode / iOS 版本后的大规模翻车现场现象Xcode 大版本升级或 iOS 新版本发布后CI 的流水线全红几百张参考图全部 diff 失败。原因系统字体、抗锯齿算法、控件布局、渲染管线的整体变化。这不是你代码改出来的问题是“外部环境变了”。解决方式也是每个 iOS 团队升级前必须做的准备升级前先把旧基线完整备份到一个 tag 或独立分支。升级后先跑一轮记录模式生成新版本的参考图但不要马上提交。把新旧两套参考图做批量 diff识别出全局变化所有页面都变了通常是系统字体/安全区变化和局部变化只有某个页面变了那多半是你自己的布局依赖了系统私有行为。针对全局变化确认是预期行为iOS 视觉风格本来就变了批量接受新基线针对局部变化逐个查看是否业务受影响。全部确认后提交新基线并在 MR 描述中记录“本次基线更新由 iOS 17.5 升级导致”。这里有个容易犯的错误——升级后第一反应是“让 CI 把 tolerance 调大一点”让测试“放过”这些变化。我劝你千万别这么做。调大容差等于告诉测试“别管渲染细节了”那快照测试的意义就废了一半。正确做法是先分类、再决定接受或修复绝不能和稀泥。4.5 记录模式忘记关闭的“静默绿”问题现象全团队 CI 一片绿但过了几周发现 UI 都改得妈都不认识了快照测试照样绿。检查后发现某位同事开了recordMode true忘关了提交后 CI 不再做任何校验只在本地悄悄更新参考图。原因记录模式下测试不 assert只跑一遍记录新图然后“通过”。解决方式在 CI 脚本里强制加一个“禁止记录模式”的检查例如搜索测试代码里是否包含recordMode true/isRecording true发现就报错。在团队的 Xcode scheme 里提供 Debug 模式给本地记录用Release/CI 用另一个 scheme保证 CI 永远跑在非记录模式。每次合并前安排一个人 review 参考图的git diff。如果某张参考图在“没改业务代码”的 MR 里更新了那就是有人偷偷开了记录模式。这个坑真的让我长记性。后来我还在提交脚本里加了一个小钩子如果在git diff --cached的测试文件里搜到recordMode就提示必须显式用#record之类的临时标记并在合并前移除。最后说点个人体会快照测试的推行过程经常是“蜜月期”之后进入“麻烦期”刚搭好时大家都觉得新鲜所有页面都开始铺用例发现基建完善等遇到假失败、基线膨胀、版本升级翻车后一些团队就会开始怀疑这工具是不是在拖后腿。我的亲身体会是快照测试不是“写一次就一劳永逸”它需要像对待产品代码一样持续维护甚至更讲究纪律性。我现在养成的习惯是每次视觉改动都必须带上“老基线 diff 成新基线”的过程每次系统升级都专门抽半天做基线的批量检查每次新组件进入组件库的第一周就补上快照测试而不是等 UI 冻结后。这样做的回报是很多线上的视觉回归问题在产品交付前就在 CI 阶段被拦住了用户根本不会看到那个圆角变方的按钮。我希望这份实践指南能帮你少走一些弯路。如果你刚踩完某个版本的坑或者有更诡异的失败案例欢迎拿出来交流——快照测试这个东西越往后越能发现它藏着的门道。
返回列表