ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

iOS快照测试实践:从搭建到CI集成的完整指南

iOS快照测试实践:从搭建到CI集成的完整指南 1. 为什么我要在iOS项目里引入快照测试在聊快照测试之前先还原一个场景。你在开发一个电商App的商品详情页改了一个价格标签的字体大小顺手把折扣信息的间距也调了第二天登录界面布局莫名其妙错位或者某个按钮在iPhone 15 Pro Max上被拉伸变形但单元测试全绿、业务逻辑一切正常。这时候你才意识到逻辑测试根本管不住UI长什么样。iOS开发里常见的测试手段有两种。单元测试通过断言验证数据结构和计算逻辑UI测试通过XCUITest模拟点击、滚动、输入最后验证某个元素是否存在。但这两者都有个共同盲区——它们只验证行为和存在性不验证视觉表现。同样的布局代码在不同屏幕尺寸、不同系统版本、不同字体设置下渲染出的结果可能完全不同而这些差异无法用XCTAssertEqual表达。快照测试Snapshot Testing解决的就是这个问题。它的思路很直白先把一个UI组件在特定状态下渲染成一张图片存为基线baseline之后每次跑测试都把新渲染结果和基线图做像素级比对有差异就失败并生成一张带标记的差异图告诉你哪里变了。一旦视觉回归测试会第一时间把你按在座位上。这套方案特别适合这几类项目组件库、设计系统类项目需要保证每个组件在不同状态下的视觉一致性页面改版频繁的App怕老功能被新样式误伤多人协作、设计走查成本高的团队快照测试可以充当自动化设计走查的第一步。市面上主流的工具是GitHub上的iOSSnapshotTestCase原FBSnapshotTestCase由Facebook贡献后来交给Point-Free团队维护支持Swift和Objective-CXcode工程直接集成不需要额外启动服务。下面的内容全部围绕这个库展开包含我从零搭建到接入CI的完整过程以及踩过的几个比较深的坑。2. 快照测试的底层机制渲染比对、容差与失败原理快照测试听起来玄乎核心原理并不复杂只是很多人拿它跟UI测试混为一谈导致用法走偏。搞清楚底层机制后面的使用才能不踩坑。2.1 一张参考图是如何生成和比对的iOSSnapshotTestCase的工作流程可以拆成四个阶段。第一阶段录制Record。你写好一个测试用例调用FBSnapshotVerifyView或Swift里的verify方法并把isRecordMode设为true。这时测试不会做断言而是把当前视图渲染成图片存到指定的ReferenceImages目录下。这张图就是基线。第二阶段校验Verify。把isRecordMode关掉再跑同样的测试。框架会把当前视图重新渲染成一张图然后和基线图做逐像素比对。比对不是简单判断像素值是否完全相等而是统计不一致的像素数量和偏差程度最终得出一个相似度百分比与预设的tolerance阈值比较。第三阶段差异输出。如果比对结果超出容差测试失败并在FailedImages目录下生成两张图一张是本次实际渲染结果一张是加了高亮色块的差异图差异区域会用红色或箭头标出方便你一眼定位是哪块UI变了。第四阶段清理与提交。确认改动是预期的更新基线图删除失败现场图把这个改动提交到仓库。下次所有人拉代码都以新基线为准。这里有个容易误解的点快照测试的快照是拿渲染管线实际输出的位图做比对不是拿UIView的frame数值做比对。这意味着Autolayout约束是否正确、字体是否生效、图层圆角是否裁剪、阴影是否正确偏移——这些视觉层面的问题都会被真实反映出来而这恰恰是传统断言覆盖不到的层面。2.2 tolerance参数不是摆设官方默认允许的容差是0%也就是必须逐像素完全一致才能通过。听起来很严苛实际跑起来你会发现真机上连续两次渲染同一段UI都不可能做到100%像素一致。原因包括字体抗锯齿的亚像素渲染、GPU纹理对齐、CALayer的渲染时机差异等等。所以工程上几乎没有人用默认的0%容差。我的经验是普通视图组件设在0.01~0.02即1%~2%的像素允许细微差异复杂视图如地图、UIStackView嵌套多层圆角阴影的设在0.02~0.05比较稳妥。FBSnapshotVerifyView(view, identifier: normal_state, tolerance: 0.02)但容差不是越大越好。它值越大真实回归越容易被放过。遇到那种整体布局错位的改动比如按钮向右偏移了2个pt这种差异会大面积触发像素不一致容差再大也会被抓出来。容差主要是消化渲染噪音不是给粗心留后门。2.3 失败的三种典型类型跑快照测试一定会遇到失败但失败并不总是坏消息。我总结下来失败有三种类型处理方式完全不同预期内变更UI改动或者设计稿更新导致渲染结果确实变了。处理方式人工确认改动正确更新基线图后提交。非预期回归某段代码改动影响了看似无关的视图测试结果和基线差异大且明显不合理。处理方式修复代码把UI改回去。环境导致假失败字体渲染、系统版本、模拟器分辨率变化导致像素差异但UI本身没问题。处理方式确认后直接更新基线图或调整容差。这三类失败对应着不同的排查路径。很多刚用快照测试的团队遇到失败就急着更新基线图结果真实的回归点被盖住了。后面会专门讲排查思路。3. 搭建iOSSnapshotTestCase环境与写第一个用例的完整过程环境搭建是整个实践中最容易劝退人的环节。这个库名声在外但文档偏简配不对初始化参数、选错target、模拟器配置不对第一轮你会得到大量莫名其妙的失败。下面按我自己验证过的路径一步步来。3.1 集成方式选择集成方式主要有三种CocoaPods、Swift Package Manager、手动导入。我的建议是新项目、项目已在使用SPM直接走Swift Package Manager老项目还在用CocoaPods、且第三方依赖都在Pods里管理走CocoaPods更省事避免两套依赖管理混用带来的冲突。以SPM为例在Xcode里通过File - Add Packages...输入仓库地址https://github.com/pointfreeco/ios-snapshot-test-case选择版本建议用release/1.x分支或最新稳定tag加到对应target即可。有个关键的坑快照测试framework必须加到测试target不是主App target。很多人第一步就加错位置结果主工程编译正常、测试target里import SnapshotTesting直接报错。3.2 配置ReferenceImages目录框架默认会在$(SOURCE_ROOT)/$(PRODUCT_NAME)Tests/ReferenceImages下查找基线图。但这个默认值在不同Xcode版本里偶发不生效我建议显式配置避免后续CI环境出幺蛾子。在测试target的Build Settings里找到SNAPSHOT_TEST_REFERENCE_IMAGE_DIR把它设为$(SOURCE_ROOT)/$(PRODUCT_NAME)Tests/ReferenceImages如果你的测试目录结构比较特殊或者准备把基线图单独放到一个共享目录这里改成自己的路径即可。注意这个目录最终是要提交进Git的基线图是团队共享资产不提交的话所有人在CI上都会得到找不到参考图的失败。再检查一个相关配置SNAPSHOT_TEST_DEVICE_NAME。这个值标注的是当前基线图对应的设备型号比如iPhone 15、iPhone 15 Pro。它主要用于多设备情况下区分不同基线图后面会专门讲多设备策略。3.3 初始化框架与第一个用例在测试target里建一个TestCase基类继承FBSnapshotTestCase在setUp里关闭isRecordMode默认就是关闭的但显式写一次能让后续维护的人看懂意图import FBSnapshotTestCase class BaseSnapshotTestCase: FBSnapshotTestCase { override func setUp() { super.setUp() isRecordMode false usesDrawViewHierarchyInRect true } }usesDrawViewHierarchyInRect我建议直接设为true。它的作用是强制使用drawViewHierarchy方式渲染视图层级而不是快照库默认的renderInContext方式。区别在于renderInContext更接近图层合成结果但有些场景比如UIVisualEffectView的模糊效果、UIStackView的某些布局会渲染不完整drawViewHierarchy更接近屏幕真实显示效果。代价是渲染速度稍慢但稳定性高很多。然后写第一个用例。比如我有一个自定义组件PriceTagView要验证它在促销价状态下的视觉表现import FBSnapshotTestCase final class PriceTagViewSnapshotTests: BaseSnapshotTestCase { func testPromoPriceState() { let view PriceTagView(frame: CGRect(x: 0, y: 0, width: 200, height: 80)) view.configure( price: ¥199.00, originalPrice: ¥399.00, state: .promo ) view.layoutIfNeeded() FBSnapshotVerifyView(view, identifier: promo_state, tolerance: 0.02) } }此时先打开录制模式override func setUp() { super.setUp() isRecordMode true }跑一次测试框架会生成基线图到ReferenceImages目录。跑完把isRecordMode关掉再跑这次就是正式校验了。生成的文件名类似PriceTagViewSnapshotTests_testPromoPriceState_promo_state2x.png。3.4 视图尺寸与window的坑快照测试的对象有两种直接在frame里布局的UIView组件以及需要加载到UIWindow的UIViewController。做组件级快照时必须先设置frame并调用layoutIfNeeded()。很多人不调layoutIfNeeded拿到一张约束未生效的空图然后一脸懵地怀疑框架坏了。实际上layoutIfNeeded会强制同步执行约束计算确保后续渲染用的是最终布局。做控制器级快照时不能只init完就快照。如果控制器的view依赖安全区域、键盘通知、导航栏交互那它必须在window环境中才会正确布局。正确姿势let window UIWindow(frame: UIScreen.main.bounds) window.rootViewController navigationController window.makeKeyAndVisible() navigationController.view.layoutIfNeeded() FBSnapshotVerifyViewController(navigationController, identifier: detail_page)我试过在最开始图省事不建window直接快照push后的controller结果布局全部堆在左上角基线图没法用。这一步省不得。4. 快照测试最常踩的坑更新基线、多设备策略与CI集成环境搭好、用例能跑了真正的工程化难题才刚刚开始。这一节是我觉得整篇文章最有价值的部分因为这些坑都是文档里不会写、只有跑过很多次才会遇到的情况。4.1 错误地更新基线图把更新当修测试前面提过快照测试失败不等于代码有bug也可能是基线过期了。但最危险的情况是代码确实改坏了UI但你觉得反正我看不出来/反正CI要过直接重新录了一条基线图把坏结果当正确结果存进去。这在多人协作项目里是真实发生过的等崩到用户手里才察觉。所以我的团队立了个规矩——跑录制模式必须走diff review流程。具体操作在本地开一个record/update-snapshots分支跑一次全量录制把所有失败的测试重新生成基线用工具图片对比、git diff逐张对比旧基线图和新基线图确认差异是本次需求要的改动确认无误后把新基线提交上来PR里附带说明每张图为什么变动。如果是个人项目至少也要做到先看一眼差异图里标出的红色区域确认改动符合预期再更新。永远不要在失败但没看过差异图的状态下直接更新基线。对了iOSSnapshotTestCase默认的失败差异图生成在~/Library/Developer/CoreSimulator/Devices/{deviceUDID}/data/Containers/Data/Application/{appUDID}/Library/Caches/SnapshotTestImages这个路径下找起来很麻烦。一个实用技巧CI上把失败图统一输出到项目目录下方便下载和review。可以通过环境变量SNAPSHOT_TEST_FAILURE_IMAGE_DIR设置SNAPSHOT_TEST_FAILURE_IMAGE_DIR$(SOURCE_ROOT)/SnapshotTestFailures配好之后每次失败都会在项目目录下留下两张现场图排查效率会高很多。4.2 多设备型号与系统版本的基线管理快照测试最容易被吐槽的点就是换个模拟器就全挂。原因很简单iPhone SE和iPhone 15 Pro的分辨率、安全区域、字体渲染都不一样同一段UI渲染结果天然不同一张基线图不可能覆盖所有设备。处理这个问题的标准方案是按设备模型分目录存基线图。iOSSnapshotTestCase在生成基线图时会读取SNAPSHOT_TEST_DEVICE_NAME配置自动在ReferenceImages目录下额外套一层设备名前缀。所以配置好这个变量后同一套测试逻辑在iPhone 15和iPhone 15 Pro上各自生成自己的基线图互不干扰。不过我的实际经验是不要贪多。全公司所有测试覆盖每个设备型号基线图数量爆炸维护成本极高。更合理的策略是主目标设备选你用户占比最高、UI约束最严格的机型比如iPhone 15 Pro全量用例都跑关键附加设备小屏如iPhone SE和大屏如iPhone 15 Pro Max各跑关键页面用例系统版本以公司最低支持版本为准不追最新系统防止第三方组件在新系统上的渲染差异扰乱基线。还有一点模拟器分辨率设置会影响快照结果。同一个iPhone 15型号如果一个人用2x模式跑测试、另一个人用3x模式跑生成的基线图名称一样但内容不同看起来就会有一堆不明所以的失败。团队里要约定统一的模拟器配置并在CI上固化。4.3 CI集成的关键细节本地跑得快不等于CI能过。快照测试进CI核心要注意三个点录制模式的开关、失败处理流程、运行耗时的控制。录制模式绝对不能出现在CI脚本里。在CI上开录制模式相当于允许测试自己改答案后续所有失败都会变成静默更新基线CI形同虚设。我在CI脚本里加了一个强制检查如果执行命令带了--record参数直接抛错终止。CI脚本用xcodebuild test跑测试指定-destination时一定要固定具体的模拟器型号。比如xcodebuild test \ -workspace MyApp.xcworkspace \ -scheme MyApp \ -destination platformiOS Simulator,nameiPhone 15 Pro,OS17.2 \ -only-testing:MyAppSnapshotTests不固定型号的话CI机器上默认模拟器变了所有快照测试都会因为设备名不匹配而失败且这类失败毫无排查价值。运行耗时方面快照测试比普通单元测试慢很多。一个普通单元测试可能零点几秒一个带复杂视图的快照测试可能要2~5秒。全项目几千个用例跑下来CI任务能被拉到半小时以上。我的经验是并行化按模块拆成多个test target配合Xcode的并行测试能力分级运行PR级别只跑改动模块相关的快照用例全量快照回归放到merge后或夜间跑裁剪UI层级快照的视图不要一整个页面往里塞拆成组件级快照。页面级一个大图一旦失败很难定位到具体是哪个组件变了而且每次渲染耗时也更高。4.4 无法用快照测试覆盖的场景快照测试不是银弹。以下几种场景我尝试过效果不佳或根本不适用建议绕行包含实时内容地图、视频播放器、WKWebView加载的网页内容不停变比对没有稳定基线动画中间帧快照发生在测试线程的特定时刻动画不保证能停在同一个状态断言结果随机Metal/Canvas绘制内容这些内容不经过UIKit的渲染管线快照抓不到或者抓到的是未绘制完成的缓冲强依赖系统日期/时区的视图同一天不同时间、不同时区会显示不同内容基线无法固定。如果一定要测需要注入固定时间源。这些场景可以退回到单元测试里对数据源做断言或者在UI测试里做行为验证。快照测试管好你的视觉回归别试图让它解决所有问题。5. 实际项目里的使用节奏把快照测试嵌入日常开发流程工具再强用错节奏也白搭。我在项目里落地快照测试后总结了一套前后端配合的工作流让它在日常开发里不拖后腿、又能真正拦住回归。5.1 什么时候录制基线什么时候只用校验基线图不是随便录的。错误的基线一旦进了仓库后面所有测试都会基于错的基础上变绿这比没测试还危险。我的做法是只在UI冻结之后录基线。开发页面时先按设计稿把UI做完、各种状态调好和设计同学过一遍视觉确认无误后再开录制模式批量生成基线。这样基线代表的是一份已验收的视觉标准而不是开发时随手长这样的中间状态。如果后续设计稿改了流程是改UI - 跑相关快照测试 - 看到失败 - 人工确认新UI符合新设计 - 更新对应基线图。每一步都有人工确认的节点让每一版基线图都有据可查。5.2 快照测试与人工走查的分工快照测试能发现像素级差异但它无法判断这个改动在审美上是否合适——它只能告诉你变了且变了多少不能告诉你这样变好不好看。所以我把它们分工成两层快照测试层拦截非预期的、无意识引发的UI回归比如某次重构把字体层级弄错了改了一个公共组件导致十几个页面样式跟着变人工走查层聚焦审美判断和交互细节比如新的配色方案好不好看、阴影方向和层级关系对不对、深色模式下的对比度是否达标。自动化在每次代码合并时拦截低级回归人把精力留给真正需要判断力的环节。这是我目前用过效率最高的组合方式。5.3 团队落地时最容易出的管理问题最后聊一个管理层面的经验。快照测试最大的阻碍往往不是技术问题而是团队习惯问题。常见的情况是一开始全员热情高涨录了几百张基线图。两周后有人改了UI跑挂了测试他嫌麻烦直接把基线更新了。一个月后基线图已经失去了意义——大家只求变绿没人看差异图。我后来定了一个简单但有效的制度更新基线图的PR必须附带新旧对比截图。没有附图的基线更新PRreviewer可以直接打回。这个门槛会让每个人都被迫去看一眼自己到底改了什么虽然增加了操作成本但保证了基线图库的长期可信度。反过来说正因为更新基线图有成本大家在开发时会更有意识地少动公共UI组件、提前规划视觉改动反而从源头上减少了回归。如果你也准备在项目里引入快照测试我的建议是不要一开始就铺开全项目先挑一两个界面稳定、视觉细节多的页面做试点把流程跑顺、让团队养成看差异图的习惯再逐步扩到公共组件库和核心页面。这套东西的价值不在一开始而在你坚持跑了半年、改版三次之后——那时候你会感谢那些在CI上拦住过N次无声回归的基线图。
返回列表