返回首页 Objective-C 期刊

第二十四期-音频

糟糕的测试

时至今日,我写自动化测试也已经有些年头了,我不得不承认当它能使代码更容易维护,因此我依然对这项技术着迷。本文中,我希望分享一些我的经验,以及我从他人或自己的一次次尝试中所吸取的教训。

这些年,我听到了许多关于写自动化测试的好的 (以及不好的) 理由。从积极的方面来说,写自动化测试能够:

  • 使重构更简单
  • 避免代码恶化
  • 提供了可执行的说明和文档
  • 减少了创建软件的时间
  • 降低了创建软件的代价

的确,你可以说这些都是对的,但是我想提出一个关于这些理由的另一个视角 —— 一个统一的视角

自动化测试唯一的理由是它让我们能在将来修改我们的代码。

换句话说:

一个测试能够体现回报价值的时候仅仅是当我们想修改我们的代码的时候。

让我们看一看这个经典论断是如何支持我们前面提到的理由的:

  • 使重构更简单 —— 你可以自信的修改实现细节,而不用去触及公有 API。
  • 避免代码恶化—— 恶化在什么时候发生?在你修改代码的时候。
  • 提供了可执行的说明和文档 —— 你在什么时候更想知道软件实际上是如何工作的?在你想修改它们的时候
  • 减少了创建软件的时间 —— 怎么减少时间的?是通过更快速地修改你的代码,出错时测试会自信地告诉你哪里出错了
  • 降低了创建软件的代价 —— 好吧,时间就是金钱,我的朋友

是的,上面所有的理由在某些方面是对的,但是这些理由适用于我们开发者的就是自动化测试能够让我们修改代码。

注意,我在这里不会写关于测试的设计所能得到的反馈,比如 TDD。那可以成为一个单独的话题。我们将要谈论的测试是已经写好的测试。

看起来好像写测试和如何写测试应该以修改作为动机。

一个简单的考虑这个问题的方法是在写测试的时候,向你的测试提出下面两个问题:

“如果我修改了我的生产代码,测试是会失败 (还是通过) 呢?”

“那是一个让测试失败 (或者通过) 的好的理由么?”

如果你发现那是一个让测试失败 (或者通过) 的不好的理由,那么请修正它。

那样,将来你修改你的代码的时候,你的测试只会因为好的理由而通过或者失败,这会比因为不好的理由而失败的古怪的测试得到的回报要好。

现在,你可能仍然会问:“什么才是最重要的?”

让我们用另一个问题来回答这个问题:当我们修改代码的时候,测试为什么会出错?

我们都认同的一个观点是我们进行测试的主要原因是为了能够轻松地修改代码。如果是那样的话,那些失败的测试是如何帮助我们的?那些失败的测试除了是噪音之外什么也不是 —— 它们甚至会阻碍着我们完成工作。那么,怎样做测试才能帮助我们呢?

这取决于我们修改代码的理由。

修改代码的行为

首先,起点必须是测试全部是绿色的,也就是说所有的测试都已经通过。

如果你想通过修改代码来修改它们的行为 (也就是,修改代码做的事情),你需要:

  1. 找到定义当你想要修改的前行为的测试。
  2. 修改这些测试来满足新的期望的行为。
  3. 运行测试,查看那些被修改的测试是否失败了。
  4. 更新你的代码,使得所有的测试重新通过。

在这一过程的结尾,我们又回到了起点——所有的测试都通过了,如果需要,我们已经准备好了再次开始。

因为你知道哪些测试失败了以及哪些代码的修改使得它们又通过了,你会很有信心,因为你只修改了你想要修改的部分。这就是自动化测试如何帮助我们通过修改代码来修改代码的行为的。

注意,看到一个测试失败是正常的,因为它是我们正在更新的行为相对应的测试。

重构:修改代码的实现 —— 保持行为不变

同样,起点应该是测试全是绿色的。

如果希望修改一段代码的实现让它变得更简单,高效,易于扩展等等 (也就是说,修改怎么做,而不是做什么),应该遵循接下来的原则:

在不触及测试的前提下修改你的代码。

当修改后的代码已经简单、快速、更灵活时,你的测试应该仍然是绿色的。在重构的时候,测试应该只在代码出错的时候失败,例如修改了代码的外部行为。当发生这种情况时,你应该退回到那个错误然后回到绿色的状态

因为你的测试总是在绿色的状态,你知道你没有破坏任何事情。这就是自动化测试如何让我们修改我们的代码的方式。

在这种情况下,看到测试失败是不应该的。因为这意味着:

  • 我们无意识地修改了代码的外部行为。庆幸的是,我们的测试帮助了我们发现这些错误。
  • 我们没有修改代码的外部行为。太不幸了,这才是最大的麻烦。

我希望测试在上面的情形下能够帮助我们。所以让我们来看一些具体的能让我们的测试更有效的 tips。

优秀实践入门

在讨论如何写测试之前,我想迅速地回顾一些优秀实践。有 5 条被认为是每个测试都应该遵守的基本原则。便于记忆这 5 条规则的缩写是: F.I.R.S.T.

测试应该:

  • 很快速(Fast) —— 测试应该能够被经常执行。
  • 能隔离(Isolated) —— 测试本身不能依赖于外部因素或者其他测试的结果。
  • 可重复(Repeatable) —— 每次运行测试都应该产生相同的结果。
  • 带自检(Self-verifying) —— 测试应该包括断言,不需要人为干预。
  • 够及时(Timely) —— 测试应该和生产代码一同书写。

更多关于这些规则的内容,你可以阅读 Tim Ottinger 和 Jeff Langr 的这篇文章

坏的实践

如何将测试的结果收益最大化?一言以蔽之:

不要将测试和实现细节耦合在一起

不要测试私有方法

说得够多了。

私有方法意味着私有。如果你感到有必要测试一个私有方法,那么那个私有方法一定含有概念性错误,通常是作为私有方法,它做的太多了, 从而违背了单一职责原则

今天:假设你的类有一个私有方法。它做了太多的事情,所以你决定测试它。你仅仅为了测试,就让那个方法变成公有的。它本来只被同一个类的其他的公有方法在内部使用。然后你为这个私有 (从技术上来说现在公有了的) 方法编写测试。

明天:因为需求上的一些变化 (这完全是有可能的),你决定修改这个方法。你发现一些同事在其他的类中使用了这个方法,因为他们说 “这个方法做了我想要的事情”。毕竟,它是公有的,不是么?这个私有方法不是公有 API 的一部分。你要想修改这个方法就不得不破坏你同事的代码。

应该做什么:将私有方法抽离到一个单独的类中,给这个类一个定义良好的约定,然后单独地测试它。当其他的测试代码依赖这个新的类的时候,如果有必要的话,你可以进行置换测试

那么,我们如何测试一个类的私有方法呢?通过这个类的公有 API。永远通过公有 API 测试你的代码。程序的公有 API 定义了一个约定,它是一组关于你的程序对应于不同输入时定义良好的一组期望。私有 API (私有方法或者整个类) 并没有定义约定,并且可以不经通知自行修改,所以你的测试 (或者你的同事) 不能依赖于它们。

通过这种方法测试你的私有方法,你可以自由地修改你的 (真正的) 私有代码,并且通过划分成只做一件事情,并经过正确测试的小的类,来提升代码的设计。

不要 Stub 私有方法

Stub 私有方法和测试私有方法具有相同的危害,更重要的是,stub 私有方法将会使程序难以调试。通常来说,用于 stub 的库会依赖于一些不寻常的技巧来完成工作,这使得发现一个测试为什么会失败变的困难。

同样,当我们 stub 一个方法的时候,我们必须依据它做出的约定来进行。但是私有方法没有指定的约定的 —— 毕竟,这也是为什么它们是私有的原因。由于私有方法的行为可以不经通知自行修改,你的 stub 可能与实际情况背道而驰,但是你的测试仍然会通过。这是多么的可怕啊,让我们来看一个例子:

今天:一个类的公有方法依赖于该类的一个私有方法。这个私有方法 foo 永远不会返回空。为公有方法编写的测试为了方便起见,我们 stub 出了私有方法。当 stub foo 方法的时候,你永远不会考虑到 foo 返回为空的情况,因为现在这种情况永远不会发生。

明天:这个私有方法被修改了,现在它返回空了。它是一个私有方法,所以这没什么问题。为公有方法编写的测试不会相应地被修改 (“我正在修改一个私有方法,所以我为什么要更新我的测试?”)。公有方法现在在私有方法返回空的情况下会出错,但是测试仍然会通过!

这实在太可怕了。

应该做什么:由于 foo 做的事情太多了,所以应该将它抽离至一个新的类,然后单独地测试它。然后,在测试的时候,为那个新类提供一个置换

不要 Stub 外部库

第三方代码不应该在你的测试中直接出现。

今天:你的网络部分的代码依赖于著名的 HTTP 库 LSNetworking.为了避免使用实际的网络 (为了让你的测试更快速更可信),你 stub 了那个库中的方法 -[LSNetworking makeGETrequest:],没有通过实际的网络合适地替代了它的行为 (它通过一个封装好的响应调用了执行成功的回调)。

明天:你需要使用一个替代品来取代 LSNetworking (可能是 LSNetworking 已经不再维护或者是你需要换成一个更先进的库,因为它有很多你需要的新特性等等)。这是一次重构,所以你不应该修改测试。你替换了库。你的测试会失败,因为依赖的网络没有被 stub (-[LSNetworking makeGETrequest:]不会被调用)。

应该做什么:测试中,依靠 stubbing 伞 (umbrella stubbing) 来替代那个库的全部功能。

stubbing 伞 (一个我刚刚发明的术语) 包括了对于所有你的代码可能用到的方式 -- 不管事现在还是将来 -- 的 stub。它们可以通过良好声明的 API 完成一些任务,而不去关心实现的细节。

正如上面的那个例子,你的代码今天可能依赖于 "HTTP 库 A",但是还是有别的可能的方式发起 HTTP 请求,不是么?比如 "HTTP 库 B"。

举个例子,我的一个开源项目 Nocilla 就为网络代码提供 stubbing 伞的解决方案。通过 Nocilla 你可以不依赖任何 HTTP 库,以声明的方式 stub HTTP 请求。Nocilla 可以 stubbing 的任何一个 HTTP 库,所以你不会将测试和实现细节耦合在一起。这使得你能够在不修改测试的情况下切换网络框架。

另一个例子是 stub 日期,在大多数编程语言中都有很多中方法获取当前时间,但是像 TUDelorean 这样的库可以 stub 每一个与日期相关的 API,所以你可以模仿一个不同的系统日期用来测试。这让你你不用修改测试就可以重构不同的日期 API 的实现细节。

除了 HTTP 和日期,在拥有各种各样 API 的其他领域,你可以用类似的方式来实现 stubbing 伞,或者你可以创建你自己的开源解决方案并分享到社区,这样其他人就可以正确地编写测试了。

正确地 Stub 依赖

这部分和前一点关系密切,但是这部分的情况更普遍。我们的生产代码通常依赖于某些事情的完成。比如,一个依赖能够帮助我们查询数据库。通常这些依赖提供了多种方法来实现相同的事情,或者说至少是实现相同的外部行为;在我们的数据库的例子中,你可以使用 find 方法通过 ID 来获取一条记录,或者使用 where 子句获取相同的记录。当我们仅仅 stub 可能的机制中的一个的时候,问题就出现了。如果我们仅仅 stub 了 find 方法 (我们的生产代码使用的机制),但是没有 stub 其他的可能性,比如 where 子句,当我们决定使用 where 子句取代 find 方法来重构我们的实现的时候,我们的测试就会失败,即使代码的外部行为并没有修改。

今天:UsersController 类依赖于 UserRepository 类从数据库中取得用户。你正在测试 UsersController 并且你为了以确定的方式更快地运行,你 stub 了 UsersRepositoryfind 方法,这实在是太棒了。

明天:你决定使用 UsersRepository 的新的可读性更高的查询语法来重构 UsersController,因为这是一次重构,所以不应该触及测试。为了找到感兴趣的记录,你使用了可读性更高的 where 方法更新了 UsersController。现在你的测试会失败,因为测试 stub 了 find 方法,但是没有 stub where 方法。

stubbing 伞在某些情况下能帮上忙,但是对于 UsersController 类的这种情形,没有可以替代的库能够从我的数据库中获取我的用户。

应该做什么:以测试为目的,为同一个类创建可替代的实现,并将它作为置换来使用。

继续我们的例子,我们应该提供一个 InMemoryUsersRepository。这个在内存中的替代方案,除了它为提高测试速度而把数据保存在内存中之外,它应该遵守原始的 UsersRepository 类的每一个单一方面的约定。这意味着,当你重构 UsersRepository 的时候,你使用在内存中这个版本做了同样的事情。为了让它更清楚:是的,现在你不得不为同一个类维护两套不同的实现。

现在你可以将这个轻量级的版本的依赖作为置换对象提供给测试。好的事情是这是一个完整的实现,所以当你决定将实现从一个方法移动到另一个方法 (在我们的例子中是从 find 移动到 where) 的时候,正在使用的置换对象将会支持新的方法,并且当重构的时候,测试也不会失败。

维护一个类的另一个版本没有什么问题。根据我的经验,它最终只会需要很少的努力,就能得到很大的回报。

你同样可以将类的轻量级版本作为生产代码的一部分,就像 Core Data 使用栈的内存版本一样。这样做可能对某些人有作用。

不要测试构造函数

构造函数定义的是实现细节,你不应该测试构造函数,这是因为我们认同测试应该与实现细节解耦这一观点。

而且,构造函数不应该包含行为,所以没有值得测试的东西。这是因为我们认同测试应该只对代码的行为进行这一观点。

今天:你有一个 Car 类,并包含一个构造函数。一旦一个 Car 被创建了,你测试它的 Engine 不为空 (因为你知道构造函数创建了一个新的 Engine 并将它赋给了变量 _engine)。

明天:Engine 类创建起来变得代价很高,所以你决定使用延迟初始化 (lazily initialize),在第一次调用 Enginegetter 方法时才初始化 Engine (这是很好的)。 现在为 Car 类的构造函数编写的测试出问题了,即便 Car 类运行良好,但 Car 并没有包括 Engine。另一个可能是你的测试不会失败,因为测试包含 EngineCar 类会触发 Engine 的延迟加载。所以我的问题是:为什么还要测试?

应该做什么:当使用不同的方法创建类的时候测试公有 API 的行为。一个愚蠢的例子:测试当 list类被创建并且没有包含条目的时候,list 类的 count 方法的行为。注意,你测试的是 count 的行为而不是构造函数的行为。

思考一下,类含有多个构造函数的情形,这可能意味着你的类做了太多事情了。试着将它们拆分成更小的类,但是如果有足够充分的理由使你的类含有多个构造函数,那么依然遵循同样的建议。保证你的测试的是那个类的公有 API。在这种情况下,使用每一个构造函数去测试 (也就是说,当类处在一种初始化状态下时,它的行为就是那种状态下的;当类处在另一种初始化状态下时,它的行为是另一种状态下的)

结论

编写测试是一项投资 —— 我们需要花时间编写和维护它们。我们可以证明这种投资有回报的唯一方法就是我们期望节省时间。将实现细节和测试耦合在一起会减少测试带来的回报,使得那些投资变得不合算,甚至在某些情况下变得一文不值。

在编写测试、重构以及修改系统行为的时候,检查你的测试在面对错误的原因时是失败还是通过,然后退一步问问自己,那些测试是否能够最大化你投资的成果。

上一篇: 依赖注入 下一篇: 置换测试: Mock,...