本文概述
前言
本文主要为你介绍测试驱动开发实例:假设现在要开发一个文档转换器,你会怎么写?如果是我以前的做法,一般首先是看下例如PDF文档的第三方解析库,然后开始写一个类,这个类有一系列的方法:例如读取文档、处理文档、转换文档等,没有写单元测试。
后来发现要写单元测试才行,不然代码太多问题了,并且你并不能一开始就写一个类,抽象类或接口还是会用到的。得首先对这个程序有一个总体的设计:程序输入和输出都是一个文档信息,对于不同的文档其解析是不同的,例如PDF和DOC,这可能需要一个接口。输入文档是读取文档信息,输出文档相当于创建文档信息,中间转换可能需要一个适配器接口……然后开始写代码。
但是这样似乎不方便写测试,写出的代码显得高耦合了,另外这样写似乎摸不着头脑,代码越写越多,反正哪里有bug有问题就改哪里吧,但总算能用,多垃圾起码能用吧。
后来开始考虑一个新的开发方法,也就是测试驱动开发(TDD),这个方法和我之前的方法刚好是倒过来的,以前的方法是从下而上设计,TDD开发模式是从上而下进行设计,它的好处是:你知道自己在做什么,方便测试和代码重构。
最近刚好有点时间,所以重新学下单元测试和TDD开发模式的相关内容,希望更好提升自己的编程能力,也写个文章分享一下。
本文的测试驱动开发实例在于让你更好理解和实际使用测试驱动开发,很强大的东西,反正我是爱了。
测试驱动开发简介
测试驱动开发,全称Test-Driven Development(TDD),它是一种编程方法或工具,或者一种编程模式。它要求你在编写任何功能代码之前,首先编写单元测试用例,这个用例包含使用这个功能的标准用法,以及断言,TDD主要就是测试优先和断言优先,我们的工作是让这个用例通过测试,待其通过后再不断重构代码和测试。
但是,只是让用例通过是不行的,如果当前功能只有一个测试用例,可以猜想这个功能代码在实际运行中会有相当大的出错频率,所以当你完成以后还是需要增加测试实现路径覆盖才行。
另外,不要忘记了设计,数据结构和算法上的设计还是要考虑到,不要为了测试通过而写代码,TDD它只是告诉你:这样编程能更好写代码,更方便实现整个程序的功能。TDD开发模式能让你知道自己在做什么,从上而下写代码有一种一目了然的感觉。
基于测试进行开发,如果你实际用多几次,就会发现,这样写出来的代码是高内聚、低耦合的,为什么呢?不知道呢,反正这就像用JDK的API一样。高内聚、低耦合是指:由测试用例规定的接口是不变的,也就是外部用法不变,但是你可以反复重构其功能代码,这就是高内聚;如果每个功能都使用TDD开发,那么子功能之间就显得耦合度较低了。
TDD简单来说就是:由测试驱动快速实现功能,然后驱动代码再设计和重构。
测试驱动开发步骤
这个步骤也是一个详细的参考步骤,你不一定非的这样做,但是首先按照这个步骤做,慢慢你就熟悉了,详细步骤如下:
- 实现一个功能、增加一个功能或修复一个问题之前,首先新增一个单元测试。
- 运行所有或一个或一部分测试,测试可能不通过。
- 往下增加实现代码或修复问题的代码,尽快让测试通过。
- 运行所有测试,并且全部通过,不行再回头设计。
- 回头对代码再设计和重构。
- 所有测试都没问题后,增加测试用例,以实现路径覆盖,以及单条路径上的值范围覆盖。
特别的,要记住最后一点,扩展测试用例,纵向实现完整的路径覆盖,横向对每条路径的极值、特殊值等进行测试,只有这样才能保证你的程序有一个较高的运行正确率。
TDD的相关内容还有很多,但是这里说的是最核心的内容了,你会进行这个操作,基本就会TDD了,其它的内容都不是主要的。
使用TDD开发模式有以下几个原则:
- 写任何产品代码之前先写一个单元测试。
- 写任何代码之前先保证运行所有测试。
- 写任何测试代码之前先保证所有测试通过。
下面我们根据这个步骤来介绍一个测试驱动开发实例。
使用TDD开发一个文档转换器
那么,这里展示一个例子来说明测试驱动开发的实际例子,我们需要开发一个文档转换器,其功能很简单,就是对文档的格式进行转换,例如将HTML文档转为PDF文档,或者将PDF文档转为HTML文档,功能说明概要如下:
- N种格式文件之间的相互转换。
- 目前先支持PDF、HTML、DOCX、Excel和PPTX格式文件之间的互相转换。
- 转换文件并保存到本地。
下面我们使用TDD来详细实现这个文档转换器,本文使用JUnit5进行测试。
1、新增一个单元测试
新增的是什么单元测试呢?首次开始项目,你可以当这个项目已经存在了的,第一个新增的测试就是在使用它,你可以想象它是任何不同的模样,但是最好是常用而标准的API使用形式,可以参考你用过的JDK、Apache开源库、Spring等较为著名的开源接口的使用形式。
一般来说,设计接口可以参考三种设计形式:
- 第一个是将数据对象和处理算法分开来,数据对象这里可以是文档信息,通常是一个简单的Java对象。而处理算法则是处理文档的相关函数,用一个新的对象处理,有时可能会需要设计数据结构,这种形式是较好的。
- 第二种是将数据对象和处理算法合并起来,数据、数据结构和算法都封装在一个类中,这种形式比较混乱,不推荐使用。
- 第三种是仅仅考虑语义上的使用,语义上怎么方便就怎么设计,这种形式仅考虑使用上的便捷。
假设我们需要将数据和算法分开,我们可以采取类似下面的接口形式:
@Test
void testConverter() {
FileObject htmlFile = new FileObject("C:/files/index.html", FileType.HTML, "UTF-8");
assertNotNULL(htmlFile);
FileObject pdfFile = new FileObject("C:/files/index_pdf.pdf", FileType.PDF, "UTF-8");
assertNotNULL(pdfFile);
IConverter converter = new DefaultConverterImpl();
assertNotNULL(converter);
boolean isOK = converter.convert(htmlFile, pdfFile);
assertTrue(isOK);
}
你可以看到数据和算法是分明的,另一种是语义的形式,类似下面的形式:
@Test
void testDocument() {
Document html = new Document("C:/files/index.html");
assertNotNULL(html);
Document pdf = html.convert("C:/files/index.pdf", DocumentType.PDF);
assertNotNULL(pdf);
}
考虑到扩展的问题,这里选择第一种方式,并且这种方式也是数据结构和算法书籍里面的一般形式,或者你可以在命名上更语义化,或者使用第二种方式。不要害怕!这两种方式都是可以的,实现起来也不会差的,条条大路通罗马!
下面是正式的代码:
@Test
void testDefaultConverter() {
Document html = new Document("C:/files/index.html", DocumentType.HTML);
assertNotNull(html);
assertTrue(html.exists());
IConverter converter = new DefaultConverter();
assertNotNull(converter);
Document pdf = new Document("C:/files/index.pdf", DocumentType.PDF);
assertNotNull(pdf);
assertFalse(pdf.exists());
boolean isOK = converter.convert(html, pdf);
assertTrue(isOK);
assertTrue(pdf.exists());
}
但是必须说明的是,这代码还是写得太快了,实际上,我们需要一步步来写,写一个测试,运行一次,重构代码;写一次测试,运行一次,重构代码……重复这个过程。例如这里可以从构造函数开始,或者构造函数可以有多种形式,这里为了方便展示就一次性写了。
例如下面的代码就是从构造函数开始的:
@Test
void testDocumentConstructorWithFileString() {
Document docx = new Document("C:/files/index.docx", DocumentType.DOCX);
assertNotNull(docx);
}
@Test
void testDocumentConstructorWithFile() {
Document docx = new Document(new File("C:/files/index.docx"), DocumentType.DOCX);
assertNotNull(docx);
}
@ParameterizedTest
@CsvSource({
"C:/Users/Administrator/Desktop/oreja/index.docx, true",
"C:/files/index.docx, false"
})
void testDocumentFileExists(String path, boolean expected) {
Document docx = new Document(new File(path), DocumentType.DOCX);
assertNotNull(docx);
assertEquals(expected, docx.exists());
}
2、运行测试并重构代码
首次运行——当然你不用运行就知道错了,因为这些类都还没有创建,可以用IDEA的快捷键Ctrl+1快速创建,创建完成后运行测试,测试成功继续写单元测试——不是还有一些代码可以写的吗?是,但是先不要写,记住:测试和断言优先,测试还没错就先不要写。
这里涉及的Document类、DocumentType枚举类或其它一些类,你可以手动创建,这里就不展示了。对于方法,需要什么方法就创建什么方法,创建完成即运行测试,就是重复这个过程就是了:测试+重构。
如下,运行测试失败:
因为该文件还没存在,这似乎很难在JUnit中模拟一个文件,所以这里不可避免地使用了真实的文件,或者你可以尝试使用第三方创建HTML的库+Mockito模拟一个看看。
解决这个问题,那就是找一个HTML文档,放到指定文件夹即可。
接着运行,测试失败:
这个失败是convert函数造成的,那么现在我们需要去实现这个函数了。
问题来了,convert函数又有一些实现代码,可能也需要依赖其它类来完成,假设convert函数这样子实现:
@Override
public boolean convert(Document sourceDocument, Document destinationDocument) {
if(!sourceDocument.exists() || destinationDocument.exists())
throw new IllegalArgumentException("source document not exists or destination document exists.");
Content content = extract(sourceDocument);
destinationDocument.setContent(content);
return false;
}
那该怎么办?记住:测试和断言优先,这段代码还在当前测试用例中,你可以继续往下写,需要什么创建什么,需要什么实现什么。
好了,就不继续写下去了,继续写下去就是展示完整的项目开发过程了,借鉴这个测试驱动开发实例,你完全可以自己动手试试,写多了就习惯了。
总结
测试驱动开发(TDD)是一个非常重要的编程技巧,使用TDD+数据结构和算法,基本上我认为是可以写出相当不错的程序的。
TDD其实非常简单:测试和断言优先,主要工作无非就是测试+重构。但是这有可能让人忘记设计代码了——确实是TDD的一个缺陷,所以除了参考测试+断言优先这个原则外,其它的东西你需要自己考虑到,不必一板一眼地写代码,例如:代码设计、路径覆盖等等。另外,借鉴本文的测试驱动开发实例可以让你更好地将TDD开发模式应用到实际开发中,我认为习惯TDD的做法就等于学会了。
下一篇打算讨论一下Mockito测试,可能会用到吧,老实说我平时并没有用mock框架,单元测试两个注解基本足够了:@Test和@ParameterizedTest。但是有一个问题,就是有时遇到有一些很难构造的对象,或者使用了真实数据,并且有时需要耦合其它类来测试。
本文的项目正在开发,是会用到其它项目中的,有可能开源也可以放到github的私有仓库中就是了。
以上就是测试驱动开发实例及其详细步骤的全部内容了,希望本文可以帮到你。