如何提升研发的可测试性架构设计

引言

在软件研发过程中需要单元测试、集成测试、用户验收测试等一系列的测试,然而我们遇到的很多软件由于在系统设计的时候没有考虑可测试性,经常会使人工测试变得很艰难,更难说自动化测试。

本Chat会从架构设计的角度讨论如何提高软件的可测试性。主要涉及内容包括:

常用可测试性设计方法

可测试性设计举例

通过TDD提升可测试性意识

任何软件都需要测试,没有测试过的代码是不可靠的,也是不安全的,但是软件代码测试起来并不容易。通常情况下软件应用越容易测试投入的成本将会越少,同时软件系统越容易测试,遗留缺陷的可能性越小,软件质量也就会越高。

测试是软件开发过程中很重要的一部分,会占用大量的时间和人力。如果想要高效的测试和获得高质量的软件产品,我们必须在软件项目的启动初期就开始关注软件质量。

当前提升软件测试效率和能力的最常用方法就是自动化测试。行为驱动开发(BDD)、测试驱动开发(TDD)是很多敏捷团队乐于采用的测试方法,这些方法都强调了在软件应用研发的概念阶段就开始关注软件系统的可测试性,并在迭代过程中确保软件系统的质量。

测试优先方法主要思想就是在编写软件代码前先设计对应代码的测试案例。当增加新的产品特性或功能时,首选设计自动化测试案例,以确保需要开发的软件功能的正确性并且在产品特性上符合业务需求。测试优先这种方法可以确保每一个新增功能都有与之相对应的自动化测试案例,同时也就保证了软件系统的可测试性。不过当进行软件的二次开发时,已有系统如何没有对应的测试案例,仍然会存在可测试性的问题,这是因为系统的可测试性并不是天然就存在的。

如果在软件系统设计时没有认真考虑软件的测试,软件系统往往会变得很难测试,自动化测试也会变得很难,最后也只能用手工测试来保证软件质量。

打破这种僵局的办法不是没有,需要引入一系列好的软件设计和自动化测试方法,这样同时也可以极大提高软件系统的可测试性和可扩展性。

图1 如何保证软件系统的可测试性

通常情况下,良好的软件系统设计所取得的收益不仅仅是一个工作正常的软件系统,而是远超这些。在设计软件系统时,主要目标是获得一个容易维护和容易扩展的可工作的软件系统,让以后可能的软件系统变更成本变得最小。然而,在分析软件变更成本时,本以为不需要测试验证的部分,往往变成了最难回归测试的部分,也是测试成本最高的部分。也许使用测试优先的方法建立一套自动化回归测试案例,才是降低回归测试成本的最有效办法。

在本Chat中,我们目标集中在单元测试层。介绍如何设计出一个可测试系统,在系统设计时应该考虑哪些因素,如何编写没有考虑可测试性的现存系统测试案例。当然,单元测试不是软件系统测试的唯一层级,不同软件系统的测试难点也许在不同层级,但是我们在本Chat里介绍的很多思想和原则,在其它高层级测试中同样有效。

可测试性架构设计

当我们谈论可测试性架构设计的时候,我们是在谈论以架构和设计的角度来提升我们软件系统的测试难度和测试效率。我们首先要理解我们在本Chat中谈论的话题环境。

我们在编写自动化单元测试的时候,主要困难在于如何把待测单元从整个软件系统中隔离出来单独测试。如果要测试一个类的功能,我们首先要把它从整个系统中拆分出来;然后我们把被测类实例化执行测试,最后把测试结果和我们的预期进行对比。除非系统设计具有低耦合、高内聚,可测试性比较好;在大多情况下,我们很难把一个类从它工作的整个系统中拆分出来。

当编写自动化单元测试时,我们经常会遇到以下问题:

类实例化

在编写测试案例时,很多情况下一个类并不是单独初始化的,而是作为整个系统的一部分初始化的。因为被测的部分依赖系统的其它部分,并且假设被依赖的部分在系统环境中存在并工作正常,然而在测试环境中设置系统环境是一个复杂和耗费资源的事情。为了避免设置负责的系统环境,我们需要一个被测单元的初始化机制,这中初始机制并不需要对系统环境的依赖。

依赖隔离

在很多情况下,一个类并不是单独工作的。一般来说,每一个类都与其它类进行交互并依赖其它类的来提供正确的功能。当编写测试案例时,把被测试类从依赖环境中隔离出来非常重要,我们很需要一个被测试类隔离出来的机制,来使得我们的测试编写工作变得更容易。

验证交互

为了确保测试案例的质量,测试案例应该检查被测单元的预期功能。在一些情况下,被测单元的行为状态只能在测试结束时被测类的结果状态中才能够检查到。然而,很多情况是,被测类的状态并没有实际意义,类的目的就是能和其它类正确交互。在测试时为了验证类交互的正确性,应该验证所有预期交互是否按照预期行为那样进行了。

要编写高效的单元测试案例,就需要高效的把被测类隔离出来,为了确保被测类能够正常工作,我们有时需要编程模拟类配合被测类完成交互行为。编程单元测试的难易程度跟被测类的隔离难易直接相关。

我们下面举例介绍一些在编写单元测试案例时会遇到的一些问题。

- 创建内部类对象

我们以智能家居为例,一所房子里面有各个房间和大门,房间包括卧室、厨房等。房间是房子的内部类,并且是在房子的构造函数中初始化的,对外没有暴露接口。

代码示例1:房子类

public class House

{

private Bedroom bedroom;

private Kitchen kitchen;

private FrontDoor door;

public house() {

bedroom = new Bedroom();

kitchen = new Kitchen();

door = new FrontDoor();

}

//出门

public void leaveHouse() {

//关闭厨房电器

kitchen.shutdownAllAppliances();

//关闭卧室电灯

bedroom.turnLightOff();

//关闭大门

lockFrontDoor();

}

//检查大门是否关闭

boolean isFrontDoorLocked(){

return door.isLocked();

}

private void lockFrontDoor(){

door.lockDoor();

}

}

我们编写当离开房子时大门是否关闭的自动化测试案例。案例举例如下:

代码示例2:测试离开房子时大门是否关闭。

public class HouseTest{

private House house;

@Test

public void testLockFrontDoor(){

house = new House();

house.leaveHouse();

assertTrue(house.isFrontDoorLocked());

}

}

当我们测试House类的时候,如果使用房间类(Bedroom、Kitchen)的一些功能,可能会有些问题。在很多情况下House类的内部逻辑可能很负责,它也可能依赖整个系统的其他类。例如,House类是否会依赖硬件控制类。但是我们的测试环境可能并没有硬件控制的测试环境,在这种情况下测试类讲无法运行,或者由于依赖环境的缺失一直失败。

很明显,以上这个House类的可测试性就不好。可测试性架构设计建议我们利用接口把类从依赖环境中解耦。被依赖实例通过依赖注入方式完成。

在我们的单元测试中,我们要验证House的调用逻辑,而不需要真实的调用执行真实的关闭电器、关闭电灯等功能。我们模拟调离开房子的过程,然后验证大门是否关闭了。我们需要通过创建模拟类来代替实际类,测试时我们把模拟类注入到House实例中完成被测试逻辑。

不幸的是我们当前的House类并没有注入我们模拟测试类的机制,House类没有对外暴露内部成员类的方法,也没有机制来替换实际的成员类。

解决成员变量类初始化的一个方法是利用依赖注入(Dependency Injection,DI),在House类外面对成员类进行初始化。我们首选把Kitchen类和BedRoom类换成接口(Interface),然后使用House类构造函数注入成员变量。

代码示例3:House类重构

public class House

{

private IBedroom iBedroom;

private IKitchen iKitchen;

private FrontDoor door;

//

public house(IBendRoom iBedRoom,IKitchen iKitchen) {

this.iBedroom = iBedroom;

this.iKitchen = iKitchen;

door = new FrontDoor();

}

//出门

public void leaveHouse() {

//关闭厨房电器

iKitchen.shutdownAllAppliances();

//关闭卧室电灯

iBedroom.turnLightOff();

//关闭大门

lockFrontDoor();

}

//检查大门是否关闭

boolean isFrontDoorLocked(){

return door.isLocked();

}

private void lockFrontDoor(){

door.lockDoor();

}

}

测试代码中使用依赖注入机制利用模拟类进行测试,从而避免对真实环境的依赖。

代码示例4:依赖注入和接口注入模拟类

public class HouseTest{

//Bedroom 模拟类

private FakeBedroom fakeBedroom;

//Kitchen 模拟类

private FakeKitchen fakeKitchen;

private House house;

@Test

public void testHouse(){

fakeBedroom = new FakeBedroom();

fakeKitchen = new FakeKitchen();

house = new House(fakeBedroom,fakeKitchen);

house.leaveHouse();

assertTrue(house.isFrontDoorLocked());

}

}

为了能正确模拟Bedroom和Kitchen的行为,FakeBedroom和Bedroom应该实现IBedRoom接口,FakeKitchen和Kitchen应该实现IKitchen接口。

我们当前的代码示例目的就是把被测试类House从依赖类中剥离出来。如何不隔离依赖类,相关被测试目标类将很难测试,以后的代码维护也变得非常困难。

通过实体类进行测试

验证两个对象之间的交互是否正确是常见的一种测试。在示例代码1中,我们是为了验证当离开房子相关离开流程是否正确执行,例如厨房的电器设备应该关闭等。比较明智的设计应该是,House类向Kitchen类发送关闭电器的指令,Kitchen类负责关闭厨房电器。请见示例代码4。

为了模拟Kitchen的行为,我们需要在FakeKitchen类中增加一些验证逻辑。FakeKitchen类需要实现IKitchen接口,以便可以使用依赖注入(DI)。

示例代码5 模拟类实现接口

public class FakeKitchen implements IKitchen{

private boolean shutDownAllAppliancesFlag;

public FakeKitchen(){

this.shutDownAllAppliancesFlag = false;

}

boolean wasCalledShutDownAllAppliances(){

return this.shutDownAllAppliancesFlag;

}

public void shutDownAllAppliances(){

this.shutDownAllAppliancesFlag = true;

}

}

静态方法依赖注入

另外一种常用模式是单例模式(Singleton Pattern),在单例模式中会使用静态方法。在本节举例中我们假设要测试BusinessLogic类,这个类使用日志跟踪记录重要的业务信息。我们要测试一个交易过程,交易过程中有指定的错误处理机制。当用户试图关闭一个未知的交易时,系统将向客户断抛出异常信息通知客户出现了问题。

示例代码6 单例模式

public class BusinessLogic{

private Map transactions;

public void finishTransaction(int transactionID, Status status){

ITransaction transaction = FindTransaction(transactionID);

transaction.updateStatus(status);

LoggerManager.instance().getLogger("BusinessLogic").logMessage("交易 %d 结束,交易状态 %d",transactionID, status);

}

private ITransaction FindTransaction(int transactionID){

ITransaction trans = transactions.get(transactionID);

if(trans == null){

LoggerManager.instance().getLogger("BusinessLogic").getMessage("交易ID没有找到");

throw MyException();

}

return trans;

}

}

public calss BusinessLogicTest{

BusinessLogic bussinessLogic;

@Test(expected=MyException.class)

public void testThrowException(){

bussinessLogic.FinishTransaction(5,SUCCESS);

}

}

日志功能也许很复杂,日志信息也许写入本地磁盘,也许是一个数据库系统或远程存储系统。进行测试时调用日志功能由于测试环境不会真实的配置日志系统,往往会导致测试执行的失败。如果接入实际的日志系统,测试执行将会是一个非常消耗资源和时间的行为。

测试业务逻辑时,实际的日志系统并不是很重要。我们建议使用模拟日志功能代替实际的日志系统进行测试。问题的关键在于我们能否用模拟日志实例替换真实的日志实例。由于BussinessLogic的实例时单例模式(Singleton),我们很难直接用模拟日志覆盖真实的日志实例。

有很多架构和设计技术能够避免这个问题。一个简单办法是使用抽象方法把日志返回具体实例的操作进行封装,然后在使用是对业务类进行集成覆盖返回日志实例的抽象方法,覆盖抽象方法时就可以用模拟日志实例代替实际的日志实例。

下面通过业务对象类BussinessLogic和测试类BussinessLogicTest举例说明。

示例代码7 抽象方法提升单例模式的可测试性

public abstract class BusinessLogic {

private Map transactions;

public void finishTransaction(int transactionID, Status status){

ITransaction transaction = FindTransaction(transactionID);

transaction.updateStatus(status);

writeToLog("交易 %d 结束,交易状态 %d");

}

private ITransaction findTransaction(int transactionID){

ITransaction trans = transactions.get(transactionID);

if(trans == null){

writeToLog("交易ID没有找到");

throw MyException();

}

return trans;

}

public abstract void writeToLog(String msg){

LoggerManager.instance().getLogger("BussinessLogic").logMessage(msg);

}

}

//通过继承覆盖writeToLog方法,替换真实的日志系统实例

public class FakeBusinessLogic extends BusinessLogic{

public abstract void writeToLog(String msg){

//什么也不做

}

}

public class BusinessLogicTest{

FakeBusinessLogic fakeBusinessLigoc;

@Test(expected=MyException.class)

public void testThrowException(){

fakeBussinessLogic.FinishTransaction(5,SUCCESS);

}

}

以上示例展示了几个遗留系统编写单元测试用例时遇到的问题。下面我们提供一些提高架构设计可测试性的一些建议:

尽量避免使用静态方法。静态方法不能够利用继承进行覆盖,因此要替换模拟依赖比较困难。

使用依赖注入(DI)。依赖注入可以很容易的替换真实的业务逻辑,把单元测试不关注的业务隔离开来。

使用接口。可以利用对接口的实现把模拟功能引入被测试方法中。是隔离不关注的业务的很好方法。

类实例初始化要简单。单元测试过程要对被测试类进行创建和销毁。类的实例初始化简化以后,不但有利于编写自动化代码,也可以提高单元测试的运行效率。

以上是我们工作中提高软件系统可测试性的一些经验总结。另外一些好的设计实践也有利于提高软件系统的可测试性。例如S.O.L.I.D原则。

S 单一职责原则,一个类只完成一项独立的工作。

O 开发封闭原则,对象应该对扩展开发,对修改封闭。

I 接口隔离原则,客户对使用的接口实现是透明的。

D 依赖倒置原则,高层次模块不依赖低层次模块的实现,而是依赖低层次模块的抽象。

残酷的现实

然而,可测试的软件系统在很多情况下不是轻易就获得的,当我们要测试的软件系统是一个遗留系统,代码库是一个已有的代码库,以上的很多设计原则将很难执行。

系统设计问题。 依赖的系统设计可能没有按照可测试性架构设计原则来设计,重写依赖系统的架构需要大量的工作和时间。

有时系统可测试性设计需要涉及的一部分超出了我们的控制范围。如果我们依赖了第三方的框架和工具,我们很难对第三方的框架或工具进行修改。

更改系统架构设计可能带来风险。更改系统架构可能导致软件系统不可用,这同时也是一个业务风险。

即使我们费尽努力使得系统设计具有很好的可测试性,这也要求我们以后的工作也要按照可测试性的要求持续下去。我们的系统需要持续添加新功能,我们的开发团队会有不停的人员变更,这就要求对于新功能也要使用可测试性架构设计方式,要求新加入的团队成员要具有可测试架构设计的技能和意思。对开发团队的管理水平提出了新的挑战。

另外,可测试性架构设计和好的设计原则有时并不是一回事。有些时候可测试架构设计和好的设计原则确实相冲突,如果要达到系统设计的可测试性要求,架构设计将变得过于复杂,而设计复杂度的增加仅仅是为了满足可测试性,对系统业务和研发工作并没有贡献额外的价值,却带来而很多额外工作量。

由于对遗留系统代码编写单元测试用例确是不是一件容易的事情,这就导致很多公司并不想对与现有系统代码编写自动化测试案例。编写单元测试案例,不仅需要掌握相关工具,掌握新的设计技能,学会使用测试驱动开发(TDD)工作方式,当遇到依赖系统代码时,又需要具有足够的智慧来更改依赖代码,使得遗留系统代码变得可测试。如果我们想要把以上这些工作同时完成,一步到位,其难度可想而知。

替代方法

如果技术上不存在可测试性的障碍,系统功能隔离上都能做到,我们还需要哪些能力才能使软件系统变得可测试呢?

我们需要的能力需要一些工具来达成。我们需要强调的是,如果要保持高效的研发效率,降低研发成本,好的系统架构设计方法是必不可少的。如果要从测试驱动开发(TDD)实际获益,提高软件设计架构技能和学会编写有价值的测试案例是很必要的。这也是想要长期保持系统的可测试性和易维护性的唯一方法。好的方法和工具的价值在于,它们能够把学习编写测试用例和学习使用设计方法分开来学习和使用,从而降低整个设计和测试过程的复杂度。让测试和设计工作变得可行。

总结

软件开发需要自动化测试,然而自动化测试也充满了挑战。采用新的方法去做软件开发不是一件容易的事情。采用测试驱动开发(TDD),需要同时学习如何进行测试,和新的架构设计技术,由于要学习和掌握的工具和方法比较多,因此就变成一件不是一触而就的事情。

具有良好的系统架构设计和同时保持系统的的测试性,是到达高质量就软件代码的好的方法。高质量的代码有利与对代码的继承和扩展。很多好的系统设计方法本身就具有良好的可测试性。

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20180905G0OWWE00?refer=cp_1026
  • 腾讯「云+社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 yunjia_community@tencent.com 删除。

扫码关注云+社区

领取腾讯云代金券