如何通过测试替代(Test Doubles)合理隔离单元测试以提高单元测试效率

发表于:2017-12-27来源:IBM作者:夏 建东 和 尹 鹏点击数: 标签:
软件开发过程中,最基本的测试就是单元测试。在现代软件工程中,单元测试已经是软件开发不可或缺的一部分。良好的单元测试技术对软件开发至关重要,可以说它是软件质量的第一

单元测试意义

软件测试技术,在现代软件工程中变得愈发的重要,单元测试、集成测试、自动化测试等测试技术都可以大幅度提高软件产品的质量,降低软件开发成本。

软件开发过程中,最基本的测试就是单元测试。在现代软件工程中,单元测试已经是软件开发不可或缺的一部分。良好的单元测试技术对软件开发至关重要,可以说它是软件质量的第一关,是软件开发者对软件质量做出的承诺。敏捷开发中尤其强调单元测试的重要性。

单元测试规则

单元测试需要遵循特定规则,违反了这些规则,便失去了单元测试的意义。这些单元测试规则有:

  1. 单元测试应该无依赖和隔离
    如测试类 ATest.java 和测试 BTest.java 必须不能相互依赖,无论是先运行测试 ATest 还是先运行 BTest,对测试结果都不应该有任何影响。
  2. 易于安装及运行
    单元测试的执行不应该需要配置等繁琐操作就可以运行。如果单元测试代码包含访问数据库、网络等,这个测试就不是真正的单元测试。
  3. 易于执行 
    易于执行和生成报表。
  4. 不能超过一秒的执行时间

单元测试的时间应该非常短,这样就可以向开发者快速反馈信息。这个要求其实非常的高,特别是在测试驱动这种开发模式中,快速高效的单元测试能够极大的提高开发、重构代码的速度进而提高和改善软件的设计。

还可以反过来看单元测试的规则定义,如果一个测试满足下列定义的任何一个,它就不是一个真正的单元测试:

  1. 访问数据库
  2. 访问网络(如 RESTful 服务接口,SOAP 服务接口的访问等等)
  3. 访问文件系统
  4. 不能独立运行
  5. 运行单元测试需要额外的配置等

单元测试中如果访问数据库,网络,文件系统,将会极大的影响单元测试的执行效率,执行时间一般会因 IO 操作而增加, 从而使单元测试变得太久而不可忍受,开发人员一般希望能够快速反馈测试结果。比如重构了代码后第一步就是运行 单元测试,看有多少测试案例因代码的改变而受到了影响,如果此时测试用例的运行时间过于长久,会失去敏捷开发的敏捷性,进而影响开发进度。

随着产品的复杂性增加,功能增加,要覆盖更多的逻辑,单元测试代码势必变得更加复杂庞大,单元测试用例的简洁和独立性就变的愈发重要,高效的单元测试代码对开发者提出了更高的要求。单元测试逻辑的任何对第三方的直接依赖如数据库,网络,文件系统都会降低单元测试的效率和速度。

为满足以上单元测试的要求,通过一定的方法和技巧,解脱单元测试对外界的依赖变得更有现实意义。良好的单元测试代码会极大的改善软件代码的架构设计和帮助开发人员编写可测试的代码(Testable Code),提高软件质量。

测试替代技术就是这样一种方式,它可以帮助单元测试人员摆脱对第三方系统的依赖,进而提高单元测试的隔离性和执行效率。

测试替代技术方法

从单元测试的规则看,对单元测试的要求是很高的,特别是复杂系统,高效的单元测试案例本身,也对软件开发者提出了更高的要求。编写单元测试代码,意味着要求开发者编写可测试的代码,可测试的代码隐含着良好的代码设计。

隔离的单元测试意味着把单元测试中的对第三方系统依赖的部分合理的提取出来,用替代体(Test Double)取而代之,使单元测试把注意力集中放在测试“单元”的逻辑上而不是和第三方系统的交互上。

测试替代技术的分类

现实开发中,开发人员会用不同类型的测试替代技术去隔离测试,这些测试替代技术如图 1 所示,一般包括:假体, 存根,模拟体和仿制体。这些类别的测试替代技术各有自己优点和缺点。下面将介绍每个测试替代技术,并讨论他们使用的范围。

图 1. 测试替代技术
图 1. 测试替代技术

假体 (Fake)

假体是真正接口或抽象类的实现体(Implementation),它是对父类或接口的扩展和实现。假体实现了真正的逻辑,但它的存在只是为了测试,而不适合于用在产品中。

比如有个简单的 Logger 类,它可以把日志写到文件系统或是数据库中。下面是对应的设计类图。从设计可以看出 Logger 依赖于 Writer 接口,Writer 接口有两个实现 FSWriter 和 DBWriter,分别对应着写文件和写数据库, 类图如图 2 所示。

图 2. 设计类图
图 2. 设计类图

Writer 通过 Logger 的构造函数注入到 Logger 实例中,此时如果想测试 Logger.logFormatedMsg()单元,为了实例化 Logger,我们可以如清单 1 所示实现 Writer 的假体 FakeWriter 类,然后注入到 Logger 中去,FakeWriter 对象的 write 函数被调用时 log 没有写入文件系统而是保存在变量 msg 中,隔离了文件访问,保存的 msg 可以用来验证 msg 是否符合测试的期望。

清单 1. FakeWriter 实现 Writer 接口
1
2
3
4
5
6
7
8
9
10
public class FakeWriter implements Writer {
 private String msg = null;
@Override
public void write(String log) {
this.msg = log;
}
public String getMsg() {
return msg;
}
}

存根(Stub)

存根是当存根的方法被调用的时候,传递间接的输入给调用者。存根的存在仅仅是为了测试。存根可以记录一些其它的信息,如调用的次数,调用的参数等信息。比如测试中异常的处理等,忽略输入的参数而只是抛出异常以测试单元的异常处理功能。

清单 2. StubWriter
1
2
3
4
5
6
7
8
9
10
11
12
13
public class StubWriter implements Writer {
@Override
public void write(String msg) throws IOException {
throw new IOException("IO errors");
}
}
@Test(expected = IOException.class)
public void test_log_ioException_error() {
StubWriter stubWriter = new StubWriter();
Logger logger = new Logger(stubWriter);
String msg = "log out messages..";
logger.logandFormatMsg(msg);
}

如清单 2 所示,在这个测试中,StubWriter 的 write 函数忽略了输入参数,用 Stub 只返回测试想要的测试预期,进而测试 Logger 处理异常是不是符合期望。

仿制体(Dummy)

仿制体是在程序中不真实存在的对象,只是为了测试的目的而“制造”的一个虚拟对象,这个制造的仿制体对测试的逻辑几乎没有影响,只是为了满足测试对象实例化时的依赖要求。清单 3 所示,dummyCustomer 是不真实存在的对象。

清单 3. Dummy 测试
1
2
3
4
5
6
7
@Test
public void test_how_many_customer_serviced() {
Customer dummyCustomer=new Customer("aname","male");
DriverSvr service=new DriverSvr();
service.take(dummyCustomer);
assertEquals(1,service.getCountOfCustomer());
}

dummyCustomer 只是为了作为数据参数满足 DriverSvr 实例的 take 函数被调用,其实对我们要测试的逻辑“服务的人数”,几乎没有直接的影响。

模拟体(Mock)

模拟体本身有期望,期望是测试者赋予模拟体的。比如测试从模拟体期望一个值,在模拟体的某个方法被调用时要返回这个期望值。模拟体还可以记录一些其他的信息,如某个函数被调用的次数等等。模拟体框架有 JMock,EasyMock,Mockito 等,他们各有特点,但功能是相同的,都是提供模拟体以帮助测试。下面的例子用的是 Mockito,它的语法和语义使用更简单。Mockito 也可以提供存根 Stub 的功能,定在 org.mockito.stubbing 中,此处不再赘述。

以 Mock 测试 Logger 为例,如清单 4 所示,FSWriter 的模拟体 mockedWriter 被注入到 Logger 的实例中。

清单 4. mock 测试
1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
public void test_logger_formatandLogging() throws IOException {
Writer mockedWriter = mock(FSWriter.class);
Logger logger = new Logger(mockedWriter);
ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
String msg = "theMsg";
logger.logandFormatMsg(msg);
verify(mockedWriter).write(captor.capture());
verify(mockedWriter, new Times(1)).write(anyString());
 
String expectedFormatedMsg = "warning-" + msg;
assertEquals(expectedFormatedMsg, captor.getValue());
}

测试时,用 Mockito 的 ArgumentCaptor 截取要写入文件的信息用于验证日志和其格式是否符合期望,从而验证了 logger 的格式化逻辑。

由以上的分析可以看出,模拟体 (mock) 功能最为强大和全面,是现代单元测试中最常用的一种测试辅助隔离技术。

测试替代技术的应用

以上主要讨论的是常用的测试替代技术,以下我们将讨论一些在设计单元测试过程中,经常遇到的一些需要隔离的单元测试。

常见的需要隔离的访问

要替代单元测试中的可替代体,首先让我们来分析一下,都有哪些“第三方“需要被隔离,然后再分别有针对性的讨论具体的“替代”方法。

可明确识别的对第三方访问的有,网络访问,数据库访问,第三方类库和文件系统,下面分析一下他们各自的特点以及对应的可行的替代技术:

网络访问

软件产品访问网络,已经变的更加普遍,随着现代软件技术的发展,软件再也不是孤立的个体,软件产品需要各种网络服务来满足当前软件的功能需要,无论是桌面型应用还是基于浏览器的 B/S 架构的软件,几乎不可避免的要访问网络。

特别是最近几年的基于服务的软件架构技术的流行,软件程序中不得不处理 SOAP,RESTful,Socket 等等的网络访问。按照单元测试的规则,单元测试中这种对网络的访问应该被隔离开来,以提高测试效率。我们以 RESTFul 为例,看如何在单元测试中通过合理的设计来隔离其对网络的访问。

这个例子是用 IBM Cognos 中提供的 RESTful 的接口去提取中报表中的数据,报表中的数据可以用 GET 方式访问,以 RESTful 的方式获取,例如:

http://HostName/ibmcognos/cgibin/cognos.cgi/rds/reportData/report/i7E932A825B08459C832B72EFC608C0FE?fmt=LDX&selection=List1

图 3. CognosBI 的 Report 的输出
图 3. CognosBI 的 Report 的输出

其中 QueryString 中 LDX 定义了数据的格式,一种 XML 格式输出,selection 选择只取报表中的 List1 中的数据。i7E932A825B08459C832B72EFC608C0FE 是 CM 中报表的存储 ID,可以通过 CMQuery 工具获取。LDX 的输出中掺杂着格式化的信息,上图 3 是 CognosBI 的 Report 的输出。

此处的目标只是获取其中的数据,所以在获取 LDX 格式的数据后,要通过 XPath 的方式抽取其中的数据部分然后转换成另外一种可以通过行列存取的格式,数据抽取的部分逻辑和网络访问定义在不同的实现类中,他们之间的接口是抽象类 InputStream,在单元测试中数据转换部分的测试需要隔离。相应的类图如图 4 所示,DataConvert 依赖于接口 CognosClient,CognosClientService 实现了接口 CognosClient,是接口的具体实现类。

图 4. 实现类图
图 4. 实现类图

如程序清单 5 所示,测试中可以看到测试数据是直接嵌入到程序中,模拟体 mockedClient 被调用时,直接返回了嵌入的测试数据,而没有去访问网络,实现了对网络访问的隔离。(注:数据也可以以资源的方式直接嵌入到 Jar 中,然后用 this.getClass().getResourceAsStream() 加载数据,不过这似乎是间接访问了文件系统。)

清单 5. 模拟的 CognosClient
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test
public void test_cognos_list_data_converter_with_mockedClient() {
 CognosClient mockedClient = mock(CognosClient.class);
 DataConverter converter = new DataConverter(mockedClient);
 when(mockedClient.getCognosStream()).thenReturn(this.getLocalData());
 ArrayList<ArrayList<String>> data = converter.convert();
 verify(mockedClient,new Times(1)).getCognosStream();
 assertEquals(1, data.size()); // for simple,only columns inserted
  
}
 
private InputStream getLocalData() {
String content = "<filterResultSet xmlns='http://www.ibm.com/xmlns/prod/cognos/layoutData/200904'>....";
ByteArrayInputStream is = new ByteArrayInputStream(content.getBytes());
BufferedInputStream bstream = new BufferedInputStream(is);
return bstream;
}

数据库访问

现今数据库访问中,特别是商业软件,数据库访问是系统的一部分。在软件中,一般会通过数据库提供的类库来访问数据,还用一些通用的标准如 JDBC 等提供对数据库的访问规范和实现。软件架构设计时,数据的访问会被抽象到持久层中,在这个持久层中,会把实体和对象通过 ORM 框架相互映射,如 OpenJpa 就是这样一个框架,可以帮助开发者很容易的实现对象和数据库实体之间的转换,避免了开发者直接以写 Sql 的方式访问数据。

单元测试中应避免直接访问数据库,数据库的访问可以通过模拟体 (Mock) 对象轻松隔离开。如我们有个 UserDao 类,这个类实现了对 User 的增删改查,可以通过 userDao=mock(UserDao.class) 和 when() 等,把所有的对通过这个类实例访问数据库的方法截获并返回自己“制造”的对象或数据,从而隔离和避免了对数据库的直接访问。

还有些数据库实现了内存数据库的概念,如嵌入式数据库 Derby,Sqlite,H2 等,单元测试中对这类数据库的访问利用其嵌入式接口都可以在内存完成,没有额外的配置要求。

文件系统

文件系统的访问,通过模拟体(Mock)的方式,可以模拟几乎所有文件的 IO 操作,如 Logger 测试中,Writer mockedWriter = mock(FSWriter.class),在 Logger 写出数据到文件时,写出的操作 write 被截获,从而避免了对文件系统的访问。

结束语

根据单元测试的规则,单元测试中应避免对文件系统,数据库系统,网络系统的访问,因为这些访问意味着需要额外的配置(对第三方的依赖如文件路径,数据库链接,网络服务器连接等等),进而使单元测试的效率降低。假体,存根,仿制和模拟技术可以用于满足这些要求,其中模拟技术功能最为全面,可以非常有效的隔离单元测试。单元测试不仅能够提高代码质量,优化代码设计,同时也提高了开发人员的代码水平,节省了开发成本,是软件开发过程中不可或缺的重要组成部分。

原文转自:https://www.ibm.com/developerworks/cn/java/j-lo-TestDoubles