软件的可测试性是指在一定的时间和成本前提下,进行测试设计、测试执行以此来发现软件的问题,以及发现故障并隔离、定位其故障的能力特性。简单的说,软件的可测试性就是一个计算机程序能够被测试的容易程度。
一般来说可测试性很好的软件必然是一个高内聚、低耦合、接口明确、意图明晰的软件,而不具可测试性的软件往往具有过强的耦合和混乱的逻辑。软件组件(模块,类)的可测试性由以下因素决定:可控制性、可观察性、可隔离性、关注点分离、可理解性、可自动化。
可控制性
设计程序时,要保证被测组件的状态可以操控。“对软件的控制越好,测试越能够被自动化执行与优化”。
参数配置化
通过参数配置可以直接控制系统的状态及变量。例如报表异步导出程序,控制导出条数上限为2万条,该上限值“20000”配置在参数表中,实际测试执行中,可以通过修改参数配置将该值调小,增加测试便利性。
重大分支有开关
什么叫重大分支,简单点说就是逻辑上靠前的分支,比如:我们的系统有页面级缓存,当一个请求过来时我们是否读缓存,这是一个比较靠前的逻辑,在此分道扬镳可能意味着程序之后会走完全不同的路径。这样做的好处是非常明显的:易于构造测试用例,易于把握分支覆盖。
分支可覆盖
通过某种输入组合,所有的代码都可能被执行,避免产生测试无法覆盖的无效分支。
确定的执行路径
确定的执行路径,为什么会出现这个问题?你的程序可能会利用时间或者随机数选择一条路径去执行,这对于测试而言是灾难性的,从可测性而言这样的程序应该怎么改进呢:把生成时间或随机数的方法提取到一个公共函数中,那么在测试的时候你就可以方便自己控制程序的执行路径。
避免使用大量的静态字段
尤其在不同的类中共享静态的集合类。比如下面的代码:
public class Catalog {
private static List<Person> people = new ArrayList<>();
public void addPerson(Person person) {
if (Calendar.getInstance().get(Calendar.YEAR) - person.getYearOfBirth() < 18) {
throw new IllegalArgumentException("Only adults admitted.");
}
people.add(person);
}
public int getNrOfPeople() {
return people.size();
}
}
现在我们来看看测试代码:
public class CatalogTest {
private final Catalog underTest = new Catalog();
@Test(expected=IllegalArgumentException.class)
public void addingAMinorShouldThrowException() {
assertEquals(0, underTest.getNrOfPeople()); // precondition
Person p = new Person(2015);
underTest.addPerson(p);
}
@Test
public void addingAnAdultShouldSucceed() {
assertEquals(0, underTest.getNrOfPeople()); // precondition, or is it?
Person p = new Person(1985);
underTest.addPerson(p);
assertEquals(1, underTest.getNrOfPeople());
}
}
如果运行这两个测试,你会发现期待抛出异常的那个用例失败了。因为JUnit可以自由安排用例执行顺序而不依赖于编写用例的顺序。在这段代码中第二个测试用例先运行,它检测集合是空的,然后成功注册了一个adult。由于我们的集合是静态的,第一个测试用例检测到集合不是空的(我们在之前的测试用例已经放进去一个people了),所以失败了。
一旦我们删掉static关键字,两个测试用例都成功执行。在每个测试用例执行前,JUnit会将测试用例中的字段初始化(不仅仅是@Before注解方法中的字段)。现在我们有一个实例成员,而不是一个绑定在类上的静态people列表。这意味着每个测试用例运行前都会创建一个新的Catalog对象,包含一个新的列表。每次我们都有一个新的空people列表。
当然,在这个例子中我们很容易发觉并解决这个问题,但想象一个庞大的系统中,有众多类操作的people列表。
静态的可变集合就像一个垃圾桶,充斥着各种垃圾,真应该避免使用。
接口可控制
各接口在外界使用适当的手段能够直接调用进行操作,所谓适当的手段主要包括使用测试工具和增加额外代码。对于向外提供的接口的接洽处能够人为的对接,比如构造测试环境模拟接口对接。人为对接时各接口所需的条件和参数能够轻易达到和提供。在接口设计中需要同时考虑接口模拟器的编写,便于接口模拟器的高效实现,以降低接口联测的依赖,提高接口测试效率。
可观察
可以观察(中间和最后)的测试结果。具体如,设计程序输出时,要求程序输出能够被准确地测试到,以决定测试是否通过。
关键日志纪录
在程序开始、完成或其他关键步骤节点记录日志、日志记录应完备详细。例如最底层投资品估值核心表非顶层数据批量生成中,在存储过程执行结束时记录完成的日志,代码片段如下:
os_msg:='最底层投资品估值核心表非顶层数据批量生成完成';
os_msg:=substrb(os_msg,1,400);
vd_endtime:=ppc.p_ppc_pub_get.f_sys_time();
ppc.p_ppc_bat_log.r_info(is_task_id,
vs_proc_id,
vd_starttime,
vd_endtime,
os_msg,
is_batch_date,
'N',
'N',
'N',
'数据日期:'||is_data_date||'组合内码:'||is_fund_code,
'');
异常日志纪录
在程序进入异常时记录日志,日志记录应完备详细。例如最底层投资品估值核表非顶层数据批量生成中,需要查询投资品余额标准化数据表取得所属计划投资品二级、三级类型,如发生取不到记录或者取得多条记录的异常情况进行本次循环的异常处理中记录错误日志,明确记录异常发生的位置“取得所属计划投资品二级、三级类型”,SQLERRM、以及对应的参数信息等,代码片段如下:
BEGIN
vs_para_value:='数据日期:'||is_data_date||'组合内码:'||is_fund_code||'投资品代码:'||inv_data.product_code||
'投资品一级类型:'||inv_data.product_type;
......
......
--取得所属计划投资品二级、三级类型,不单独捕获异常,如发生异常则进入本次循环的异常处理中记录错误日志,不再继续插入数据
vs_msg:='取得所属计划投资品二级、三级类型';
SELECT t2.product_type_2,t2.product_type3
INTO vs_top_prod_type_2,vs_top_prod_type_3
FROM ppc.sub_inv_bal_std t2
WHERE t2.data_date=is_data_date
AND t2.fund_code=is_fund_code
AND t2.product_code_in=inv_data.top_en_code_in
AND t2.product_type_1 ='08';
......
......
EXCEPTION
WHEN OTHERS THEN
os_msg:='最底层投资品估值核心表非顶层数据批量生成出错,'||vs_msg||SQLERRM;
os_msg:=substrb(os_msg,1,400);
oi_flag:=1;
ROLLBACK;
vd_endtime:=ppc.p_ppc_pub_get.f_sys_time();
ppc.p_ppc_bat_log.r_error(is_task_id,
vs_proc_id,
vd_starttime,
vd_endtime,
os_msg,
is_batch_date,
'N',
'N',
'01',
vs_para_value,
'');
END;
可隔离
被测组件可以独立测试。
1)模块化:软件系统由独立模块构成,能够独立测试各模块,降低了单个单元测试的复杂度,从而降低测试难度,提高测试效率。
2)高内聚、低耦合:在模块化设计中强调高内聚、低耦合,使得功能之间的关系更依赖接口而非具体的内部实现,只要接口不变,模块内部的逻辑变化就不会影响另外一个模块,增强了模块独立性,便于隔离问题影响。
高耦合的类
假设现在有一个Validation的服务,要针对用户输入的id与密码进行验证。Validation的CheckAuthentication方法的业务逻辑如下:
- 根据id,取得存在数据源中的密码。
- 根据传入的密码,进行hash运算。
- 比对数据源回传的密码,与输入密码经过hash运算的结果,是否吻合。
简单的程序代码如下:
using System;
public class Validation
{
public bool CheckAuthentication(string id, string password)
{
// 取得数据库中,id对应的密码
AccountDao dao = new AccountDao();
var passwordByDao = dao.GetPassword(id);
// 针对传入的password,进行hash运算
Hash hash = new Hash();
var hashResult = hash.GetHashResult(password);
// 对比hash后的密码,与数据库中的密码是否吻合
return passwordByDao == hashResult;
}
}
public class AccountDao
{
internal string GetPassword(string id)
{
//连接DB
throw new NotImplementedException();
}
}
public class Hash
{
internal string GetHashResult(string passwordByDao)
{
//使用SHA512
throw new NotImplementedException();
}
}
从上面的代码我们看到取得数据是通过AccountDao对象,Hash运算则通过Hash对象。看起来各对象职责分离,一切都很合理。实际上,我们仔细看会发现对CheckAuthentication方法来说,其实根本就不管、不在乎AccountDao以及Hash对象,因为那不在他的业务逻辑中。但却为了取得密码,而直接初始化AccountDao对象,为了取得Hash结果,而直接初始化Hash对象。所以,Validation对象便与AccountDao以及Hash对象直接相依。其类关系如下图所示:
就单元测试的角度来说,当想要测试Validation的CheckAuthentication方法是否符合预期时,会发现要单独测试Validation对象,是件不可能的事。当调用Validation的CheckAuthentication方法就肯定会使用AccountDao的GetPassword方法,进而联机至DB。
那我们该如何隔离对象之间的相依性呢?隔离相依性的重点很简单,别直接在目标对象中初始化相依对象。首先,为了扩充性,定义接口,让目标对象仅相依与接口,程序代码改写成下面方式:
public interface IAccountDao
{
string GetPassword(string id);
}
public interface IHash
{
string GetHashResult(string password);
}
public class AccountDao : IAccountDao
{
public string GetPassword(string id)
{
throw new NotImplementedException();
}
}
public class Hash : IHash
{
public string GetHashResult(string password)
{
throw new NotImplementedException();
}
}
public class Validation
{
private IAccountDao _accountDao;
private IHash _hash;
public Validation(IAccountDao dao, IHash hash)
{
this._accountDao = dao;
this._hash = hash;
}
public bool CheckAuthentication(string id, string password)
{
// 取得数据库中,id对应的密码
var passwordByDao = this._accountDao.GetPassword(id);
// 针对传入的password,进行hash运算
var hashResult = this._hash.GetHashResult(password);
// 对比hash后的密码,与数据库中的密码是否吻合
return passwordByDao == hashResult;
}
}
上面可以看到,原本直接相依的对象,现在通过相依与接口。而CheckAuthentication逻辑更加清楚了,取得数据中的id对应的密码(数据怎么来的,不必关注),针对password进行hash(怎么hash的,不必关注),针对hash结果与数据库中存放的密码比对,回传结果。修改后的类关系如下图:
如此一来,目标对象就可以专注于自身的业务逻辑,而不直接相依与任何实体对象,仅相依与接口。为什么这样的设计方式,就可以帮助我们只独立的测试Validation的CheckAuthentication方法呢?大家回过头来看一下,CheckAuthentication方法中,使用到了IAccountDao的GetPassword方法,取得id对应的密码。也用到了IHash的GetHashResult方法,取得hash运算结果。接着才是比对两者是否相同。通过接口可扩展性、多态和重载的特性,建立一个StubAccountDao的类型,来实现IAccountDao。并且在GetPassword方法中,不管传入参数为何,都固定回传“Hello World”,代表Dao回来的的密码。用同样的方式,让StubHash的GetHashResult,也回传“Hello World”,代表hash后的结果。程序代码如下:
public class StubAccountDao : IAccountDao
{
public string GetPassword(string id)
{
return "Hello World";
}
}
public class StubHash : IHash
{
public string GetHashResult(string password)
{
return "Hello World";
}
}
这样就可以让我们的测试目标对象不直接相依与AccountDao和Hash对象,通过stub对象来模拟,以验证Validation对象本身的CheckAuthentication方法逻辑,是否符合预期。测试程序如下:
public void CheckAuthenticationTest()
{
//arrange
// 初始化StubAccountDao,来当作IAccountDao的执行对象
IAccountDao dao = new StubAccountDao();
// 初始化StubHash,来当作IStubHash的执行对象
IHash hash = new StubHash();
Validation target = new Validation(dao, hash);
string id = "随便写";
string password = "随便写";
bool expected = true;
bool actual;
//act
actual = target.CheckAuthentication(id, password);
//assert
Assert.AreEqual(expected, actual);
}
类似上面介绍的Stub类的方法在单元测试中有很广泛的应用。这种方法要求在设计时充分考虑类之间的联系,类与类之间的关系要基于接口,而不是基于实现。这样的方式也会为程序开来最大的灵活性。一般来说,下列模块都应该通过接口的方式进行调用:硬件、数据库、还没有完成的类等。
用户界面
对用户界面进行单元测试,要求程序具有较好的层次性,用户界面和其背后的业务逻辑要是分离的。也就是说:用户界面只是薄薄的一层,并且用户界面和业务逻辑之间的耦合度很小,模块间联系较为松散。这样的实现进行单元测试就容易了。后台的业务逻辑可以独立进行测试,前台的界面可以写一个假的后台接口实现进行测试。基本上所有的东西都是可以进行自动化测试的。
程序设计一般遵循层次原则,一般来说,上层的程序可以调用下层的程序,同层之间也可能存在相互的调用。具体到UI的设计, 如下所示:UI和下面各个层次之间的调用时单方向的,UI调用业务代码得到返回值,业务代码只能通过消息、事件和抛出错误等方式与UI发生联系,不能够在业务代码中直接调用UI,否则业务代码将不可测试。
关注点分离
关注点分离是日常生活和生产中广泛使用的解决复杂问题的一种系统思维方法。大体思路是,先将复杂问题做合理的分解,再分别仔细研究问题的不同侧面\(关注点\),最后综合各方面的结果,合成整体的解决方案。在概念上分割整体以使实体个体化的观点可以追溯到柏拉图。柏拉图把探究自然比作在关节处切割自然,窍门在于要找到关节,不要像生疏的屠夫那样把关节切得粉碎。庄子在庖丁解牛寓言中也阐释了类似的真知灼见。 作为最重要的计算思维原则之一,关注点分离是计算科学和软件工程在长期实践中确立的一项方法论原则。此原则在业界更多的时候以分而治之 的面目出现,即将整体看成为部分的组合体并对各部分分别加以处理。模块化是其中最有代表性的具体设计原则之一。
关注点分离(Separation of concerns,SOC)是对只与“特定概念、目标”(关注点)相关联的软件组成部分进行“标识、封装和操纵”的能力,即标识、封装和操纵关注点的能力。是处理复杂性的一个原则。由于关注点混杂在一起会导致复杂性大大增加,所以能够把不同的关注点分离开来,分别处理就是处理复杂性的一个原则,一种方法。
关注点分离是面向方面的程序设计的核心概念。分离关注点使得解决特定领域问题的代码从业务逻辑中独立出来,业务逻辑的代码中不再含有针对特定领域问题代码的调用(将针对特定领域问题代码抽象化成较少的程式码,例如将代码封装成function或是class),业务逻辑同特定领域问题的关系通过侧面来封装、维护,这样原本分散在在整个应用程序中的变动就可以很好的管理起来。
好的架构设计必须把变化点错落有致地封装到软件系统的不同部分。要做到这一点,必须进行关注点分离。Iuar Jacobson在《AOSD中文版》中写道:
“好的架构必须使每个关注点相互分离,也就是说系统中的一个部分发生了变化,不会影响其他部分。即使需要改变,也能够清晰地识别出那些部分需要改变。如果需要扩展架构,影响将会最小化,已经可以工作的每个部分都将继续工作。"
上述论述中的三句话:
“系统中的一个部分发生了变化,不会影响其他部分。”
“即使需要改变,也能够清晰地识别出那些部分需要改变。”
“如果需要扩展架构,将影响最小化,已经可以工作的每个部分都将继续工作。”
可以说是对软件开发者的奋斗目标的最精辟的论述。也是软件设计要达到的最高目标。
举例来说,ASP.NET MVC就是关注点分离的一个体现,它将原来的ASP.NET WebForm分离成模型(model)-视图(view)-控制器(controller),从而把业务逻辑、数据、界面分离,这也是组织代码结构的一个形式。
MVC的基本结构:
- Model层表示应用程序的数据核心,通常负责在数据库中存取数据。
- View是应用程序的显示层,通常是依据模型的数据而建立。
- Controller是用来控制和处理输入输出的,是处理用户交互的部分,也负责向模型(Model层)发送数据。
MVC的这个设计各个关注点是分开的,这样有助于我们管理和开发复杂的应用程序,我们可以在某个时间点只集中精力在其中的某一个关注点,而不是所有的部分。举例来说,前端的开发人员可以配合设计团队绕过业务逻辑,专注在视图和交互设计部分。另外的一端,DBA也可以配合某个团队专注在数据持久化的部分,而中间的业务逻辑层又可以由其他团队集中精力来负责。这种分层也简化了分组开发,让测试也更为容易。
除了ASP.NET MVC还有其他的框架也是这样的关注点分离的思想,比如Django,Structs,Spring等等。
关注点分离是实现上述目标的基本方法。关注点分离的基本方法有:
●纵向分离
将一个功能的实现分成界面层(UI Layer),业务逻辑层(Business Layer)和数据持久化层(Data Access Layer),就是这一种自上而下的纵向的分层手法。
●横向分离
我们倡导的模块化的编程,把我们的软件拆分成模块或子系统。从左到右是模块1、模块2、模块3,这是一种水平方向的切割。这跟纵向的分离是两个不同的方向,横向分离大多是模块化的过程。
●切面分离
在软件架构设计中,有些内容是多个层之间都需要的,比如日志(logging),在你的系统里面,界面层、逻辑层、数据访问层可能都需要写日志,这种跨到多层同样逻辑就可以考虑切面分离。在asp.net mvc中,我们可以使用filter来实现, Spring中也有SpringAOP等等。
●依赖方向分离
依据如下考虑点,我们来决定某个类应该放在哪个层次里面,或者考虑将某一层切割成多层。
有些类要修改的几率比其他的类修改的几率大得多。
具体的类比抽象类修改的几率大得多。
修改被依赖得很多的类可能引起很大的改动。
某些类比其他类被重用的可能性大得多。
●关注数据分离
在组织数据时,应该尽量考虑数据本身的固有属性,如果不是它们的固有属性,那么应该分离出来。
比如产品的类就不应该关联customer类,因为产品不应该跟客户直接产生数据关系,产品的颜色、型号、描述才是产品该有的固有属性。至于客户,应该是用订单类来把他们联系在一起。
- 关注行为分离
跟上面讲的一样,行为也应该是事物或对象的固有的本身的行为,明显偏离原来行为的,应该考虑成另外的关注点儿分离开。
比如有一个函数叫做CreateNewCustomer(),那么CreateNewCustomer的行为就应该限定在创建一个新客户上面,给新客户自动发优惠券的动作就不能放到这个函数里面。
- 扩展分离
如果基于某种设计,原先不具有某些行为需要增加,可以考虑通过扩展或插件的形式来完成,将这些功能放入到插件或扩展中,就是扩展分离。
比如Firefox、Chrome的去广告的插件,这些功能增加了系统原本的行为,将这些行为分离到插件里面去,就是扩展分离。
- 委托分离
如果某个行为还无法具体确定,可以使用委托的方式。
比如C#的delegate,当我们还不知道某些具体行为应该如何实现,或者不应该在此处对该行为进行实现,或者有多个行为可以互相替代,就可以将函数的参数指定为一个delegate。至于delegate具体怎样实现,那是其他部分应该关注的点。
比如现在需要将Customer的信息持久化,就可以把这个请求委托给DatabaseManager或WebSerivceManager,由他们自行处理数据,然后返回给我结果。
- 反转分离
现在有了很多的依赖注入的框架,像Autofac,Unit,Castle Windsor等等,这些帮助我们做依赖翻转,从而倒置依赖关系。
要指出是,上面提到了9种分离层次的概念,每一种概念都可以任意的与其他概念组合在一起,从而产生更多的变化。
在实际的开发过程中,没有东西是一成不变的,而层次和架构也应该是在开发的过程里面不断完善和重构。
可理解
设计程序时,程序模块或组件简单易理解,测试起来越容易(测试成本也更低)。
1.设计文档易理解
设计文档,包括软需设计和程序设计,均要求层次分明,清晰易懂,全面准确,无二义性。
如软需编写过程的经验和方法,参见《软需编写方法》
2.接口文档易理解
接口功能明确,各输入输出参数均有明确释义。对于接口约定内容,比如输入字段是否必输、输入范围限制、输入内容限制、输出字段含义、异常返回处理、超时时间等均需在接口文档中描述清晰。
3.注释易理解
编码注释清晰易于理解。一般程序设计时很少关注注释的问题,但往往注释容易出现以下几个易犯问题:
(1)无关注释
A产品申请单功能,但如下第二段对于该程序来说就是无关注释:
<%—
fileName :/WebContent/A_apply.jsp
author :kfzx-xiaocai
createDate :2017-11-05
description :本页面实现了A产品申请单功能*****
—%>
<%—
fileName :/WebContent/B_apply.jsp
author :kfzx-bb
createDate :2008-01-05
description :本页面实现了B产品申请单功能*****
—%>
(2)错误注释
程序实现与注释不相符。
//当起息日期不等于当前日期,则提示异常。
if (valueDate > currentDate)
{
alert(“异常!”);
}
(3)漏掉注释
对于重要业务分支、异常分支或复杂处理漏掉注释,让其他开发人员代码理解困难。如下,没有在重要业务分支中表述清晰,开发人员无法很好理解代码意图。
if(条件一)
{
业务过程一;
}else if (条件二)
{
业务过程二;
}
(4)冗余注释
对于每一行代码或做过多简单重复性注释同样让其他开发人员不易理解。
String A = “”; //定义StringA
String B = “”;//定义StringB
A = context.getValueAt(“test”);//A取test值
B = context.getValueAt(“test2”);//B取test2值
if(A == B)//如果A等于B
……
(5)理解困难(二义)注释
注释写的不清晰,不仅不会有助于理解代码,往往会造成理解上的困难。
如下注释就不容易理解,往往代码内容笔注释更容易让人理解:
if(A==B||(C==D&&E!=F))//一种可能A=B,另一种可能A不等于B,但C等于D,且E不等于F,也可能A等于B,C也等于D,E也等于F,或者……
4.设计编码规范化
设计编码能够被很好地理解并遵循相关技术规范。设计编码具体规范可参见《杭研安全可信评价体系》,杭州二部《应知应会基础编码规范》以及中心其他相关技术规范及指引。
5.拥有完备的环境配置说明与操作指导。
对于一个优秀的程序设计来说,完备的环境配置说明及操作指导是不可或缺的。比如之前某应用引入的异步导出功能。涉及的配置不下五处,包括了服务配置、数据库配置、目录配置、nfs配置、Quartz配置等。若没有该功能的环境配置说明及操作指导手册,不仅是测试同事,连开发同事本人也无法准确地发现问题的所在。
因此一款优秀的软件,必须包含详尽的环境配置说明和操作指导,内容包括(不仅限于):开发语言、开发工具、开发操作系统、适用操作系统、开发数据库、用户配置表、使用前提等等。
自动化
在许多重构的书籍中,大师们都建议我们在重构开始前建立自动化测试机制。但现实总是残酷地告诉我们这几乎不可能实现。大多数遗留系统都有一些共同的特征:
(1)代码凌乱,没有清晰的接口;
(2)代码间耦合度高,相互依赖严重;
(3)web层、业务层、数据访问层往往没有清晰的界限,代码相互参杂其中。
在这样的情况下,编写自动化测试代码是几乎不可完成的任务。因此在程序设计时,尽可能避免设计不能被自动化测试的组件。简单来说,我们需要了解哪些程序是不方便被自动化测试的。
举一个简单的例子:假如你现在已经写了一个调用接口的查询类,想编写它的自动化测试代码。本来这个查询类并不复杂,业务也很清晰。但如果在函数传递参数时,其中一个参数是Web容器中的Request、Response或Session。如果这样,为了测试一个简单的函数,我们必须启动整个Web应用,这是我们不可接受的。随后你可能会说,我们为什么非要传递一个真正地Request、Response或Session呢?我们Mock一个假的嘛!想法不错,但你真正去尝试Mock时你会发现这也是一个不可完成的任务。Request、Response或Session有许多的状态,属性变量中又有对象,又有属性变量。除此还有大量集合变量,集合变量里都有什么对象无从知晓。因此,即使你费尽千辛万苦Mock出来,也可能因某些属性不对而使得测试失败。
另一个自动化测试比较忌讳的就是访问数据库。比如你这次执行的插入操作成功了,并不意味着下次执行就可以成功。下次执行会报“主键冲突”错误,出现这个错误并不是被测程序错了,而是测试程序错了。上次执行一个查询产生的结果集,不一定就是下一次执行同样一个查询产生的结果。查询结果变了,并不意味着被测程序错了,而是测试程序不对。自动化测试程序之所以能够自动化执行,必须要保证测试过程是可以反复执行的,并且不论什么时候执行都有一个确定的结果。
总之,并不是所有代码都适合自动化测试。与Web容器或其它设备驱动相关的代码是不适合自动化测试的,因为我们在测试的时候不希望去启动Web容器或其它设备。因此,我们在做自动化测试程序前,首先应当确保要测试的程序已经与Web容器或其它设备驱动相关的代码充分解耦。一个比较好的办法就是分离出Web层与BUS层,Web层负责从Web容器中获取数据,并打包传递给BUS层,而BUS层则完成真正需要测试的业务逻辑。
另一个不适合自动化测试的就是要访问数据库的程序,因为它们执行的结果总是与数据库状态有关,无法获得稳定而可以不断复现的结果。所以,我们解决它的最好办法就是将访问数据库的部分Mock掉。如何Mock呢?你不能Mock一个JDBC,也不能Mock一个Hibernate,因为那都过于复杂了,你唯一可以做的就是将DAO层Mock掉。这就要求我们在做程序设计的时候,要将数据库访问的代码从业务代码中脱离出来,写入到DAO层。最后,被Mock的DAO层代码并不真正去访问数据库。每当客户程序传入一个参数时,它首先作为测试程序去验证这个参数是否与预期一致,然后返回一个确定的结果。
说了那么多,让我们用示例看看,系统重构是应该怎样做自动化测试的。我们准备一个HelloWorld的例子,该类中有一个sayHello()方法,只要我们输入当前的时间与用户名,就返回对该用户的问候语。如果当前时间是上午,则返回“Hi, XXX. Good morning!”;如果是下午,则返回“Hi, XXX. Good afternoon!”;如果是晚上,则返回“Hi, XXX. Good Night!”,这是HelloWorld这个程序实现的功能。
/**
* The Refactoring's hello-world program
* @author fangang
*/
public class HelloWorld {
/**
* Say hello to everyone
* @param now
* @param user
* @return the words what to say
*/
public String sayHello(Date now, String user){
//Get current hour of day
Calendar calendar = Calendar.getInstance();
calendar.setTime(now);
int hour = calendar.get(Calendar.HOUR_OF_DAY);
//Get the right words to say hello
String words = null;
if(hour>=6 && hour<12){
words = "Good morning!";
}else if(hour>=12 && hour<19){
words = "Good afternoon!";
}else{
words = "Good night!";
}
words = "Hi, "+user+". "+words;
return words;
}
}
下面就是编写这个测试程序执行测试了。由于被测程序有三个分支,即当前时间是上午、下午、晚上,因此我们分别为之建立了三个测试用例,测试程序如下:
/**
* Test for {@link org.refactoring.helloWorld.resource.HelloWorld}
* @author fangang
*/
public class HelloWorldTest {
private HelloWorld helloWorld = null;
/**
* @throws java.lang.Exception
*/
@Before
public void setUp() throws Exception {
helloWorld = new HelloWorld();
}
/**
* @throws java.lang.Exception
*/
@After
public void tearDown() throws Exception {
helloWorld = null;
}
/**
* Test method for {@link org...HelloWorld#sayHello(java.util.Date, java.lang.String)}.
*/
@Test
public void testSayHelloInTheMorning() {
Date now = DateUtil.createDate(2013, 9, 7, 9, 23, 11);
String user = "张三";
String result = "";
result = helloWorld.sayHello(now, user);
assertThat(result, is("Hi, 张三. Good morning!"));
}
/**
* Test method for {@link org...HelloWorld#sayHello(java.util.Date, java.lang.String)}.
*/
@Test
public void testSayHelloInTheAfternoon() {
Date now = DateUtil.createDate(2013, 9, 7, 15, 7, 10);
String user = "李四";
String result = "";
result = helloWorld.sayHello(now, user);
assertThat(result, is("Hi, 李四. Good afternoon!"));
}
/**
* Test method for {@link org...HelloWorld#sayHello(java.util.Date, java.lang.String)}.
*/
@Test
public void testSayHelloAtNight() {
Date now = DateUtil.createDate(2013, 9, 7, 21, 30, 10);
String user = "程序员";
String result = "";
result = helloWorld.sayHello(now, user);
assertThat(result, is("Hi, 程序员. Good night!"));
}
}
这段程序采用的是JUnit4编写的,其中assertThat(result, is("Hi, 程序员. Good night!"));,第一个参数是被测程序执行的结果,而第二个参数是根据期望结果进行验证。如果执行结果与预期结果相同,则测试通过,否则测试失败。
随后我们运行该测试程序,得到如下结果:
三项测试用例全部通过,测试成功!
现在我们为原程序编写了测试用例并全部测试通过,我们为重构所做的准备工作就一切就绪了。然后,我们开始进行第一次重构。如前面所述,第一次重构我们调整了程序的顺序,进行了分段,增加了注释,并修改了相应的变量,使其更加利于阅读。这是一个小步快跑的过程,我们完成此次重构只花费了3、5分钟。当重构完成,程序重新回到可编译运行状态时,我们执行它的这个测试程序,测试通过。测试通过意味着,虽然程序内部的代码有所修改,但程序对外的功能没有变化,即程序的外部行为没有变化,则重构成功,我们可以继续后面的工作。
第二次重构,我们运用“抽取方法”,从sayHello()函数中抽取出了getFirstGreeting(), getSecondGreeting(), getHour()三个方法。之后我们再次执行测试程序,测试通过。
第三次重构,我们运用“抽取类”,将getFirstGreeting()与getSecondGreeting()分别抽取出来形成了GreetingToUser和GreetingAboutTime。完成之后执行测试通过。
第四次重构,我们的需求发生了变化,问候语不仅随一天中的上午、下午、晚上等进行变化,还需要根据不同的日期判断是否是节日。在这种情况下,我们采用“两顶帽子”的方式进行开发:首先不引入新的需求,仅仅修改原程序,使之适应新需求。为此我们从GreetingAboutTime类中提炼出DateUtil,使之不仅有getHour(),还有getMonth()与getDate()。完成重构以后测试通过。
关于“两顶帽子”的设计方式,也是系统重构中另一个不同以往的地方,我们还将在后面详细地进行讨论。随后我们开始添加新需求,使GreetingAboutTime中的getGreeting()写成这样:
/**
* @return the greeting about time
*/
public String getGreeting(){
DateUtil dateUtil = new DateUtil(date);
int month = dateUtil.getMonth();
int day = dateUtil.getDay();
int hour = dateUtil.getHour();
if(month==1 && day==1) return "Happy new year! ";
if(month==1 && day==14) return "Happy valentine's day! ";
if(month==3 && day==8) return "Happy women's day! ";
if(month==5 && day==1) return "Happy Labor day! ";
......
if(hour>=6 && hour<12) return "Good morning!";
if(hour==12) return "Good noon! ";
if(hour>=12 && hour<19) return "Good afternoon! ";
if(hour>=19 && hour<22) return "Good evening! ";
return "Good night! ";
}
之后我们的测试不能通过:
为什么testSayHelloAtNight测试不能通过呢?仔细查看被测程序,我们发现它的功能发生了变化,变为:如果当前时间是1月1日,则返回“Hi, XXX. Happy new year!”;如果是1月14日,则返回“Hi, XXX. Happy valentine's day!”……如果当前时间都不是这些节日,如果是上午则返回“Hi, XXX. Good morning!”,是中午则返回“Hi, XXX. Good noon!”,是下午则返回“Hi, XXX. Good afternoon!”,是傍晚则返回“Hi, XXX. Good evening!”,否则才返回“Hi, XXX. Good night!”。正因为如此,我们需要调整我们的测试程序,为每一个分支编写测试用例。测试修改好后,最后测试通过。
第五次重构我们引入了数据库的设计,用户信息要从数据库中读取,问候语库存储在数据库中,并支持添加与更新。数据库的引入使自动化测试变得困难了,因为数据状态总是变化着的,而这种变化使得测试过程不能复现,这是我们不愿看到的。因此,我们在设计时将业务与数据库访问分离,形成了UserDao与GreetingRuleDao。此时,我们的设计应当遵从“依赖反转”原则,即将UserDao与GreetingRuleDao设计成接口,并编写它们的实现UserDaoImpl与GreetingRuleDaoImpl。这样设计就为我们Mock掉UserDao与GreetingRuleDao的实现类创造了条件。
这是我们的设计:为此,我们编写了这样的测试程序:
private HelloWorld helloWorld = null;
private GreetingToUserImpl greetingToUser = null;
private GreetingAboutTimeImpl greetingAboutTime = null;
private final static List<GreetingRule> GREETING_RULES = getRules();
/**
* @throws java.lang.Exception
*/
@Before
public void setUp() throws Exception {
helloWorld = new HelloWorld();
greetingToUser = new GreetingToUserImpl();
greetingAboutTime = new GreetingAboutTimeImpl();
helloWorld.setGreetingToUser(greetingToUser);
helloWorld.setGreetingAboutTime(greetingAboutTime);
}
/**
* @throws java.lang.Exception
*/
@After
public void tearDown() throws Exception {
helloWorld = null;
greetingToUser = null;
greetingAboutTime = null;
}
/**
* Test method for {@link org...HelloWorld#sayHello(java.util.Date, java.lang.String)}.
*/
@Test
public void testSayHelloInTheMorning() {
final Date now = DateUtil.createDate(2013, 9, 7, 9, 23, 11);
final long userId = 2013090701;
UserDao userDao = createMock(UserDao.class);
GreetingRuleDao greetingRuleDao = createMock(GreetingRuleDao.class);
expect(userDao.loadUser(userId)).andAnswer(new IAnswer<User>(){
@Override
public User answer() throws Throwable {
User user = new User();
user.setUserId(userId);
user.setName("张三");
return user;
}});
expect(greetingRuleDao.findAllGreetingRules())
.andAnswer(new IAnswer<List<GreetingRule>>(){
@Override
public List<GreetingRule> answer() throws Throwable {
return GREETING_RULES;
}});
replay(userDao);
replay(greetingRuleDao);
greetingToUser.setUserDao(userDao);
greetingAboutTime.setGreetingRuleDao(greetingRuleDao);
String result = helloWorld.sayHello(now, userId);
Assert.assertEquals("Hi, 张三. Good morning!", result);
verify(userDao);
verify(greetingRuleDao);
}
这段测试程序比较长,核心是那个testSayHelloInTheMorning()用例,即问候早上好这个用例。userDao与greetingRuleDao是两个接口,我们在实例化它们的时候,并没有去创建它们的实现类,而是用Mock的方式进行创建:
UserDao userDao = createMock(UserDao.class);
GreetingRuleDao greetingRuleDao = createMock(GreetingRuleDao.class);
随后我们开始定义它们的行为loadUser()与getAllGreetingRules()。在这个测试用例中,我们并不关心它们是怎样去数据库里查询数据并返回的,我们只关心它们是否得到应该得到的参数,并要求它们按照规定返回一个结果:
final long userId = 2013090701;
expect(userDao.loadUser(userId)).andAnswer(new IAnswer<User>(){
@Override
public User answer() throws Throwable {
User user = new User();
user.setUserId(userId);
user.setName("张三");
return user;
}
});
我们希望被测程序在执行的时候调用了userDao.loadUser(userId),并且调用时传入的参数userId = 2013090701。如果测试过程中传入的参数是这个值,这一项检查点可以通过,否则不能通过。随后我们希望该函数返回“张三”这个用户对象。通过Mock程序,我们完全将数据库访问的过程剥离在自动化测试之外,而只是验证它的输入参数,并指定测试所需的返回结果。也就是说数据访问过程被Mock掉,而大大降低了测试难度。
如果UserDao与GreetingRuleDao的Mock程序不能得到规定的参数时,测试就不能通过,这就是说传递给Mock程序的参数也成为了测试程序要验证的一个输出。随后,Mock程序返回规定值,该规定值则成为了被测程序的一个输入。最后,被测程序根据这个输入返回结果,为测试程序所验证,测试结束。图中的BUS层才是我们大量编码,应当自动化测试的部分。既然是测试就是验证怎样的输入,应当得到怎样的输出。Web层向BUS层发出的请求,即调用BUS层某个类的方法,就是测试用例中的一个输入,执行完该方法后的返回值就是测试用例的一个输出。但是,这对输入输出并不是该测试用例的全部。这里经过BUS层处理以后,经过一系列的逻辑判断和数据操作,随后会去调用DAO层进行数据访问操作。调用DAO层时所传递的参数,就是测试用例的另一个输出。图中,从DAO层的输入,到它的输出,这段数据库访问的过程被Mock程序Mock掉了,因为它不在这个用例的测试范围。然后DAO层返回给BUS层一个结果,该结果就是测试用例的另一个输入。接着BUS层会再次对这个返回结果进行处理,最后返回给Web层最终的结果。这就是采用Mock方式进行自动化测试的一个完整流程。
采用自动化测试,测试程序将不再验证后台的数据库是否正确,同时也不再验证前台的Web应用及其前端设备是否正确。在该例中,系统的真正目的是要在前台显示对用户的问候,因此将会有一个Action或Servlet调用HelloWorld:
Date now = DateUtil.getNow();
String user = SessionUtil.getCurrentUser(session);
HelloWorld helloWorld = new HelloWorld();
String greeting = helloWorld.sayHello(now, user);
request.setAttribute(“greeting”, greeting);
然而,这些程序都不适合自动化测试而应采用手工测试。
回顾HelloWorld自动化测试建立的过程我们不难发现,它在设计之初就实现了业务逻辑与Web应用、与数据访问的分离,所以它可以轻易的建立自动化测试。