objc系列译文(1.3):测试 View Controllers

我们不是信奉测试,但它应该帮助我们加快开发进度,并且让事情变得更有趣。

让事情保持简单

测试简单的事情很简单,同样,测试复杂的事会很复杂。就像我们在其他文章中指出的那样,让事情保持简单小巧总是好的。除此之外,它还有利于我们测试。这是件双赢的事。让我们来看看测试驱动开发(简称 TDD),有些人喜欢它,有些人则不喜欢。我们在这里不深入讨论,只是如果用 TDD,你得在写代码之前先写好测试。如果有什么疑问,可以去看看 Wikipedia 上的文章。同时,我们也认为重构和测试可以很好地结合在一起。

测试 UI 部分通常很麻烦,因为它们包含太多活动部件。通常,view controller 需要和大量的 model 和 view 类交互。为了使 view controller 便于测试,我们要让任务尽量分离。

幸好,我们在更轻量的 view controller 这篇文章中的阐述的技术可以让测试更加简单。通常,如果你发现有些地方很难做测试,这就说明你的设计出了问题,你应该重构它。你可以重新参考更轻量的 view controller 这篇文章来获得一些帮助。总的目标就是有清晰的关注点分离。每个类只做一件事,并且做好。这样就可以让你只测试这件事。

记住:测试越多,回报的增长趋势越慢。首先你应该做简单的测试。当你觉得满意时,再加入更多复杂的测试。

Mocking

当你把一个整体拆分成小零件(即更小的类)时,我们可以在每个类中进行测试。但由于我们测试的类会和其他类交互,这里我们用一个所谓的 mockstub 来绕开它。把 mock 对象看成是一个占位符,我们测试的类会跟这个占位符交互,而不是真正的那个对象。这样,我们就可以针对性地测试,并且保证不依赖于应用程序的其他部分。

在示例程序中,我们有个包含数组的 data source 需要测试。这个 data source 会在某个时候从 table view 中取出(dequeue)一个 cell。在测试过程中,还没有 table view,但是我们传递一个 mock table view,这样即使没有 table view,也可以测试 data source,就像下面你即将看到的。起初可能有点难以理解,多看几次后,你就能体会到它的强大和简单。

Objective-C 中有个用来 mocking 的强大工具叫做 OCMock。它是一个非常成熟的项目,充分利用了 Objective-C 运行时强大的能力和灵活性。它使用了一些很酷的技巧,让通过 mock 对象来测试变得更加有趣。

本文后面有 data source 测试的例子,它更加详细地展示了这些技术如何工作在一起。

SenTestKit

我们将要使用的另一个工具是一个测试框架,开发者工具的一部分:Sente 的 SenTestingKit。这个上古神器从 1997 年起就伴随在 Objective-C 开发者左右,比第一款 iPhone 发布还早 10 年。现在,它已经集成到 Xcode 中了。SenTestingKit 会运行你的测试。通过 SenTestingKit,你将测试组织在类中。你需要给每一个你想测试的类创建一个测试类,类名以 Testing 结尾,它反应了这个类是干什么的。

这些测试类的方法会做具体的测试工作。方法名必须以 test 开头来作为触发一个测试运行的条件。还有特殊的 -setUp-tearDown 方法,你可以重载它们来设置各个测试。记住,你的测试类就是个类而已:只要对你有帮助,随便在里面加 properties 和辅助方法。

做测试时,为测试类创建基类是个不错的模式。把通用的逻辑放到基类里面,可以让测试更简单和集中。可以通过示例程序中的例子来看看这样带来的好处。我们没有使用 Xcode 的测试模板,为了让事情简单有效,我们只创建了单独的 .m 文件。通过把类名改成以 Tests 结尾,类名可以反映出我们在对什么做测试。

与 Xcode 集成

测试会被 build 成一个 bundle,其中包含一个动态库和你选择的资源文件。如果你要测试某些资源文件,你得把它们加到测试的 target 中,Xcode 就会将它们打包到一个 bundle 中。接着你可以通过 NSBundle 来定位这些资源文件,示例项目实现了一个 -URLForResource:withExtension: 方法来方便的使用它。

Xcode 中的每个 scheme 定义了相应的测试 bundle 是哪个。通过 ⌘-R 运行程序,⌘-U 运行测试。

测试的运行依附于程序的运行,当程序运行时,测试 bundle 将被注入(injected)。测试时,你可能不想让你的程序做太多的事,那样会对测试造成干扰。可以把下面的代码加到 app delegate 中:

编辑 Scheme 给了你极大的灵活性。你可以在测试之前或之后运行脚本,也可以有多个测试 bundle。这对大型项目来说很有用。最重要的是,可以打开或关闭个别测试,这对调试测试非常有用,只是要记得把它们重新全部打开。

还要记住你可以为测试代码下断点,当测试执行时,调试器会在断点处停下来。

测试 Data Source

好了,让我们开始吧。我们已经通过拆分 view controller 让测试工作变得更轻松了。现在我们要测试 ArrayDataSource。首先我们新建一个空的,基本的测试类。我们把接口和实现都放到一个文件里;也没有哪个地方需要包含 @interface,放到一个文件会显得更加漂亮和整洁。

这个类没做什么事,只是展示了基本的设置。当我们运行这个测试时,-testNothing 方法将会运行。特别地,STAssert 宏将会做琐碎的检查。注意,前缀 ST 源自于 SenTestingKit。这些宏和 Xcode 集成,会把失败显示到 Issues navigator 中。

第一个测试

我们现在把 testNothing 替换成一个简单、真正的测试:

实践 Mocking

接着,我们想测试 ArrayDataSource 实现的方法:

为此,我们创建一个测试方法:

首先,创建一个 data source:

注意,configureCellBlock 除了存储对象以外什么都没做,这可以让我们可以更简单地测试它。

然后,我们为 table view 创建一个 mock 对象

Data source 将在传进来的 table view 上调用 -dequeueReusableCellWithIdentifier:forIndexPath: 方法。我们将告诉 mock object 当它收到这个消息时要做什么。首先创建一个 cell,然后设置 mock

第一次看到它可能会觉得有点迷惑。我们在这里所做的,是让 mock 记录特定的调用。Mock 不是一个真正的 table view;我们只是假装它是。-expect 方法允许我们设置一个 mock,让它知道当这个方法调用时要做什么。

另外,-expect 方法也告诉 mock 这个调用必须发生。当我们稍后在 mock 上调用 -verify 时,如果那个方法没有被调用过,测试就会失败。相应地,-stub 方法也用来设置 mock 对象,但它不关心方法是否被调用过。

现在,我们要触发代码运行。我们就调用我们希望测试的方法。

然后我们测试是否一切正常:

STAssert 宏测试值的相等性。注意,前两个测试,我们通过比较指针来完成;我们不想使用 -isEqual:。我们实际希望测试的是 resultcellconfiguredCell 都是同一个对象。第三个测试要用 -isEqual:,最后我们调用 mock 的 -verify 方法。

注意,在示例程序中,我们是这样设置 mock 的:

这是我们测试基类中的一个方便的封装,它会在测试最后自动调用 -verify 方法。

测试 UITableViewController

下面,我们转向 PhotosViewController。它是个 UITableViewController 的子类,它使用了我们刚才测试过的 data source。View controller 剩下的代码已经相当简单了。

我们想测试点击 cell 后把我们带到详情页面,即一个 PhotoViewController 的实例被 push 到 navigation controller 里面。我们再次使用 mocking 来让测试尽可能不依赖于其他部分。

首先我们创建一个 UINavigationController 的 mock:

接下来,我们要使用 partial mocking。我们希望 PhotosViewController 实例的 navigationController 返回 mockNavController。我们不能直接设置 navigation controller,所以我们简单地对 PhotosViewController 实例 stub 这个方法,让它返回 mockNavController 就可以了。

现在,任何时候对 photosViewController 调用 -navigationController 方法,都会返回 mockNavController。这是个强大的技巧,OCMock 有这种本领。

现在,我们要告诉 navigation controller mock 我们调用的期望,即,一个 photo 不为 nil 的 detail view controller。

现在,我们触发 view 加载,并且模拟一行被点击:

最后我们验证 mocks 上期望的方法被调用过:

现在我们有了一个测试,用来测试和 navigation controller 的交互,以及正确 view controller 的创建。

又一次地,我们在示例程序中使用了便捷的方法:

于是,我们不需要记住调用 -verify

进一步探索

就像你从上面看到的那样,partial mocking 非常强大。如果你看看 -[PhotosViewController setupTableView] 方法的源码,你就会看到它是如何从 app delegate 中取出 model 对象的。

上面的测试依赖于这行代码。打破这种依赖的一种方式是再次使用 partial mocking,让 app delegate 返回预定义的数据,就像这样:

现在,无论 [AppDelegate sharedDelegate].store 时候调用过,它也会返回 storeMock。可以把它发挥到极致。确保让你的测试尽可能保持简单,除非确实有复杂的需要。

牢记的事

Partial mocks 会修改 mocking 的对象,并且在 mocks 的生存期一直有效。你可以通过提前调用 [aMock stopMocking] 来停止这种行为。大多数时候,你希望 partial mock 在整个测试期间都保持有效。确保在测试方法最后放置 [aMock verify]。否则 ARC 会过早 dealloc 这个 mock。而且不管怎样,你都希望加上 -verify

测试 NIB 加载

PhotoCell 设置在一个 NIB 中,我们可以写一个简单的测试来检查 outlets 设置得是否正确。我们来回顾一下 PhotoCell 类:

我们的简单测试的实现看上去是这样:

非常基础,但是它能工作。

值得一提的是,当有什么发生变动时,测试和相应的类或 nib 需要同时更新。这是事实。你需要把它和 outlets 变化的可能性做权衡。如果你用了 .xib 文件,你可能要注意了,这是经常发生的事。

关于 Class 和 Injection

我们已经从与 Xcode 集成得知,测试 bundle 会注入到应用程序中。省略注入的如何工作的细节(它本身是个巨大的话题),简单地说:注入是把待注入的 bundle(我们的测试 bundle)中的 Objective-C 类添加到运行的应用程序中。这很好,因为这样允许我们运行测试了。

还有一件事会很让人迷惑,那就是如果我们同时把一个类添加到应用程序和测试 bundle中。如果在上面的示例程序中,(偶然)把 PhotoCell 类添加到测试 bundle 和应用程序,然后在测试 bundle 中调用 [PhotoCell class] 会返回一个不同的指针(你应用程序中的那个类)。于是我们的测试将会失败:

再一次声明:注入很复杂。你应该避免:不要把应用程序中的 .m 文件添加到测试 target 中。否则你会得到预想不到的行为。

额外的思考

如果你使用一个持续集成的解决方案,让你的测试启动和运行是一个好主意。详细的描述超过了本文的范围。这些脚本通过 RunUnitTests 脚本触发。还有个 TEST_AFTER_BUILD 环境变量。

一个有趣的选择是创建单独的测试 bundle 来自动化性能测试。你可以在测试方法里做任何你想做的。定时调用一些方法并使用 STAssert 来检查它们是否在特定阈值里面是一种选择。

扩展阅读

Daniel Eggert, 2013 年 6 月


1 收藏 评论

相关文章

可能感兴趣的话题



直接登录
跳到底部
返回顶部