Tryswift ash furrow cover cn

Artsy 的测试之旅

Artsy 拥有 4 个 iOS 应用,它们都已经全部开源,并且都采用了不同的方法来进行了测试。为什么这样做呢?因为不同的测试技术在各种的情况下的优劣各有不同。在 try! Swift 的本次演讲中,Ash Furrow 讨论了 Artsy iOS 团队做出这种决策背后的动机,谈论了他们所遇见到的问题,以及他们是如何克服这些困难的,以便能够帮助您更好的理解:为什么对于构建精美应用来说,进行测试以及何时进行测试是非常重要的。


在 Artsy 进行测试 (0:00)

我是 Ash,是 Artsy 的一名开源开发者,Artsy 是位于纽约的一家公司。我们正试图让艺术变得同音乐一般流行。我主要是负责 iOS 应用的开发,因此这也是为什么我今天能够站在这个讲台上的原因。

在 Artsy 我们构建了四种不同的 iOS 应用,每个应用都使用了不同的单元测试方法。我们从我们的经验中获益良多,我们进行了不懈的尝试,看看在不同的情况下,哪些方法可行,哪些方法不可行,以及哪些方法更好。

我们下面将要重走一遍 Artsy 这四个应用在进行单元测试时所经历过的路程。虽然现在至少有两个应用是使用 Objective-C 编写的,但是我觉得在这里讨论的东西同样对 Swift 也适用。在整个演讲过程中,我希望您能够将以下三条观念铭记在心:

  1. 不要担心是否做到完美。不要担心是否覆盖了 100% 的测试范围。测试是一种固有价值,不仅仅是因为您能够从中得到单元测试,而且是测试这个行为本身就具有极高的价值。
  2. 将现有的组件分割成更小的组件,因为小组件更易于进行测试。
  3. 对于新应用来说,您应当将组件尽可能地保持精简——不管它是不是小的有点可笑,我们接下来将会讨论这将会是多么的小。

测试 Emergence (1:36)

这是我们的首款应用,代号是:Emergence。这是一个 Apple TV 应用,它提供的功能相对来说是比较少的。���户启动应用,然后选择一个城市,这样他们就可以看到在该城市当中正在举办哪些艺术展览,因此,他们可能会选择东京,然后查看有哪些艺术展览正在进行。

然我们来看一下它的测试策略:

╮(╯▽╰)╭

哟~没有测试。

我承认,一个关于单元测试的演讲以一个没有任何测试的应用作为初始展示,这样做很不寻常,但是我认为这很重要,有两点原因:第一,有时候您会发现您无法进行测试,或者是不应该进行测试;第二,我只是想获得那些认为测试并不重要的开发者的支持而已。

据我所知,在我们公司,测试有时候是一个很崭新的东西,大家都觉得难以下手,并且也害怕去写测试,并且还有时候您根本没法进行测试,不过这都还好。

我想要谈论一下我们为什么不对这个应用进行测试的原因。

  • 首先,这款应用的构建花费的时间很短,基本是在一个月以内。我们拥有一个非常恼人的上线日期,因为 Apple TV 即将发布,我们必须要在第一时间占领这个市场。
  • 实际上,这款应用是由一个人独立开发出来的,也就是我的同事 Orta,他现在住在曼切斯特。因此,这款应用整体下来只有一个人在做。并且我们只需要做很少的日常维护就可以了,根本没有计划去添加任何新功能。这真的是……已经结束了。
  • 这款应用的功能相当少,它只做一件事,不过也完成得很棒。

决定是否对其进行测试,就如同软件开发的其他部分一样,是一个权衡的过程。每个团队及其成员都必须明白这个权衡所在,并且基于此来作出决定。Emergence 是一款非常简单的应用。我问了我的同事,他说他可以在两周之内对其从头进行重构。这款应用在匆忙之中完成的。我们没有添加任何测试,因为时间压力远远大于测试所带来的价值。

Receive news and updates from Realm straight to your inbox

因此,我们回顾一下,您是否需要测试呢?或许需要吧,不过也不一定。您同样也不必因为不能够添加测试而感到万分难过。

测试 Energy (4:00)

接下来,我会谈论一下这个名为 Energy 的应用。这是我们为艺术画廊而搭建的一款应用。它可以帮助艺术画廊管理并向潜在的客户展示他们的存品。这实际上是我们最老的一款应用,也是我们的第一款应用。一开始,它是没有任何测试的,不过随后我们向其中添加了测试。这是一个非常明智的决定。为什么我们决定添加测试呢?

为什么要添加测试? (4:53)

嗯,Energy 的代码远比 Emergence 的要大得多,因此有很多可以分割的地方。让 Energy 代码与我们的 API 时刻保持同步是很重要的。我们只能保留那些不能够被分割的关键代码。我们还不能够引入 BUG,因此我们必须测试以确保没有任何 BUG 出现。

最重要的是,我们想要减少公共要素。公共要素是说某个团队成员是如何知识集中的一种衡量。这样子想,如果这个人被公交撞了,那么会不会导致产品全面崩溃?您的企业会不会就此崩溃?

我们添加测试是因为我们需要记录存储在同事闹钟的知识,将其制度化,测试是一个很棒的方式来定义我们应用是如何工作的。单元测试是文档的一种形式,描述了您应用或者应用某个部分是如何工作的。好的测试会定义了您应用的某个部分是如何工作的。也就是说,测试不仅仅是在您的类当中发生的,而且也要对外部行为进行测试。

当我告诉人们保持类精简的时候,他们总是各种抱怨,说如果他们让类变得精简,那么就会产生更多的类。不过,使用这些小的、组合的、经过严格测试的类显然是很好的做法。

依赖注入 (6:19)

Energy 在我们所有的 iOS 应用当中是单元测试做的最好的。是什么让它的测试如此之棒的呢?我们使用了不少的依赖注入 (Dependency Injection)。依赖注入的理念是,对象不必创建它所需要的东西来完成它的工作,不必要创建相关的依赖。而是将依赖对象注入到对象当中。

在 Core Data 中,您应该向某个对象传递 Core Data 管理对象上下文,而不是让其能够访问某个单例。我们为管理对象上下文 i以及网络同步代码使用依赖注入。Energy 使用内存中 Core Data 管理对象上下文,这种上下文可以很快创建,并且带来的开销很小,接着在我们的单元测试当中将其销毁。我们能够测试的部分是,某个对象是如何修改 Core Data 存储的东西的,因此我们可以创建一个管理对象上下文,将其注入到我们需要测试的对象当中,然后接下来对象执行某些操作,随后我们就可以检查管理对象上下文以观察,发生在该对象上的变化是否是我们所期待的。

使用 RSpec 进行“可视化”测试 (7:38)

我们的测试最初有三个,它们分别执行某些操作。我不清楚它们实际上做了什么,因为它是一堆代码的集合,我必须要仔细阅读代码才能够知道它做了什么。这可能是一堆重复的代码,不过我也不清楚,这里完全是一团糟,没有人知道这里发生了什么。

我们打算应用 RSpec 风格的测试来进行替代。术语 RSpec 来自于 Ruby 社区。您可以使用诸如 KiwiSpecta 或者 Quick 之类的第三方库。要使用 RSpec 风格的测试,我们打算先浏览一遍我们所有的测试,然后识别它们当中相同的配置部分。一旦我们识别出了配置,我们就将其进行重构,将其合并到一个函数当中。

一旦这些通用的配置代码在每个测试当中运行过后,我们就将这个代码移除掉。我们在每个测试之前运行的每个步骤之前,将这段通用代码放到其中。在 Energy 当中,创建一个内存中 Core Data 存储室很常见的事情,接着每个测试可以依赖于这个已经创建好的存储,这时候 RSpec 风格测试的好处就体现出来了,我们可以重复这段过程,我们还可以重新识别通用的配置。我们识别完通用配置之后,我们就可以准备重构了。

我们的两个最内部的测试现在就可以在每条步骤完成之前,只依赖于输入和输出就可以完成了。例如,如果每个步骤之前的输出创建了一个管理对象上下文,那么输入步骤就可以向其中添加一些测试数据。因此,让我们看一看我们一开始所做的。三个测试——它们都做了些什么?它们有多复杂?我们完全没有任何头绪。我们必须阅读每个测试,以便找出这些信息,这需要花费大量的时间,并且非常无聊,与其浪费这些时间,您还不如做一些更有意义的事情。

与此相反,新的测试将更加容易阅读。所需要写的、要读的代码更少,并且最重要的是,单元测试时无法测试的代码,这意味着它们应该尽可能地清晰。从视觉上来说,我们现在就可以说我们正在测试的上下文有多少?这些测试的嵌套方式是如何的?通过测试的相对长度可以看到每个测试有多复杂?如果我们拥有了太多嵌套的上下文,那么我们的对象就可能太复杂了。

重要的是,我们现在可以创建输入和输出上下文,让后给它们定义一个具有描述性的名称。这使得识别一个失败的测试变得十分容易。Xcode 给出的错误信息会给您指导,例如“在某个带有实例数据的 Core Data 存储当中,删除对象操作失败“之类的信息。

回顾 Energy (10:56)

总而言之,您可以添加测试以帮助对您的应用及其部分建立更优质的文档说明。您应当测试您对象的行为,而不是测试它里面的操作。如果行为不通过触及内部信息就难以测试的话,那么这说明您的这段代码实在太复杂了。在我看来,每个类都应当只有一个公共函数。

依赖注入帮助我们编写基于 Mock 的网络访问和 Core Data 存储的测试。RSpec 风格测试帮助我们编写更简洁和更具有表现力的测试。它们让我们的测试意图更为清晰明显,这是非常重要的一点,因为我们再强调一遍,单元测试是无法测试的代码。

测试 Eigen (11:50)

接下来,我们就来看 Eigen 这款应用。这是我们面向客户设计的应用。如果您想要探索艺术品的话,诸位都应当在您的 iPhone 或者 iPad 上面装上这个应用

我们最初构建它的时候是没有任何单元测试的,我们也是在随后添加上了单元测试。应用是一个赶工的成果,三位开发者用很短的时间快速地完成了这个项目。我们添加测试是因为我们已经完成了 Energy 的全部工作,我们同时也能看到测试所带来的价值,我们一开始的时候并没有足够的时间来编写测试。

测试所面临的挑战 (12:42)

Eigen 可能和那些需要测试的应用最为相似的了。它当中包含了大量的代码,这些代码纠缠在一起,并且由许多开发者共同完成,这些开发者面临的编码风格都大为不同;很难开始去对这款应用进行测试。

很难测试的重要原因之一是因为网络访问遍布了整个代码,与 Energy 相比就大有不同,Energy 的网络访问只在一个地方发生:同步代码当中。当我们在试图测试 Eigen 的时候,确保我们成功获取了所有的网络回应是一个非常巨大的问题。添加更多的测试同样也是一个需要不断努力的过程,这非常困难,因为我们有很多不同的开发者为这款应用工作,他们编写代码的风格都有略微的不同。

与 Energy 这款只有很少的开发者编写的应用相比,Energy 用来测试东西的方式非常统一,而多种不同的编码风格意味着我们需要使用不同的测试技术,这可能会带来极大的认知开销。

多平台测试 (13:35)

Eigen 开始只是在 iPhone 上面运行,但是最终还是成为了一个通用的应用,这提出了一个严肃的问题:如何在多个平台上进行测试。这里有几种不同的选项,但是我们最终选定的是一种叫做共享示例 (Shared Examples) 的东西。

共享示例允许您定义基于给定上下文的测试。在这种情况下,测试使用不同的上下文环境并运行多次,我们拥有两种上下文:测试应该通过 iPhone 环境,并且测试也应该通过 iPad 环境。

当我们一开始添加测试的时候,我们开始试图测试那些超巨型的类,因为我们觉得它们是最脆弱的,因此应该以最高优先级对其进行测试。但是它们实在是太大了,因此为了对它们进行测试,我们必须要测试它们的内部代码,并且现在它们变得非常难以改变,因为如果我们改变了内部的某些东西,那么我们接下来就必须要更新一堆测试代码。

我们总的原则是,所有添加到代码库当中的新代码都必须要经过测试。如果我们在修改既有代码的时候有不少时间的话,我们还会优先添加对这段代码的测试。修复一个 BUG 总是意味着需要添加新的测试以防止回归的出现,并且也可以防止该 BUG 再次出现。

截图测试 (15:19)

我测试一款 iOS 应用最喜欢的方式之一就是使用截图测试了。它们依赖于一个很赞的来自于 Facebook 的第三方库,下面是它如何工作的:

您搭建完视图或者视图控制器之后,您向其中给定一些数据,接着您使用截图记录一下这个视图是什么样的,将其保存为一个 .PNG 图片,然后放到您的仓库当中。接着,当您重新运行您的测试的时候,您执行相同的配置,给予您的视图相同的数据,然后截取另一张截图,之后您就可以对这两张截图进行比较,一个像素一个像素的来。如果它们有不同的地方,那么测试就失败了。

这是非常有用的做法。不过有一个缺点是您需要在您的仓库当中存储大量的 .PNG 图片,因此会占用较大的硬盘空间,不过这是一个用以检测用户界面发生变化的好方法。

当您检查提交请求的时候,这些东西也是非常有用的。例如,您可以看到某个界面是如何发生改变的。或者您可以从视觉上看到,在这个提交请求中某个新的界面长什么样。这使得检查新的提交请求变得非常容易。

请求帮助 (15:31)

有些时候我们会遇到难题,我们不知道该怎么做,因此我们会和其他的 iOS 开发者进行交流。或许我们会写一篇博客文章,或许我们会在推特上面发送某个问题或者提交请求的链接,因为我们是完全开源的。让我们的应用开源真的让我们受益匪浅。如果我们着实遇到了难题,我们会从大家的建议中采取适当的解决方案,即使这个方案并不是很理想,但是我们会试图记录下我们是如何对其进行改善的,因此如果我们有机会回顾的话,我们就知道我们该怎么做了。记住,没有东西是可以止步不前的,没有东西是完美无缺的,我们只能不停地改善。

回顾 Eigen (17:26)

总而言之,我们开始编写测试的时候,我们并没有将代码分割开来,我们仍然就以原有代码的基础之上编写测试。现在,我们将既有的类分割成相似的部分以便为了更好地进行测试,这使得代码变得容易查看、也容易测试。如果您刚开始学习如何进行测试的话,如果您觉得这些观念很生疏的话,我建议您首先以一个最小类开始进行测试,这样您就可以很快习惯单元测试。

为您的团队建立相关的规则:所有的新代码都必须要经过测试,并且当可以的时候尽可能给新修改的代码添加测试。我们有些时候可能会遇到难题,不过这没有任何关系。我们可以去寻求帮助,因为 iOS 社区的氛围非常友善,我们可以从中获得大量的帮助。有些时候,我们会不得不写下那些我们知道不是非常理想或者说不是完美的代码,不过我们可以尽自己最大努力记录下为什么我们要决定这样做,以及我们如何在未来对其进行解决。

测试 Eidolon (18:33)

让我们来谈谈我们是如何编写一开始就进行测试的应用的。这是 Eidolon,它是一个在艺术拍卖会上面对艺术品进行竞标的应用。我们使用iye分发来将这款应用部署到 iPad 上面,并通过物理方式将其安装到机柜当中,这样位于拍卖会的人们就可以浏览并对艺术品进行竞标。

这款应用一开始就是带有测试的,但是时间压力迫使我们只能选择偷工减料。我们从那时起就一直不停地为我们的偷工减料付出代价,但是由于添加了测试,这使得我们在维护应用的时候让代价尽可能地降低了许多。

Swift 测试 (19:20)

这是我们第一款 Swift 应用,当 Swift 1.0 仍然处于测试版本的时候我们就开始构建它了。然而,由于语言、编译器、Xcode 时刻都在发生变化,因此单元测试可以有效地帮助我们验证对工具的理解程度,以及对语言的理解程度。它同样也确保我们的应用不会因为工具和语言的演变而出现问题。

这是非常有意思的,因为我们在编写这个首个 Swift 应用的时候,单元测试是唯一一件我们觉得很熟悉的东西。测试帮助我们坚守软件工程的原则,同时帮助我们验证我们对 Swift 如何进行工作的理解对不对,并且还确保我们对我们代码的质量保持信心。

使用 Quick (20:10)

Quick 是一个用在 Swift 和 Objective-C 上的 RSpec 风格的测试库。当我们进行开发的时候,我们使用 Quick 以便能够便携良好的测试,我们还为 Quick 团队提供了不少的反馈,这个团队非常的热心,他们回答了我们所有关于开发应用以及同步使用测试库的相关问题。

Quick 为我们引入了一个 RSpec 风格的测试框架,而 Nimble 为我们引入了一个非常好用的匹配器。什么是匹配器 (Matchers) 呢?这是一个很好的问题。让我们来进一步看一下测试实际上看起来是怎样的。

在 Eidolon 当中,一个好的测试是非常简短的,我想说通常而言,一个好的测试都是简短的。它拥有三个步骤:安排、操作及断言。

  1. 安排通常都在每个步骤之前完成
  2. 我们通过调用我们试图去测试行为的方法以完成操作
  3. 我们接下来可以断言这个正在测试的这个类的行为符合我们的预期,并且我们会调用这些预期结果。

断言、预期本质上是相同的东西。因此,一个预期是什么样的呢?

Nimble 模式匹配 (21:46)

这就是 Nimble 大显身手的时候了……让我们来看一下普通的 XCTest 断言是怎样的:

XCTAssertEqual(1 + 1, 2, "...")

这看起来非常的冗长。现在,让我们看一下如果使用了 Nimble 匹配器的话,这段话会变成什么样子:

expect(1 + 1).to( equal(2) )

这看起来好很多了。它更为简洁,更具有表现力。但是我们可以做得更好。使用自定义运算符重载功能,我们可以更加简洁:

expect(1 + 1) == 2

这就是我所喜欢写的测试。虽然断言并不是完全是我们所说的期望,但是要记住,它们本质上是一回事。Nimble 不仅仅拥有判断相等的匹配器;它还拥有字符串、数组范围检测,所有类型的东西都内置有相应的匹配器。

同时还有异步的匹配,这样就可以很容易地在不适用 XCTests 的地方测试异步代码。而且,您可以自定义编写执行相应处理的期望,正如我们在前面所提到的截图库,我们通过 Nimble 对它进行了相应的自定义。

回顾 Eidolon (23:11)

总而言之,我们该如何才能编写有效的测试呢?我们使用具有 RSpec 风格的测试库——Quick,我们使用 Nimble 的匹配器来编写期望和简短的测试,我们使用自定义匹配器以便让我们的测试更具备可读性,同时我们还试图限制每个测试当中期望的数目。测试应当小而精,它们应当是非常显而易见的,同时它们也应当是充分的。

总结 (23:40)

让我们回顾一下我在演讲开始的时候就希望各位铭记在心的那三个概念。

我希望您不必担心测试是否写的完美或者完善,因为世界上不存在完美的东西。也不存在完全完善的东西。我们有时候我看到,我们不得不编写那些不是很理想的代码,不过这完全没有任何关系。有些时候我们可能会陷入迷惑,我们可能会卡在某个问题上,这是很常见的。寻求帮助真的很有用,因为我们可以从 iOS 社区中学习到很多很多的知识。

尝试将既有的组件分割为更小的组件,因为小的组件更容易进行测试,也更容易进行维护。并且我们只需要测试某个类的公共接口即可,不要测试类当中的行为。此外,对于公共接口来说,应该保持精简。我的理想化就是只提供一个公共的函数。对于新应用来说,我们试图从一开始就让所有东西保持精简。明确定义的逻辑和代码风格更容易进行测试,但是这往往是不可能实现的。

通过保持我们的类精简,并且限制不同代码风格的差异以保持类所具有的界限,这使得我们的类能够容易进行测试,并且保持类小而精可以在最大程度上帮助我们减少我们在开发过程中遗留下来的技术问题,并且可以帮助我们在以后更好的去解决这些问题。

我们所有的 iOS 应用都是开源的,我们一直努力确保社区中的开源开发者们能够很容易的运行它们。我很乐意您去帮助我们贡献代码,如果您对我们开发的应用程序或者对如何测试我们的应用程序有任何问题的话,只需要在 Github 上打开一个问题就可以了。

问题时间到! (25:03)

问:您该如何向后台工作的同事解释测试的重要性呢?

Ash:我想我会试图介绍一些测试所带来的好处,它可以记录应用的行为,确保代码的关键部分不出任何问题。我还会说对 iOS 应用进行测试至少是和服务器端一样重要的,假设有一个 Rails 应用,它在发布的时候没有通过 App Store 审核,因此如果您没有通过单元测试将 BUG 找出来的话,那么您很可能就必须要再等一周进行审核,这样的话您的顾客就很可能不得不忍受这个 BUG 带来的影响。

问:您对 UI 测试或者集成测试的看法是怎样的呢?

Ash:我还没有决定是否要使用 UI 测试或者集成测试,主要是因为我没有被 Xcode 最近新增加的 UI 测试这个功能所打动。说实话,我并没有完整地去研究它。我觉得截图测试已经完全足够了,只要您将您的视图或者视图控制器保持简洁就可以了。 这种做法并不需要做很多工作,他们只需要对用户交互做出回应,然后展示数据就可以了。接着,如果您在其他类中拥有逻辑和重要对象的话,那么您可以独立测试这个类。

因此您完全没必要进行端对端测试,有很多事情是无需端对端测试就可以进行的。我们将火星车发送到火星,我们无法对其进行端对端测试,我们必须单独对它的每一个部分进行测试,因此如果这对 NASA 来说没有任何问题的话,对我们来说也没有任何问题。

这就是我的看法,不过我觉得别人可能对 UI 测试更情有独钟,如果它对您的团队���有用的话,那么用就是了。

问:您对进行测试驱动开发有什么建议吗?它需要有怎样的思维过程呢?

Ash:测试驱动开发是很有争议的话题。有些人真的很喜欢他,而有人恰恰却不喜欢。我是中间派。我喜欢写代码,但是我很不耐烦去写测试,因此我往往都是先执行完毕之后再去写测试,尽管我知道我的做法是不对的。

已经写好的代码,我会去为之编写测试,我经常会发现我写的方法会很难进行测试,因此我又会在我的类当中对它进行修改。我已经这样子做了一遍又一遍,我已经掌握了诀窍,如何编写我的类以便让其能够更加容易进行测试,我觉得这种经验是很价值的,这和如果您使用测试驱动开发得到的价值是差不多的。

我认为测试驱动开发具有很强的鲁棒性,但是我觉得它还不够好,对我们的团队来说,经过平衡和权衡之后,我觉得我们的做法只值得的,而不是使用测试驱动开发。

至于我的建议是,我只会说尽量保证您的类小而精。如果您有一个东西很难进行测试,那么尝试将其切分成几块,然后测试每个部分,最后使用依赖注入来测试它们如何进行协同工作。最后,只要练习就可以了,随着经验的积累,您会做得更好。

About the content

This talk was delivered live in March 2017 at try! Swift Tokyo. The video was recorded, produced, and transcribed by Realm, and is published here with the permission of the conference organizers.

Ash Furrow

Ash Furrow is a Canadian iOS developer and author, currently working at Artsy. He has published four books, built multiple apps, and is a contributor to the open source community. He blogs about a range of topics, from interesting programming to explorations of analogue film photography.

4 design patterns for a RESTless mobile integration »

close