5.3 测试代码的写法
自动化测试可以说是 CI 的重点。如同我们在第 2 章问题 5 中所见,如果不持续地执行自动化测试,就无法发现不知不觉中发生的退化。
同样还有问题 7,因为缺乏测试,所以无法进行重构。通过实施 CI,构筑起能够时常自动执行测试的环境,那样就应该有信心进行重构了。从长期来看也能够有效地提高开发质量。随着质量的提高,添加新功能的速度也会相应地加快,并最终向客户提供更多的价值。
虽说 CI 的自动化测试能有效提高开发速度及质量,但为了执行自动化测试,必须编写测试代码。
请使用之前叙述过的 TDD 或 BDD 等测试框架来编写测试代码。还有一些 Web 应用程序框架原生自带测试框架。
5.3.1 作为 CI 的对象的测试的种类
一般来说软件测试可进行如下分类 30 。
30 除此之外还有性能测试、β 用户测试等各类测试,这里就不提了。
单元测试(Unit Test,UT)
集成测试(Integration Test,IT)
用户验收测试(User Acceptance Test,UAT)
回归测试
其中哪些应该作为 CI 的对象呢?答案是全部。当然测试的搭建是一项耗费成本的工作,因此要根据项目的状况,仔细考虑希望得到的测试效果和成本之间的平衡。但是如果成本允许的话,还是应该将上述所有测试都作为 CI 的对象。
本章对主要由开发人员编写的单元测试和集成测试的 CI 实施进行说明。用户验收测试的相关内容将在第 7 章进行讲解。而利用 CI 持续地执行测试可以说就是回归测试。
5.3.2 何时编写测试
何时编写测试代码?根据时间以及项目状况的不同,需要注意的事项也有所不同。
●…… 新建工程的情况
在 5.2 节中已经讲解过,新建工程时,请将测试框架和 Maven 等 build 工具一起导入。也可以使用 Ruby on Rails 或 Play Framework 等全栈式 Web 应用程序框架。每次新建类或添加方法时,都要注意将测试代码一同添加,并一同提交到版本管理系统。
编写测试代码,从短期看来,很多人担心会影响开发速度。这样的想法是不正确的。在工程代码的基础上还要编写测试代码,这的确会耗费时间,因此仅从完成编码的时间来看的确是延长了。但是编写测试代码是开发人员必须进行的重要工作,因为开发人员的工作并不是只要完成编码就结束了,而是要向顾客提供价值。换言之,就是要编写按照预期运行的软件,提供没有退化的产品。
使用测试优先或者需求优先的开发方式,就能够在编写代码的同时确认程序是否正常运行,进而使返工减少,开发的整体速度加快。
●…… 已有工程中没有测试的情况
很多已有的工程都没有实施自动化测试。根据工程规模的不同,应该采取的措施也有所差异,但还是应该尽可能地为能够测试的部分编写测试代码。
但未经测试的代码往往是无法编写或者很难编写测试的代码。类没有得到合理地职责分割,与 RDBMS 或外部系统的 API 紧密耦合,不启动整个程序就无法确认动作,这些都是常有的事情。在这样的情况下,首先请编写从最外侧确保程序动作的测试代码。这里的“最外侧”是指用户所看到的用户界面。
Web 应用程序方面,有一款名为 Selenium 的仿真浏览器动作并进行测试的著名工具。首先可以使用 Selenium 编写针对浏览器用户界面的用户验收测试,并实施自动化。重复上述测试即等同于实施了回归测试,这样就不必再担心退化的发生,能够放心地对代码进行重构。在借助 Selenium 确保对外的用户界面不变的基础上,内部逐渐转变设计,分割为能够测试的类。这样既能逐渐提高质量,添加新功能的速度也会相应加快。
使用 Selenium 进行用户验收测试的相关内容,会在第 7 章进行详细讲解。
●…… 修改 bug 或添加新功能的情况
无论是一开始就编写了测试代码这样幸运的工程,还是没有测试代码这样悲惨的遗留工程,都会在修改 bug 或者增加新功能时添加代码。这时请一定要编写测试代码。修改 bug 的话,可以仅以修改的函数为对象添加数行测试代码。千里之行始于足下。即便只是一点一点地积累测试代码,也总有一天会覆盖全体代码。
编写测试代码还涉及工程的文化层面。也就是说,缺乏测试的工程是没有编写测试文化的工程。为了改变这样的文化,实际编写测试代码并展现其效果是最好的办法。因此在修改 bug 或添加新功能时请一定要编写测试代码。
5.3.3 棘手的测试该如何写
为了对和 RDBMS 或外部系统的 API 耦合的部分这种比较难测试的部分进行测试,这里介绍一些小技巧。
●…… 和外部系统有交互的测试
连接 RDBMS 等数据库的部分,或者调用 Twitter、Facebook 的 API 的部分等,编写依赖于应用程序外部状态的测试是比较困难的。这部分的测试要怎么写才好呢?
●…… 使用 mocking 框架进行测试
关于这部分测试,单元测试基本上是利用仿真对象 (mock) 或桩程序 (stub) 进行测试的。
例如,可以使用名为 Mockito31 的 mocking 框架来仿真连接数据库的部分。大致的代码如下所示 32 。
32 这里的示例代码非常简单。在实际工程中编写这样的 mock 的测试代码时,对象类中获取外部数据的部分需要能够简单地替换成 mock,需要具备 DI(Dependency Injection)的机制。DI 的形式多种多样,这方面可以参考相关资料。
// 检查数据库中的用户的年龄是否满18岁 // 编写Authrization类的单元测试 // 将数据库连接对象转换为mock对象 User mocked = mock(User.class) // 根据测试目的设置mock对象的返回值 when(mocked.getAge()).thenReturn(16); // 在测试对象中注入mock对象并开始测试 Authorization auth = new Authorization(mocked); // 确认年龄检查的结果 assertThat(auth.proveAge(), is(false)mock 、when 、thenReturn 都是 Mockito 提供的方法。这里创建 User 类的 mock 对象,在该对象的 getAge 方法被调用时,返回值定义为 16。这里不再做更详细的说明,上述测试代码的写法还是相当直观、易懂的。Mockito 是一款功能强大的 mock 框架,详细的使用方法请参考相关资料。
这样,本来不得不根据测试用例来修改数据库值的地方,借助 mock 框架便不必实际修改数据库,使用 mock 对象就能够编写测试。
虽然没有列举示例代码,但和 Web 服务的 API 相关的部分同样可以使用 Mockito 来仿真。例如,可以为连接 Web 服务的类制作 mock,使其返回固定的 JSON 作为响应。
CI 中,由于同一测试要持续地、反反复复地执行,因此无论执行几次都必须返回相同的结果。数据库是将状态持久化的工具,所以反复执行测试的话,数据库的状态会发生变化。防止这一问题的方法之一就是 mock。如前所述,mock 能够提高单元测试的效率,不仅是数据库关联的测试,调用外部 Web 服务 API 的测试同样可以使用。
如果不使用 mock 框架,也可以在每次进行测试时启动数据库、创建数据库、CREATE 表格、加载数据,测试结束后再将数据库删除。已经存在大量代码没有测试的情况下,添加 mock 的机制比较困难,因此多采用上述方法。
例如,在使用 Selenium 实施用户验收测试时,比起使用 mock,使用真正的数据库进行测试或许更有价值。用户验收测试中数据库连接是否成功本身也是需要测试的项目之一,多数情况下,表之间复杂的状态迁移也需要进行测试。并且在进行用户验收测试时,如果要将所有的相关对象都 mock 化,那么所涉及的数据条数以及表的数量实在太大,实际上很难做到。
这样的情况下只能采取在每次测试时构建并删除数据库的方法。由于这样的处理非常费时,测试的速度也会由此变慢。并且在真实的数据库上创建这样的机制要耗费不少的时间和精力 33 。
33 这样的情况下可以使用 DbUnit 等工具,虽然这些工具有些旧了,但还是多少能够节省一些时间。
●…… 使用内存数据库进行测试
这里有必要考虑使用一款名为 H2 的数据库。它是用 Java 编写的轻量级的数据库发动机,支持内存数据库模式。使用 H2 能够使得重量级的数据库构筑处理变得轻便,测试的范围也得到了相应的拓展。需要注意的是,H2 无法支持各个 RDBMS 的独特的检索语法,关于这一点,可以在中间加一层 Hibernate 这样的 O/R 映射工具,来吸收各个数据库之间的差异。
现代的 Web 应用程序框架应该能够根据运行时的配置更改数据库的连接。例如 Play Framework 在执行测试时默认使用 H2 连接数据库 34 ,并且还提供了名为 Fixture 的数据加载机制。借助该机制,执行测试时就可以用和正式环境相同的步骤,在内存上高速地构建数据库和表,加载数据,在此基础上,编写的测试用例也能够以和正式环境相同的方式来访问数据。因为是内存数据库,所以能够在测试结束后轻易地销毁。测试也因此具备了很高的独立性。写过测试代码的各位一定知道,和数据库紧密耦合的话,每次测试后数据库状态都会发生变化,因此测试非常难写。而内存数据库将很大程度地改善这个问题。
34 当然配置可以修改。
●…… 数据库变更管理和配置文件管理的测试
如第 3 章中所提到的那样,在利用数据库迁移机制进行数据库的变更管理的情况下,SQL 的正确与否可以通过表的生成是否正确来判断,但应用程序的动作是否正确还不得而知。
数据库迁移自身的测试也可以简单地写一下。数据库构筑完成后,写一下简单的冒烟测试 35 ,只要能够确认应用程序的基本部分运行正常就可以了。
35 简单的动作确认测试。为了确认是否能够实施正式的测试而进行的准备性质的测试。原本是电机行业的术语,指将冰箱、电视机等接通电源看看有没有冒烟。
环境构建部分的测试同样也是写一下比较好。中间件启动后,确认一下是否有响应,即使是这样简单的测试,也是非常有效的。关于使用 Chef 或 serverspec 的环境构建自动化以及相关的测试详情,我们将在第 6 章讲述。
数据库的变更管理也好,环境配置管理也好,为测试这些项目,严密地说需要在这些项目的基础上来确认应用程序的所有动作。从这个意义上来说,如果要对这方面进行严密的测试的话,通过用户验收测试来确认是比较正确的做法。当然也要根据工程的状况、预算以及期限适度地编写测试。
●…… UI 相关的测试
UI 相关的测试是比较困难的部分。因此要尽可能地将业务逻辑内聚在 MVC 模式中的 M(模型)中,设计为不必涉及 UI 就能网罗几乎所有的测试用例。应该尽量避免编写依赖于 UI 的测试。
最近使用充分挖掘了 Ajax 特性的 Rich UI 的 Web 应用程序也开始多了起来。随之而来的是,JavaScript 的 MVC 框架也开始大量出现。如果在客户端较多地使用了 JavaScript 的话,应该尽量将业务逻辑内聚在 M 之中,以便可以通过 UI 以外的部分进行测试。
不得不针对 UI 编写测试的情况下,可以利用 Selenium 这样的工具来实现用户验收测试的自动化。使用 Selenium 的用户验收测试自动化的相关内容将在第 7 章详细讲解。
●…… 棘手的测试要权衡工数
编写和外部交互相关的测试或数据库变更管理、UI 相关的测试等相当棘手的测试自然会耗费相当多的工数。如果只考虑质量的话,这些测试的确是完整地写一下比较好,但实际上编写测试可用的时间并不是无限的。
测试的自动化是越做越耗费时间和精力。将编写测试所获得的质量提升以及能够在未来消减的工数,与编写测试实际耗费的工数进行权衡,注意保持两者的平衡。
如果过度执着于测试自动化的工作,其本身就像填字游戏一样,虽然有趣,但不知不觉之中就可能做过了头,投入和回报不相符了。
请大家根据自身所处的状况,以及项目所追求的价值,在最合适的限度内来实现测试的自动化。
