OceaBase开发者大会落地上海!4月20日共同探索数据库前沿趋势!报名戳 了解详情
写点什么

自动化单元测试实践之路

  • 2014-05-09
  • 本文字数:7842 字

    阅读完需:约 26 分钟

自动化单元测试并不是什么新鲜事物,它应该是团队持之以恒的事情,可能有很多团队知道如何去做,但是还做得不够好;还有不少团队不知道如何去做,甚至有一些旧系统还不敢去重构,还在坚持着 Java 中的 main 方法调用的方式来执行,在漫长等待构建结果。

本文主要讲基于 Java 项目如何做自动化单元测试的实践。

1 是否值得

关于单元测试的意义,详细参考 stackoverflow 这篇文章:

http://stackoverflow.com/questions/67299/is-unit-testing-worth-the-effort

Martin Fowler 在博客( http://martinfowler.com/bliki/TestPyramid.html)中解释了

TestPyramid ,如下图所示:

图 -1-1-TestPyramid

Unit 是整个金字塔的基石(在建筑行业,基石是做建筑物基础的石头),如果基石不稳,Service 和 UI 何谈有构建意义呢?只有基石稳如磐石,上层建筑才够坚固。

本来想拿瑞士做钟表的例子来说明下,但同事说的汽车例子更好。一辆汽车由许多配件组成,如果有以下两种选择,你会选择哪个呢?

  1. 所有单元配件没有测试过,在 4S 店,销售人员告诉你:刚组装好,已经开了一天,能跑起来,你可以试试;
  2. 所有单元配件在生产过程已经经过严格测试,在 4S 点,销售人员告诉你,已经通过国家认证,出厂合格,有质量保证,你可以试试;

答案不言而喻了。

实施单元测试,并不代表你的生产效率能提高迅猛,反而有时候阻碍了瞬间的生产效率(传统的开发一个功能,看似就算完成的动作,增加单元测试看起来无法是浪费时间),但是,它最直接的是提升产品质量,从而提升市场的形象,间接才会提升生产效率。

做产品,到底是要数量,还是质量呢?这个应该留给老板们去回答,看企业是否需要长远立足。

2 关键部分

自动化单元测试有四个关键组成部分要做到统一,如图所示:

图 -2-1- 关键组成部分

配置管理:使用版本控制

版本控制系统(源代码控制管理系统)是保存文件多个版本的一种机制。一般来说,包括 Subversion、Git 在内的开源工具就可以满足绝大多数团队的需求。所有的版本控制系统都需要解决这样一个基础问题: 怎样让系统允许用户共享信息,而不会让他们因意外而互相干扰?

如果没有版本控制工具的协助,在开发中我们经常会遇到下面的一些问题:

一、 代码管理混乱。

二、 解决代码冲突困难。

三、 在代码整合期间引入深层 BUG。

四、 无法对代码的拥有者进行权限控制。

五、 项目不同版本发布困难。

  • 对所有内容都进行版本控制

版本控制不仅仅针对源代码,每个与所开发的软件相关的产物都应该被置于版本控制下,应当包括:源代码、测试代码、数据库脚本、构建和部署脚本、文档、web 容器(tomcat 的配置)所用的配置文件等。

  • 保证频繁提交可靠代码到主干

频繁提交可靠、有质量保证的代码(编译通过是最基本要求),能够轻松回滚到最近可靠的版本,代码提交之后能够触发持续集成构建,及时得到反馈。

  • 提交有意义的注释

强制要求团队成员使用有意义注释,甚至可以关联相关开发任务的原因是:当构建失败后,你知道是谁破坏了构建,找到可能的原因及定位缺陷位置。这些附加信息,可以缩短我们修复缺陷的时间。示例:团队使用了 svn 和 redmine,注释是:

refs #任务 id 提交说明

每个任务下可以看到多次提交记录:

图 -2-2- 相关修订版本

  • 所有的代码文件编码格式统一使用 UTF-8
  • 上班前更新代码,下班前提交代码

前一天,团队其他成员可能提交了许多代码到 svn,开始新的一天工作是,务必更新到最新版本,及时发现问题(例如代码冲突)并解决;

当日事,当日毕,下班别把当天的编码成果仅保存在本地,应当提交到 svn,次日团队更新就可以获取到最新版本,形成良性循环。

构建管理:使用 Maven 构建工具

Maven 是基于项目对象模型 (POM),通过为 Java 项目的代码组织结构定义描述信息来管理项目的构建、报告和文档的软件项目管理工具。使用“惯例胜于配置”(convention over configuration)的原则,只要项目按照 Maven 制定的方式进行组织,它就几乎能用一条命令执行所有的构建、部署、测试等任务,却不用写很多行的 XML(消除 Ant 文件中大量的样板文件)。

或许,使用 Ant 来构建的团队要问,为什么用 Maven 呢?简单来说两点

1、对第三方依赖库进行统一的版本管理

说实话,ant 处理依赖包之间的冲突问题,还是得靠人工解决,这个对于研发来说是消耗时间的,倒不如把节省的时间投入到业务中去。另外再也不用每个项目繁琐复制 spring.jar 了,通过 maven 自动管理 Java 库和项目间的依赖,打包的时候会将所有 jar 复制到 WEB- INF/lib/ 目录下。

2、统一项目的目录结构。

官方的约定: http://maven.apache.org/guides/introduction/introduction-to-the-standard-directory-layout.html

src/main/java

Application/Library sources

src/main/resources

Application/Library resources

src/main/filters

Resource filter files

src/main/config

Configuration files

src/main/scripts

Application/Library scripts

src/main/webapp

Web application sources

src/test/java

Test sources

src/test/resources

Test resources

src/test/filters

Test resource filter files

src/it

Integration Tests (primarily for plugins)

src/assembly

Assembly descriptors

src/site

Site

LICENSE.txt

Project’s license

NOTICE.txt

Notices and attributions required by libraries that the project depends on

README.txt

Project’s readme

保证所有项目的目录结构在任何服务器上都是一样的,每个目录起什么作用都很清楚明了。

3、统一软件构建阶段

http://maven.apache.org/guides/introduction/introduction-to-the-lifecycle.html

Maven2 把软件开发的过程划分成了几个经典阶段,比如你先要生成一些 java 代码,再把这些代码复制到特定位置,然后编译代码,复制需要放到 classpath 下的资源,再进行单元测试,单元测试都通过了才能进行打包,发布。

测试框架:JUnit&Mockito

  • JUnit

JUnit 是一个 Java 语言的单元测试框架。

2013 年见过一个旧项目,测试代码还是以 main 作为入口,为什么要使用 JUnit?

JUnit 的优点是整个测试过程无人值守,开发无须在线参与和判断最终结果是否正确,可以很容易地一次性运行多个测试,使得开发更加关注测试逻辑的编写,而不是增加构建维护时间。

团队示例代码:

复制代码
// 功能代码
package com.chinacache.portal.service;
public class ReportService {
public boolean validateParams() {
}
public String sendReport(Long id) {
}
public String sendReport(Long id, Date time) {
}
}
// 单元测试代码
package com.chinacache.portal.service; // 必须与功能代码使用相同 package
public class ReportServiceUnitTest { // 测试类名以 UnitTest (单元测试) 或 InteTest (集成测试) 结尾
// 测试方法名以 test 开头,然后接对应的功能方法名称
@Test
public void testValidateParams() {
}
// 如果功能方法存在重载,则再接上参数类型
@Test
public void testSendReportLong() {
}
// 如果一个功能方法对应多个测试方法,不同测试方法可使用简洁而又有含义的单词结尾,例如 success、fail 等
@Test
public void testSendReportLongDateSuccess() {
}
// 这样通过测试方法名即可知道:测的是哪个功能方法,哪种情况
@Test
public void testSendReportLongDateFail() {
}
}
  • Mockito

Mockito 是一个针对 Java 的 mocking 框架。使用它可以写出干净漂亮的测试用例和简单的 API。它与 EasyMock 和 jMock 很相似,通过在执行后校验什么已经被调用,消除了对期望行为(expectations)的需要,改变其他 mocking 库以“记录 - 回放”(这会导致代码丑陋)的测试流程,使得自身的语法更像自然语言。

Mockito 示例:

复制代码
List mock = mock(List.class);
when(mock.get(0)).thenReturn("one");
when(mock.get(1)).thenReturn("two");
someCodeThatInteractsWithMock();
verify(mock).clear();

EasyMock 示例:

复制代码
List mock = createNiceMock(List.class);
expect(mock.get(0)).andStubReturn("one");
expect(mock.get(1)).andStubReturn("two");
mock.clear();
replay(mock);
someCodeThatInteractsWithMock();
verify(mock);

官方对比文章: http://code.google.com/p/mockito/wiki/MockitoVSEasyMock

反馈平台:Jenkins&Sonar

持续集成平台:Jenkins

Jenkins 的前身是 Hudson 是一个可扩展的持续集成引擎,主要用于:

  • 持续、自动地构建测试软件项目
  • 监控一些定时执行的任务

Jenkins 将作为自动化单元测试持续集成的平台, 实现自动化构建。

图 -2-3-Jenkins 平台

代码质量管理平台:Sonar

Sonar (SonarQube) 是一个开源平台,用于管理源代码的质量。Sonar 不只是一个质量数据报告工具,更是代码质量管理平台。支持的语言包括:Java、PHP、C#、C、Cobol、PL/SQL、Flex 等。

主要特点:

  • 代码覆盖:通过单元测试,将会显示哪行代码被选中
  • 改善编码规则
  • 搜寻编码规则:按照名字,插件,激活级别和类别进行查询
  • 项目搜寻:按照项目的名字进行查询
  • 对比数据:比较同一张表中的任何测量的趋势

Sonar 将作为自动化单元测试反馈报告统一展现平台,包括:

单元测试覆盖率、成功率、代码注释、代码复杂度等度量数据的展现。

图 -2-4 Sonar 平台

3 原则

自动化测试金字塔,也称为自动化分层测试,Unit 是整个金字塔的基石,最重要特点是运行速度非常快;第二个重要特点是 UT 应覆盖代码库的大部分,能够确定一旦 UT 通过后,应用程序就能正常工作。

Unit:70%,大部分自动化实现,用于验证一个单独函数或独立功能模块的代码;

Service:20%,涉及两个或两个以上,甚至更多模块之间交互的集成测试;

UI:10%,覆盖三个或以上的功能模块,真实用户场景和数据的验收测试;

这里仅仅列举了每个层次的百分比,实际要根据团队的方向来做调整。

自动化单元测试原则

提交代码、运行测试的重点是什么?快速捕获那些因修改向系统中引入的最常见错误,并通知开发人员,以便他们能快速修复他们。提交阶段提供反馈的价值在于,对它的投入可以让系统高效且更快地工作。

  • 隔离 UI 操作

UI 应当作为更高层次的测试 Level,需要花费大量时间准备数据,业务逻辑复杂,过早进入 UI 阶段,容易分散开发的单元测试精力。

  • 隔离数据库以及文件读写网络开销等操作

自动化测试中如果需要将结果写入数据库,然后再验证改结果是否被正确写入,这种验证方法简单、容易理解,但是它不是一个高效的方法。这个应当从集成测试的 Level 去解决。

首先:与数据库的交互,是漫长的,甚至有可能要投入维护数据库的时间,那将成为快速测试的一个障碍,开发人员不能得到及时有效的反馈。假设,我需要花费一个小时,才能验证完毕与数据库交互的结果,这种等待是多么漫长呀。

其次,数据管理需要成本,从数据的筛选(线上数据可能是 T 级)到测试环境的 M 级别,如何把筛选合适的大小,这都使得管理成本增加(当然在集成测试中可以使用 DBUnit 来解决部分问题)。

最后,如果一定要有读写操作才能完成的测试,也要反思代码的可测试性做的如何?是否需要重构。

单元测试决不要依赖于数据库以及文件系统、网络开销等一切外部依赖。

  • 使用 Mock 替身与 Spring 容器隔离

如果在单元测试中,还需要启动 Spring 容器进行依赖注入、加载依赖的 WebService 等,这个过程是相当消耗时间的。

可以使用模拟工具集:Mockito、EasyMock、JMock 等来解决,研发团队主要是基于 Mockito 的实践。与需要组装所有的依赖和状态相比,使用模拟技术的测试运行起来通常是非常快,这样子开发人员在提交代码之后,可以在持续集成平台快速得到反馈。

  • 设计简单的测试

明确定义方法:

成功:public void testSendReportLongDateSuccess()

失败:public void testSendReportLongDateFail(),可以包括异常

和单一的断言,避免在一个方法内使用多个复杂断言,这会造成代码结构的复杂,使得测试的复杂性提高。

  • 定义测试套件的运行时间

使用 Mock 构建的单元测试,每个方法的构建时间应该是毫秒级别,整个类是秒级别,理想的是整体构建时间控制在 5 分钟以内,如果超过怎么办呢?

首先,拆分成多个套件,在多台机器上并行执行这些套件;

其次,重构那些运行时间比较长且不经常失败的测试类;

更多参考推荐阅读:《Unit Testing Guidelines》

http://geosoft.no/development/unittesting.html

4 流程

图 -4-1- 典型工作流程

  1. 开发人员遵循每日构建原则,提交功能代码、测试代码(以 UnitTest 结尾的测试类)到 Svn;
  2. Jenkins 平台,根据配置原则(假设配置定时器每 6 分钟检查 Svn 有代码更新则构建)进行:代码更新、代码编译、UnitTest、持续反馈的流水线工作;
  3. 构建结果发送到 Sonar,并且把失败的构建以邮件方式通知影响代码的开发人员;
  4. 开发人员、测试人员需要在 Sonar 平台进行 review;

5 实践

Jenkins 配置重点

构建触发器:推荐使用 PollSCM

Poll SCM:定时检查源码变更(根据 SCM 软件的版本号),如果有更新就执行 checkout。

Build periodically:周期进行项目构建(它不 care 源码是否发生变化)。

配置时间:H/6 * * * *

  • Build 配置

Goals and options:emma:emma -Dtest=*UnitTest soanr:sonar

注明:

emma:emma,Add the “emma:emma” goal to your build to generate Emma reports;

-Dtest=*UnitTest,参数配置,运行以 UnitTest 结尾的测试类;

sonar:sonar,来触发静态代码分析。

需要安装 Emma Plugin https://wiki.jenkins-ci.org/display/JENKINS/Emma+Plugin)

  • 构建后操作

增加 Aggregate downstream test results,勾选自动整合所有的 downstream 测试;

增加 Editable Email Notification,在“高级”选项增加触发器“Unstable”,

勾选“Send To Committers”,Check this checkbox to send the email to anyone who checked in code for the last build。

注明:Editable Email Notification 插件是 https://wiki.jenkins-ci.org/display/JENKINS/Email-ext+plugin

另外一些 Jenkins 的单元测试覆盖率展现方式,可以查看官网。

构建管理工具(Maven)

  • 项目统一使用 Maven 进行构建管理,在 pom.xml 中进行依赖 jar 包配置
  • 持续集成服务器上同时需要安装 Maven,setting.xml 除了配置仓库之外,还需要配置 sonar,包括 sonar 服务器地址、数据库连接方式:
复制代码
<profile>
<id>sonar</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<!-- EXAMPLE FOR MYSQL -->
<sonar.jdbc.url>
jdbc:mysql://127.0.0.1:3306/sonar?useUnicode=true&characterEncoding=utf8
</sonar.jdbc.url>
<sonar.jdbc.driverClassName>com.mysql.jdbc.Driver</sonar.jdbc.driverClassName>
<sonar.jdbc.username>sonar</sonar.jdbc.username>
<sonar.jdbc.password>sonar</sonar.jdbc.password>
<!-- SERVER ON A REMOTE HOST -->
<sonar.host.url>http:/127.0.0.1:9000</sonar.host.url>
</properties>
</profile>

Mockito 配置重点

所有单元测试继承 MockitoTestContext 父类

MockitoTestContext 父类:

复制代码
package com.chinacache.portal;
import java.util.Locale;
import org.junit.BeforeClass;
import org.mockito.MockitoAnnotations;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import com.chinacache.portal.web.util.SessionUtil;
import com.opensymphony.xwork2.util.LocalizedTextUtil;
/**
* Mockito 测试环境。继承该类后,Mockito 的相关注解 (@Mock, @InjectMocks, ...) 就能生效
*/
public class MockitoTestContext {
public MockitoTestContext() {
MockitoAnnotations.initMocks(this);
}
}

BillingBusinessManager 源码:

复制代码
package com.chinacache.portal.service.billing;
// 引入包忽略...
/**
* 计费业务相关的业务方法
*/
@Transactional
public class BillingBusinessManager {
private static final Log log = LogFactory.getLog(BillingBusinessManager.class);
@Autowired
private UserDAO userDAO;
@Autowired
private BillingBusinessDAO billingBusinessDAO;
@Autowired
private BillingBusinessSubscriptionDAO billingBusinessSubscriptionDAO;
@Autowired
private BillingBusinessSubscriptionDetailDAO billingBusinessSubscriptionDetailDAO;
@Autowired
private BillingRegionSubscriptionDAO billingRegionSubscriptionDAO;
@Autowired
private BillingRegionDAO billingRegionDAO;
@Autowired
private ContractTimeManager contractTimeManager;
/**
* 根据 id 查询业务信息
* @return 如果参数为空或者查询不到数据,返回空列表 <br></br>
* O 中的中、英文业务名来自 BILLING_BUSINESS 表
*/
public List<businessvo> getBusinessesByIds(List<long> businessIds) { return billingBusinessDAO.getBusinessbyIds(businessIds); } } </long></businessvo>

BillingBusinessManagerUnitTest 类:

复制代码
// 引入包忽略...
public class BillingBusinessManagerUnitTest extends MockitoTestContext {
@InjectMocks
private BillingBusinessManager sv;
@Mock
private BillingBusinessDAO billingBusinessDAO;
@Test
public void testGetBusinessesByIds() {
List<BusinessVO> expected = ListUtil.toList(new BusinessVO(1l, "a", "b"));
// 简洁的语法如下所示
when(billingBusinessDAO.getBusinessbyIds(anyListOf(Long.class))).thenReturn(expected);
List<Long> businessIds = ListUtil.toList(TestConstants.BUSINESS_ID_HTTP_WEB_CACHE);
List<BusinessVO> actual = sv.getBusinessesByIds(businessIds);
Assert.assertEquals(expected, actual);
}
}

更多 Mockito 的使用,可以参考官网: http://code.google.com/p/mockito/

6 总结

如何加强开发过程中的自测环节,一直都是个头痛的问题,开发的代码质量究竟如何?模块之间的质量究竟如何?回归测试的效率如何?重构之后,如何快速验证模块的有效性?

这些在没有做自动化单元测试之前,都是难以考究的问题。唯有通过数据去衡量,横向对比多个版本的构建分析结果,才能够发现整个项目质量的趋势,是提升了,还是下降了,这样开发、测试人员才能够有信心做出恰当的判断。

当然,单元测试也不是银弹,即便项目的覆盖率达到 100%,也不能表明产品质量没有任何问题,不会产生任何缺陷。重点在于确保单元测试环节的实施,可以提前释放压力、风险、暴露问题等多个方面,改变以往没有单元测试,所有问题都集中到最后爆发的弊端。

最后,用一张图来做个对比:

图 -6-1- 使用前后对比

增加单元测试之后:

  1. 开发效率有望提升 5-20%;重构、回归测试效率提升 10%,降低出错的几率,总体代
  2. 码质量提升;
  3. 在开发过程中暴露更多问题,将风险和压力提前释放,持续构建促使开发重视代码质量;
  4. UnitTest 质量对于团队来说,是可视化了,交付的是有质量的产品,而不是数量;

作者简介

李乐,测试经理,7 年以上工作经验,目前就职于 ChinaCache 质量部

博客:jooben.blog.51cto.com

微博:weibo.com/iamlile


感谢侯伯薇对本文的审校和策划

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。

2014-05-09 09:5618102

评论

发布
暂无评论
发现更多内容
自动化单元测试实践之路_最佳实践_乐少_InfoQ精选文章