如何编写测试

写测试相比于写代码来说算是一件简单的事。多数时候,我们并不需要考虑复杂的逻辑。我们只需要按照我们的代码逻辑,对代码的行为进行覆盖。

需要注意的是——在不同的团队、工作流里,测试可能是会有不同的工作流程:

  • 开发人员写单元测试、集成测试等等
  • 测试团队通过界面来做黑盒测试
  • 测试人员手动测试来测试功能

在允许的情况下,测试应该由开发人员来编写,并且是由底层开始写测试。为了更好地去测试代码,我们需要了解测试金字塔。

测试金字塔

测试金字塔是由 Mike Cohn 提出的,主要观点是:底层单元测试应多于依赖 GUI 的高层端到端测试。其结构图如下所示:

测试金字塔

从结构上来说,上面的金字塔可以分成三部分:

  1. 单元测试。
  2. 服务测试
  3. UI 测试

从图中我们可以发现:单元测试应该要是最多的,也是最底层的。其次才是服务测试,最后才是 UI 测试。大量的单元测试可以保证我们的基础函数是正常、正确工作的。而服务测试则是一门很有学问的测试,不仅仅只在测试我们自己提供的服务,也会测试我们依赖第三方提供的服务。在测试第三方提供的服务时,这就会变成一件有意思的事了。除此还有对功能和 UI 的测试,写这些测试可以减轻测试人员的工作量——毕竟这些工作量转向了开发人员来完成。

单元测试

单元测试是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。它是应用的最小可测试部件。举个例子来说,下面是一个JavaScript 的函数,用于判断一个变量是否是一个对象:

  1. var isObject = function (obj) {
  2. var type = typeof obj;
  3. return type === 'function' || type === 'object' && !!obj;
  4. };

这是一个很简单的功能,对应的我们会有一个简单的 Jasmine 测试来保证这个函数是正常工作的:

  1. it("should be a object", function () {
  2. expect(l.isObject([])).toEqual(true);
  3. expect(l.isObject([{}])).toEqual(true);
  4. });

虽然这个测试看上去很简单,但是大量的基本的单元测试可以保证我们调用的函数都是可以正常工作的。这也相当于是我们在建设金字塔时用的石块——如果我们的石块都是经常测试的,那么我们就不怕金字塔因为石块的损坏而坍塌。

当单元测试达到一定的覆盖率,我们的代码就会变得更健壮。因为我们都需要保证我们的代码都是可测的,也意味着我们代码间的耦合度会降低。我们需要去考虑代码的长度,越长的代码在测试的时间会变得越困难。这也就是为什么 TDD 会促使我们写出短的代码。如果我们的代码都是有测试的,单元测试可以帮助我们在未来重构我们的代码。

并且在很多没有文档或者文档不完整的开源项目中,了解这个项目某个函数的用法就是查看他的测试用例。测试用例(Test Case)是为某个特殊目标而编制的一组测试输入、执行条件以及预期结果,以便测试某个程序路径或核实是否满足某个特定需求。这些测试用例可以让我们直观地理解程序程序的 API。

服务测试

服务测试顾名思义便是对服务进行测试,而服务可以是有不同的类型,不同层次的测试。如第三方的 API 服务、我们程序提供的服务,虽然他们他应该在这一个层级上进行测试,但是对他们的测试会稍有不同。

对于第三方的提供的 API 服务或者其他类似的服务,在这一个层级的测试,我们都不会真实地去测试他们能不能工作——这些依赖性的服务只会在功能测试上进行测试。在这里的测试,我们只会保证我们的功能代码是可以正常工作的,所以我们会使用一些虚假的 API 测试数据来进行测试。这一类提供 API 的 Mock Server 可以模拟被测系统外部依赖模块行为的通用服务。我们只要保证我们的功能代码是正常工作的,那么依赖他的服务也会是正常工作的。

Mock Server

而对于我们提供的服务来说,这一类的服务不一定是 API 的服务,还有可能是多个函数组成的功能性服务。当我们在测试这些服务的时候,实际上是在测试这个函数结合在一起是不是正常的。

一个服务可能依赖于多个函数,因而我们会发现服务测试的数量是少于单元测试的。

UI 测试

在传统的软件开发中,UI 测试多数是由人手动来完成的。而在稍后的章节里,你将会看到这些工作是可以由机器自己来完成的——当然,前提是我们要编写这些自动化测试的代码。需要注意的是 UI 测试并不能完全替代手工的工作,一些测试还是应该由人来进行测试——如对 UI 的布局,在现阶段机器还没有审美意识呢。

自动化 UI 测试是一个缓慢的过程,在这个过程里我们需要做这么几件事:

  1. 运行起我们的网站——这可能需要几分钟。
  2. 添加一些 Mock 的数据,以使网站看上去正常——这也需要几分钟到几十分钟的时间。
  3. 开始运行测试——在一些依赖于网络的测试中,运行完一个测试可能会需要几分钟。尽管可以并行运行测试,但是一个测试几分钟算到最后就会累积成很长的时间。

所以,你会发现这是一个很长的测试过程。尽可能地将这个层级的测试往下层级移,就会尽可能的节省时间。一个 UI 测试需要几分钟,但是一个单元测试可能不到1秒。这就意味着,这样的测试下移可以节省上百个数量级的时间。

如何测试

现在问题来了,我们应该怎么去写测试?换句话来说,我要测什么?这是一个很难的问题,这足够可以以一本书的幅度来说明这个问题。这个问题也需要依赖于不同的实践,不同的时候我们可能对问题的看法都有不同。

编写测试的过程大致可以分成下面的几个步骤:

  1. 了解测试目的(Why)?即我们需要测什么,我们是为了什么而编写的测试。
  2. 我们要测哪些内容(What)?即测试点,我们即要从功能点上出发来寻找需要我们测试的点,在不同的条件下这个测试点是不一样的。
  3. 我们要如何进行测试(How)?我们要使用怎么样的方法进行测试?

测试目的

我们在上面提到过的测试金字塔,也表明了我们在每个层级要测试的目的是不一样的。

在单元测试这一层级,因为我们所测试的是每一个函数,这些函数没有办法构成完整的功能。这时候我们就只是用于简简单单的测试函数本身的功能,没有太多的业务需求。

而对于服务这一层级,我们所要测试的就是一个完整的功能。对于以 API 为主的项目来说,实际上就是在测返回结果是否是正确的。

最后 UI 这一层级,我们所需要测试的就是一个完整的功能。用户操作的时候应该是怎样的,那么我们就应该模仿用户的行为来测试。这是一个完整的业务需求,也可以称之为验证测试。

测试点

在了解完我们要测试的目的之后,我们要测试的点也变得很清晰。即在单元测试测试我们的函数的功能,在我们的服务测试我们的服务,在我们的 UI测试测试业务。

而这些都理想的情况,当系统由于业务的原因不得不耦合的时候。究竟是单元测试还是功能测试,这是一个特别值得思考的问题。如果一个功能既可以在单元测试里测,又可以在服务测试里测,那么我们要测试哪一个?或者说我们应该把两个都测一遍?而如果是花费时间更长的 UI 测试呢?这样做是不是会变得不划算。

如何写测试代码

先让来们来简单地看一下测试用例,然后再让我们看看一般情况下我们是如何写测试代码的。下面的代码是一个用Python写的测试用例:

  1. class HomepageTestCase(LiveServerTestCase):
  2. def setUp(self):
  3. self.selenium = webdriver.Firefox()
  4. self.selenium.maximize_window()
  5. super(HomepageTestCase, self).setUp()
  6. def tearDown(self):
  7. self.selenium.quit()
  8. super(HomepageTestCase, self).tearDown()
  9. def test_can_visit_homepage(self):
  10. self.selenium.get(
  11. '%s%s' % (self.live_server_url, "/")
  12. )
  13. self.assertIn("Welcome to my blog", self.selenium.title)

在上面的代码里主要有三个方法,setUp()、tearDown()和 test_can_visit_homepage()。在这三个方法中起主要作用的是 test_can_visit_homepage()方法。而 setUp() 和 tearDown() 是特殊的方法,分别在测试方法开始之前运行和之后运行。同时,在这里我们也用这两个方法来打开和关闭浏览器。

而在我们的测试方法 test_can_visit_homepage() 里,主要有两个步骤:

  1. 访问首页
  2. 验证首页的标题是“Welcome to my blog”

大部分的测试代码也是以如何的流程来运行着。有一点需要注意的是:一般来说函数名就表示了这个测试所要做测试的事情,如这里就是测试可以访问首页。

如上所示的测试过程称为“四阶段测试”,即这个过程分为如下的四个阶段:

  1. Setup。在这个阶段主要是做一些准备工作,如数据准备和初始化等等,在上面的 setup 阶段就是用 selenium 启动了一个 Firefox 浏览器,然后把窗口最大化了。
  2. Execute。在执行阶段就是做好验证结果前的工作,如我们在测试注册的时候,那么这里就是填写数据,并点击提交按钮。在上面的代码里,我们只是打开了首页。
  3. Verify。在验证阶段,我们所要做的就是验证返回的结果是否和我们预期的一致。在这里我们还是使用和单元测试一样的 assert 来做断言,通过判断这个页面的标题是”Welcome to my blog”,来说明我们现在就是在首页里。
  4. Tear Down。就是一些收尾工作啦 ,比如关闭浏览器、清除测试数据等等。

Tips

需要注意的几点是:

  1. 从运行测试速度上来看,三种测试的运行速度是呈倒金字塔结构。即,单元测试跑得最快,开发速度也越快。随后是服务测试,最后是 UI 测试。
  2. 即使现在的 UI 测试跑得非常快,但是随着时间的推移,UI 测试会越来越多。这也意味着测试来跑得越来越久,那么人们就开始不想测试了。在我们之前的项目里,运行完所有的测试大概接近一个小时,我们开始在会议会争论这些测试的必要性,也在想方设法减少这些测试。
  3. 如果一个测试可以在最底层写,那么就不要在他的上一层写了,因为他的运行速度更快。

参考书籍:

  • 《优质代码——软件测试的原则、实践与模式》
  • 《Python Web 开发: 测试驱动开发方法》