开发者对于为自己的应用写测试有自己的动机。虽然我认为应该写测试,但是这篇文章不是来劝说你来做这个的。
为一个 app 的表现层写测试是一件棘手的工作。Apple 对于对象的逻辑测试已经有内建的支持,但是却没有支持测试那些界面代码的结果。这个功能上的鸿沟实际上造成了很多开发者因为界面测试的复杂性而选择忽视它。
当 Facebook 发布 FBSnapshotTestCase
到 CocoaPods 的时候,我起初还因为这个理由忽视了它, 还好我的同事没有。
基于界面的测试意味着验证你用户最终看到的是不是你希望用户看到的。测试界面可以保证不同版本,不同状态的视图看起来可以保持一致。界面测试可以用来提供一个高级别的测试,这涵盖了很多相关对象的用例。
FBSnapShotTestCase
将一个 UIView
或者 CALayer
的子类渲染为一个 UIImage
。这个截图被用和一个已经保存了的截图进行比对,从而创建测试并生成测试的版本。当测试失败的时候,将创建一个失败的测试的参考图片,并且创建一个另外的图像来表现两者的不同之处。
这是一个失败的测试的例子,原因是我们的一个 View Controller 中 gird 元素比预期的要少:
它通过将 view 或者 layer 以及已经存在的截图渲染到两个 CGContextRefs
,并且用 C 函数 memcmp()
来进行内存比较。这样的比较会非常快,我在一台 MacBook Air 上生成 iPad 或者 iPhone 的全屏截图并进行测试,每张图耗时在 0.013 到 0.086 秒之间。
当配置好以后,它默认会将参考图片存储到你项目的 [Project]Tests
目录里面的一个叫 ReferenceImages
的子文件夹里。文件夹中是根据你的测试用例的类名建立的文件夹,在测试例文件夹中是每个测试的参考图片。当一个测试失败的时候,它会将失败的结果存储下来,另外再存储一张这个结果和参考图片的差异对比所生成图片。三张图片都会放到应用的 tmp 目录下,截图测试同时会用 NSLog
在控制台输出一条命令,你可以用这条命令来启动 Kaleidoscope 并进行可视化的比较。
我们就不在这里兜圈子了:你应该在使用 CocoaPods 吧,所以安装仅仅需要在你的 Podfile 的测试 target 里面加入 pod "FBSnapshotTestCase"
。运行 pod install
就可以安装这个库了。
默认的截图测试需要继承 FBSnapshotTestCase
而不是 XCTestCase
,然后使用 FBSnapshotVerifyView(viewOrLayer, "optional identifier")
宏来和已经存在的图片验证比较。这里的子类有一个 recordMode
的 boolean 属性。当设置了这个值的时候,会录制一个新的截图而不是把结果和参考图片做比较。
@interface ORSnapshotTestCase : FBSnapshotTestCase
@end
@implementation ORSnapshotTestCase
- (void)testHasARedSquare
{
// Removing this will verify instead of recording
self.recordMode = YES;
UIView *view = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 80, 80)];
view.backgroundColor = [UIColor redColor];
FBSnapshotVerifyView(view, nil);
}
@end
没有事情是完美的。让我们谈谈不好的一面吧。
UIView
类不能在没有 frame 的时候初始化,所以请总是给你的 view 一个 frame 来避免 <Error>: CGContextAddRect: invalid context 0x0. [..]
这样的错误信息。 如果你使用了很多 Auto Layout 代码,那么就不会那么简单了。基于 CATiledLayer
的 view 需要在 main screen 上并且在渲染瓦片 (tiles) 前被展现出来。它们同样是异步渲染的。我一般为这些测试加入 两秒等待。UILabels
的截图都需要重新录制。FBSnapshotTestCase
渲染的视图,这省去了很多开发时间。我没有使用原生的 XCTest。我用的是 Specta 和 Expecta,因为使用的时候更加简单,可读性也更强。这是你在创建一个新 CocoaPod 的时候的初始配置。我是 Expecta+Snapshots 这个 pod 的贡献者,它为 FBSnapshotTestCase
提供了一个类似 Expecta 的 API。它会为截图命名,同时可以在视图的生命周期里面选择性运行。我的 Podfile 看起来是这样子的:
target 'MyApp Tests', :exclusive => true do
pod 'Specta','~> 1.0'
pod 'Expecta', '~> 1.0'
pod 'Expecta+Snapshots', '~> 1.0'
end
然后,我的测试看起来会是这个样子的:
SpecBegin(ORMusicViewController)
it (@"notations in black and white look correct", ^{
UIView *notationView = [[ORMusicNotationView alloc] initWithFrame:CGRectMake(0, 0, 80, 320)];
notationView.style = ORMusicNotationViewStyleBlackWhite;
expect(notationView).to.haveValidSnapshot();
});
it (@"Initial music view controller looks corrects", ^{
id contoller = [[ORMusicViewController alloc] initWithFrame:CGRectMake(0, 0, 80, 80)];
controller.view.frame = [UIScreen mainScreen].bounds;
expect(controller).to.haveValidSnapshot();
});
SpecEnd
解析 console 里面的日志来找到图片要花不少力气,装载不同的失败测试到一个可视化的工具比如 Kaleidoscope 里,需要运行不少命令行程序。
为了处理几乎所有这些常见的场景,我写了一个 Xcode 插件 Snapshots。它可以通过 Alcatraz 安装或者自己编译。它可以让在 Xcode 中失败测试的失败和成功的图片的比较变得非常容易。
FBSnapshotTestCase
给你一个测试视图相关代码的方法,它可以用来测试视图相关的状态而不用依赖于模拟器。如果你使用 Xcode 的话,你可以考虑和我的插件 Snapshots 一起使用它。有些时候它可能会让人很烦,但是这还是值得的。它可以让设计师参与代码审查阶段,也可以成为为现有项目写测试的简单的第一步,你可以试一试。
开源项目案例: