first commit

This commit is contained in:
张乾
2024-10-16 09:49:17 +08:00
parent bf199f7d5e
commit 389450bbd2
38 changed files with 4729 additions and 4 deletions

View File

@ -0,0 +1,146 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 一个好的自动化测试长什么样?
你好,我是郑晔!
在上一讲里我们讲了测试的一个关键点是自动化测试,而自动化刚好是程序员的强项。自从有了自动化测试框架,自动化测试就从业余走向了专业,但这并不是说,有了测试框架你就能把测试写好了,我们来看几个典型的问题:
测试不够稳定,一次运行通过,下次就不能通过了;
要测的东西很简单,但是为了测这个东西,光是周边配套的准备就要写很多的代码;
一个测试必须在另一个测试之后运行;
……
这是让很多团队在测试中挣扎的原因,也是很多人放弃测试的理由。之所以测试会出现这样那样的问题,一个重要的原因是这些测试不够好。这一讲,我们就来讲讲好的测试应该长什么样。
测试的样子
关于自动化测试,其实有一个关键的问题我们一直还没有讨论。我们用测试来保证代码的正确性,然而,测试的正确性如何保证呢?
这是一个会问懵很多人的问题:测试保证代码的正确性,那测试代码的正确性也用测试保证?但你见过有人给测试写测试吗?没有。因为这是一个循环的问题,你给测试写了测试,那新的测试怎么保证正确性呢?难不成要递归地写下去?是不是有种大脑要堆栈溢出的感觉了。
既然给测试写测试不是一个行得通的做法,那唯一可行的方案就是,把测试写简单,简单到一目了然,不需要证明它的正确性。由此,我们可以知道,一个复杂的测试肯定不是一个好的测试。
简单的测试应该长什么样呢?我们一起来看一个例子,这就是我们在实战环节中给出的第一个测试。
@Test
public void should_add_todo_item() {
// 准备
TodoItemRepository repository = mock(TodoItemRepository.class);
when(repository.save(any())).then(returnsFirstArg());
TodoItemService service = new TodoItemService(repository);
// 执行
TodoItem item = service.addTodoItem(new TodoParameter("foo"));
// 断言
assertThat(item.getContent()).isEqualTo("foo");
// 清理(可选)
}
我把这个测试分成了四段,分别是准备、执行、断言和清理,这也是一般测试都会具备的四个阶段,我们分别来看一下。
准备。这个阶段是为了测试所做的一些准备,比如启动外部依赖的服务,存储一些预置的数据。在我们这个例子里面就是设置所需组件的行为,然后将这些组件组装了起来。
执行。这个阶段是整个测试中最核心的部分,触发被测目标的行为。通常来说,它就是一个测试点,在大多数情况下,执行应该就是一个函数调用。如果是测试外部系统,就是发出一个请求。在我们这段代码里,它就是调用了一个函数。
断言。断言是我们的预期,它负责验证执行的结果是否正确。比如,被测系统是否返回了正确的应答。在这个例子,我们验证的是 Todo 项的内容是否是我们添加进去的内容。
清理。清理是一个可能会有的部分。如果在测试中使到了外部资源,在这个部分要及时地释放掉,保证测试环境被还原到一个最初的状态,就像什么都没发生过一样。比如,我们在测试过程中向数据库插入了数据,执行之后,要删除测试过程中插入的数据。一些测试框架对一些通用的情况已经提供支持,比如之前我们用到的临时文件。
如果准备和清理的部分是在几个测试用例间通用的,它们就有可能被放到 setUp 和 tearDown 里去完成,这一点我们在上一讲已经讲过了。
这四个阶段中,必须存在的是执行和断言。想想也是,不执行,目标都没有,还测什么?不断言,预期都没有,跑了也是白跑。如果不涉及到一些资源释放,清理部分很可能就没有了。而对一些简单的测试来说,也不需要做特别的准备。
从结构上来看,测试用例应该就是这么简单。你去看一下我们在实战中的代码,大部分测试都是可以这样划分的。
理解了测试的结构,有一些测试存在的问题你一眼就能看出来了。比如对于没有断言的测试来说,看上去测试从来不会出错,但这样的测试几乎是没有价值的。
再比如,一个测试里有多个执行目标,可能是需要在一个测试里要测多个不同的函数。这就是一个坏味道了。为什么说这是一个坏味道呢?因为测试的根基是简单,一旦复杂了,我们就很难保证测试本身的正确性。如果你有多个目标怎么办?分成多个测试就好了。
如果测试本身简单到令人发指的程度,出于节省代码篇幅的角度,你可以考虑在一个测试里面写。比如测试字符串为空的函数,我要分别传入空对象和空字符串,每种情况执行和断言一行代码就写完了,那我可能就在一个测试里面写了。
一段旅程A-TRIP
有了对测试结构的基本认知我们再进一步看看如何衡量一个测试有没有做好有人把好测试的特点总结成一个说法A-TRIP。这其实是五个单词的缩写分别是
Automatic自动化
Thorough全面的
Repeatable可重复的
Independent独立的
Professional专业的。
这是什么意思呢?我们分别来解释一下。
Automatic自动化。经过上一讲的讲解这一点你应该已经很容易理解了。自动化测试相比传统测试核心增强就在自动化上。这也是为什么测试一定要有断言因为只有在有断言的情况下机器才能够帮我们判断测试是否成功。
Thorough全面的。这一点其实是测试的要求应该尽可能用测试覆盖各种场景。不管什么样的自动化测试它的本质还是测试前面我们讲了向测试人员学习关键点就在于这有助于我们写出更全面的测试。理解全面还有一个角度就是测试覆盖率。我们在实战环节中已经见识了如何通过测试覆盖率工具帮我们去发现代码中测试中没有覆盖到地方。
Repeatable可重复的。它要求测试能够反复运行并且结果都应该是一样的。这是保证测试简单可靠的前提。如果一个测试不是可重复的我们就没法相信它的运行结果测试的价值也就荡然无存了。一旦测试报错我们没法确定是我们程序出错了还是其它什么地方出错了。
在内存中执行的测试一般都是可重复的。影响一个测试可重复性的主要因素是外部资源,常见的外部资源包括文件、数据库、中间件、第三方服务等等。如果在测试中遇到这些外部资源,我们就要想办法让这些资源在测试结束后,恢复原来的样子。你在实战中已经见识过如何处理文件,在后面的应用篇,我们还会讲到如何处理数据库。简单说就是在测试执行之后,能够把数据回滚掉。
如果你遇到中间件,最好有一个独立可控的中间件。而遇到第三方服务,则可以采用模拟服务,我的开源项目 Moco 主要就是为了解决这种外部依赖而生的。
理解可重复性还有一个角度,那就是一批测试也要可重复。这就需要测试之间彼此没有依赖,这也是我们接下来要讨论的测试的另外一个特点。
Independent独立的。测试和测试之间不应该有任何依赖。什么叫有依赖就是一个测试要依赖于另外一个测试运行的结果。比如两个测试都要依赖于数据库第一个测试运行时往数据库里写了一些数据而第二个测试在执行时要用到这些数据。也就是说第二个测试必须在第一个测试执行之后再执行这就叫做有依赖。
我知道,有很多人有很多的理由让测试之间有依赖。比如说为了提高执行效率,但这种做法属于特定的优化。对于其他绝大多数情况而言,一旦你开始这么做了,测试就走上了歧途。比如,一些框架支持多个测试并行运行,一旦测试有依赖,测试就无法并行执行,因为这两个测试之间是有顺序的。再比如,一旦有人破坏了测试的独立性,紧接着就会有更多的人破坏独立性,这就像代码的坏味道一样,很容易传播。
可重复性和独立性关联非常紧密。因为我们通常认为,可重复是测试按照随机的顺序执行,其结果也是一样的,这就要依赖于测试是独立的。而一旦测试不独立,有了依赖,从单个测试上来看,它也违反了可重复性。
Professional专业的。这一点是很多人观念中缺失的测试代码也是代码也要按照代码的标准去维护。这就意味着你的测试代码也要写得清晰比如良好的命名、把函数写小、要重构甚至要抽象出测试的基础库、测试的模式。在 Web 测试中常见的 PageObject 模式,就是这种理念的延伸。
有一点我准备多说几句,就是测试的命名。很多人写代码时,知道要取一个有意义的命名,但在测试上常常忽略这一点,我们经常可以看到 test1、test2这样的测试命名。那测试应该怎么命名呢
我不知道你是否注意到了,我在实战中写的测试,其命名与传统的 Java 函数有着很大的区别。首先,我用了下划线区隔单词,而没有采用驼峰命名;其次,名字都很长;再有,所有的测试都是以 should 开头。
我为什么要这么写呢?其实,我是希望在测试名中把测试用例的场景给描述出来。换言之,这个测试名不是一个简单的名字,而是一句话,这样测试的名字就会很长。而一旦名字太长,用驼峰阅读起来就不那么舒服了,所以,我采用了下划线区隔。
我对测试的命名主要有两种:
should_测试场景
should_测试效果_while_测试条件。
第一种命名表示应该做成什么样子比如should_add_todo_item一般来说对于一个正常情况的测试用例我会这么命名。第二种情况则表示在什么条件下应该出现什么效果比如should_throw_exception_while_parameter_is_empty可以用来描述各种异常的情况。你会看到这两种命名方法其实都是写了一句话而之所以会用 should 开头,它表示这个测试“应该”是什么样的。
有一些测试框架在测试描述上做得更加进一步,看上去就更像一句话了,下面是一个例子。
it.should("throw exception while parameter is empty", () -> {
...
});
经过这一讲的介绍,相信你对一个好的测试应该长成什么样已经有了一个初步的认识,但知道了好测试长什么样,只能帮助你发现测试中存在的问题。下一讲,我们接着来讨论一个影响写好测试的关键因素:软件设计。
总结时刻
这一讲,我们讨论了一个好的测试应该是什么样子的。一个好的测试首先应该是简单的,否则,我们无法保证测试的正确性。
我们还谈到了测试的基本结构:准备、执行、断言和清理。其中,核心的部分是执行和断言。一个测试既不能执行太多的东西,也不能没有断言。
怎么衡量测试是否做好了呢有一个标准A-TRIP这是五个单词的缩写分别是 Automatic自动化、Thorough全面的、Repeatable可重复的、Independent独立的和 Professional专业的
如果今天的内容你只能记住一件事,那请记住:编写简单的测试。
思考题
用今天讲到好测试的原则去对比一下你的测试,你会发现哪些问题呢?欢迎在留言区分享你的发现。

View File

@ -0,0 +1,196 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 测试不好做,为什么会和设计有关系?
你好,我是郑晔!
在前面几讲里,我们讲了测试的一些基础,要有测试思维、要学会使用自动化测试框架、要按照好测试的样子去写测试……但是,懂了这些就能把测试写好吗?
答案显然是否定的。因为这些东西很多人都知道,但真正到了实际的项目中面对自己的代码,还是不会写测试。主要的问题就是不好测,这也是测试劝退了很多程序员的一个重要原因。
不好测实际上是一个结果。那造成这个结果的原因是什么呢?答案就是我们今天要讨论的话题:软件设计。
可测试性
为什么说不好测是由于软件设计不好造成的呢?其实,更准确的说法是绝大多数人写软件就没怎么考虑过设计。
软件设计是什么?软件设计就是在构建模型和规范。
然而,大多数人写软件的关注点是实现。我们学习写程序的过程,一定是从实现一个功能开始的。这一点在最开始是没有问题的,因为需求的复杂度不高。不过需求一旦累积到一定规模,复杂度就会开始大幅度升高,不懂软件设计的人就开始陷入泥潭。
即便一个人认识到软件设计的重要性,学习了软件设计,但在做设计的时候还是常常会对可测试性考虑不足。可测试性是一个软件/模块对测试的支持程度,也就是当我执行了一个动作之后,我得花多大力气知道我做得到底对不对。
我们所说的代码不好测,其实就是可测试性不好。当我们添加了一个新功能时,如果必须把整个系统启动起来,然后给系统发消息,再到数据库里写 SQL 把查数据去做对比,这是非常麻烦的一件事。为了一个简单的功能兜这么大一圈,这无论如何都是可测试性很糟糕的表现。然而,这却是很多团队测试的真实状况。因为系统每个模块的可测试性不好,所以,最终只能把整个系统都集成起来进行系统测试。
如果建楼用的每块材料都不敢保证质量,你敢要求最终建出来的大楼质量很高吗?这就是很多团队面临的尴尬场景:每个模块都没有验证过,只知道系统集成起来能够工作。所以,一旦一个系统可以工作了,最好的办法就是不去动它。然而,还有一大堆新需求排在后面。
相应地,对一个可测试性好的系统而言,应该每个模块都可以进行独立的测试。在我们把每一个组件都测试稳定之后,再把这些组件组装起来进行验证,这样逐步构建起来的系统,我对它的质量是放心的。即便是要改动某些部分,有了相应的测试做保证,我才敢于放手去改。
可测试性很重要,但我要怎么让自己的代码有可测试性呢?
编写可测试的代码
编写可测试的代码,最简单的回答就是让自己的代码符合软件设计原则。在《软件设计之美》的专栏里,我专门讲了 SOLID 原则,这是目前软件设计中最成体系的一套设计原则。如果代码真的能做到符合 SOLID 原则,那它基本上就是可测的。
比如,符合单一职责原则的代码一般都不会特别长,也就没有那么多的分支路径,相对来说就比较容易测试。再比如,符合依赖倒置原则的代码,高层的逻辑就不会依赖于底层的实现,测试高层逻辑的部分也就可以用 Mock 框架去模拟底层的实现。
编写可测试的代码,如果只记住一个通用规则,那就是编写可组合的代码。什么叫可组合的代码?就是要能够像积木一样组装起来的代码。
既然要求代码是组装出来的,由此得出的第一个推论是不要在组件内部去创建对象。比如,我们在前面的实战中有一个 TodoItemService它有一个 repository 字段。这个字段从哪来呢?直接创建一个实例理论上是可以的,但它会产生耦合。根据我们的推论,不要在组件内部创建对象,所以,我们考虑从构造函数把它作为参数传进来。
public class TodoItemService {
private final TodoItemRepository repository;
public TodoItemService(final TodoItemRepository repository) {
this.repository = repository;
}
...
}
你或许会问了,如果不在内部创建对象,那谁来负责这个对象的创建呢?答案是组件的组装过程。组件组装在 Java 世界里已经有了一个标准答案,就是依赖注入。
不在内部创建,那就意味着把组件的组装过程外置了。既然是外置了,组装的活可以由产品代码完成,同样也可以由测试过程完成。
站在测试的角度看,如果我们需要测试 TodoItemService 就不需要依赖于 repository 的具体实现,完全可以使用模拟对象进行替代。
我们可以完全控制模拟对象的行为,这样,对 TodoItemService 的测试重点就全在 TodoItemService 本身,无需考虑 repository 的实现细节。在实战的过程中你也看到了,我们在实现了 TodoItemService 时,甚至还没有一个 repository 的具体实现。
现在你知道了,编写可组合的代码意味着,我们把组件之间的关联过程交了出去,组件本身并不会去主动获取其相关联组件的实现。由此,我们要得出第二个推论:不要编写 static 方法。
我知道很多人喜欢 static 方法,因为它用起来很方便,但对测试来说却不是这样。使用 static 方法是一种主动获取的做法。一旦组件主动获取,测试就没有机会参与到其中,相应地,我们也就控制不了相应的行为,测试难度自然就增大了。所以,如果团队需要有一个统一约定,那就是不使用 static 方法。
如果非要说有什么特例,那就是编写一些基础库(比如字符串处理等),这种情况可以使用 static 方法。但基本上大部分程序员很少有机会去写基础库,所以,我们还是把不编写 static 方法作为统一的原则。
如果你能够摒弃掉 static 方法,还有两样东西你也就可以抛弃了,一个是全局状态,一个是 Singleton 模式。
如果你的系统中有全局状态,那就会造成代码之间彼此的依赖:一段代码改了状态,另一端代码因为要使用这个状态而崩溃。
但如果我们抛弃了 static 方法,多半你也就没有机会使用全局状态了,因为直接访问的入口点没有了。如果需要确实有状态,那就可以由一个组件来完成,然后,把这个组件注入进来。
如果你能够理解 static 方法的问题,你也就能够理解 Singleton 模式存在的问题了。它也是一样没有办法去干涉对象的创建,而且它本身限制了继承,也没有办法去模拟。
你或许已经意识到了,之所以说编写可组合的代码是可测试性的关键,是因为我们在测试的过程中要参与到组件的组装过程中,我们可能会用模拟对象代替真实对象。模拟对象对我们来说是完全可控的,而真实对象则不一定那么方便,比如真实对象可能会牵扯到外部资源,带来的问题可能比解决的问题更多。
要使用模拟对象,就要保证接口可继承,函数可改写,这也是我们对于编写可测试代码的一个要求。所以,这又回到了设计上,要想保证代码的可测试性,我们就要保证代码是符合面向对象设计原则的,比如要基于行为进行封装等等。
与第三方代码集成
如果说前面讨论的内容更多的是面向自己写的代码,那在实际工作中,我们还会面临一个真实的问题,就是与第三方的代码集成。无论是用到开源的程序库,还是用到别人封装好的代码,总之,我们要面对一些自己不可控的代码,而这些代码往往也会成为你编写测试的阻碍。
对于测试而言,第三方的代码难就难在不可控,要想让它们不再成为阻碍,就要让它们变得可控。
如何让第三方代码可控呢?答案就是隔离,也就是将第三方代码和我们自己编写的业务代码分开。如何隔离呢?我们分成两种情况来讨论。
调用程序库
第一种情况是我们的代码直接去调用一个程序库。在实际工作中,这应该是最广泛的使用场景,可能是对一个协议解析,也可能调用一个服务发送通知。
在实战的例子中,我们也曾经调用 Jackson 去实现 JSON 的处理。那个例子就表现了一个典型的第三方代码不可控,它抛出的异常我们不好去模拟,所以,很难用测试去覆盖。不过,因为那个例子比较特殊,算是基础库的范畴,我们就直接封装成 static 方法了。
在大部分的情况下,我们做代码隔离,需要先定义接口,然后,用第三方代码去做一个相应的实现。比如,我们在实战中定义过一个 TodoItemRepository当时给的实现是一个基于文件的实现。
interface TodoItemRepository {
...
}
class FileTodoItemRepository implements TodoItemRepository {
...
}
如果我们要把数据存到数据库里,那我们就可以给出一个数据的实现。
class DbTodoItemRepository implements TodoItemRepository {
...
}
而要存到云存储,就写一个云存储的实现。
class S3TodoItemRepository implements TodoItemRepository {
...
}
这里的关键点是定义一个接口,这个接口是高层的抽象,属于我们业务的一部分。但要使用的第三方代码则属于一个具体的实现,它是细节,而不是业务的一部分。如果熟悉软件设计原则,你已经发现了,这其实就是依赖倒置原则。
有了这层隔离之后,我们就可以竭尽全力地把所有的业务代码用测试覆盖好,毕竟它才是我们的核心。
由框架回调
我们再来看与第三方代码集成的另外一种情况,由框架回调。比如,我们在实战里面用到了一个处理命令行的程序库 Picocli它会负责替我们解析命令行然后调用我们的代码这就是一个典型的由框架回调的过程。
这种情况在使用一些框架时非常常见,比如,使用 Spring Boot 的时候,我们写的 Controller 就是由框架回调的。使用 Flink 这样的大数据框架时,我们写的代码最终也是由框架回调的。
不同的框架使用起来轻重是不同的,比如在实战中,我们就直接触发了 Picocli因为它本身比较轻量级而像 Flink 这样的大数据框架想要在本地运行就需要做一些配置。
总而言之,要想测试使用了这些框架的程序,多半就是一种集成测试,而集成测试相对于单元测试来说,是比较重的,启动配置比较麻烦,运行时间比较长。
如果应用能在一个进程中启动起来,这还是好的情况。我还依然记得当年 Java 的主流开发方式是部署到应用服务器上,每次打包部署都是一个让人痛苦不堪的过程。像今天本地能够启动一个 Spring Boot 进程,这完全是需要感谢嵌入式 Web 服务器的发展。
面对这样的框架,我们有一个统一的原则:回调代码只做薄薄的一层,负责从框架代码转发到业务代码。
我们在实战的代码中已经见到了,比如,下面这段代码是添加一个 Todo 项的实现。
@CommandLine.Command(name = "add")
public int add(@CommandLine.Parameters(index = "0") final String item) {
if (Strings.isNullOrEmpty(item)) {
throw new CommandLine.ParameterException(spec.commandLine(), "empty item is not allowed");
}
final TodoItem todoItem = this.service.addTodoItem(TodoParameter.of(item));
System.out.printf("%d. %s%n", todoItem.getIndex(), todoItem.getContent());
System.out.printf("Item <%d> added%n", todoItem.getIndex());
return 0;
}
这里面的核心代码就一句话,剩下的要么是做校验,要么是与框架的交互,几乎没有太多逻辑可言。
this.service.addTodoItem(TodoParameter.of(item));
正如你在实战过程中见到的那样,我会先编写自己的业务核心代码,而把与框架接口的部分放到了后面去编写。因为最容易出问题的地方往往不是在这个转发的过程,而是业务的部分。只有当你的业务代码质量提升了,整个系统的质量才会得到真正的提升。所以,如果你看到这一层写了很多的代码,那这段代码一定是有坏味道了。
或许你也发现了,这其实也是一种常见的模式:防腐层。是的,不仅仅是我们与第三方系统交互有防腐层,与外界的交互同样需要防腐层。
你也看到了无论是调用程序库还是由框架回调,说来说去,都会回到软件设计上。所以,一个可测试的系统,关键要有一个好的设计。想要写出高质量的代码,软件设计就是程序员必备的一项能力。
通过软件设计,我们将业务代码同一些实现细节分离开来。但如果我们在测试中使用同样的实现,结果必然是把复杂性又带回来了。那不用同样的实现该怎么测试呢?下一讲,我们就来说说,怎么在测试中给出一个可控的实现。
总结时刻
这一讲,我们讲了软件的可测试性,这是影响到一个系统好不好测的一个重要因素。可测试性好的软件,各个模块都可以独立测试。而可测试性不好的软件,只能做整体的测试,其复杂度和过程中花费的时间都是不可同日而语的。
提升软件的可测试性,关键是改善软件的设计,编写可测试的代码。关于如何编写可测试的代码,我给了一个路标:编写可组合的代码。从这个路标出发,我们得出了两个推论:
不要在组件内部创建对象;
不要编写 static 方法。
由不编写 static 方法,我们可以推导出:
不要使用全局状态;
不要使用 Singleton 模式。
在实际工作中,除了要编写业务代码,还会遇到第三方集成的情况:
对于调用程序库的情况,我们可以定义接口,然后给出调用第三方程序库的实现,以此实现代码隔离;
如果我们的代码由框架调用,那么回调代码只做薄薄的一层,负责从框架代码转发到业务代码。
如果今天的内容你只能记住一件事,那请记住:编写可测试的代码。
思考题
今天我们讲了代码中不好测的情况主要是由于软件设计不好造成的。在实际的工作中,你还有遇到过哪些不好测的情况呢?欢迎在留言区分享你的经验。

View File

@ -0,0 +1,187 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 Mock 框架:怎么让测试变得可控?
你好,我是郑晔!
上一讲,我们谈到测试不好测,关键是软件设计问题。一个好的设计可以把很多实现细节从业务代码中隔离出去。
之所以要隔离出去,一个重要的原因就是这些实现细节不那么可控。比如,如果我们依赖了数据库,就需要保证这个数据库环境同时只有一个测试在用。理论上这样不是不可能,但成本会非常高。再比如,如果依赖了第三方服务,那么我们就没法控制它给我们返回预期的值。这样一来,很多出错的场景,我们可能都没法测试。
所以,在测试里,我们不能依赖于这些好不容易隔离出去的细节。否则,测试就会变得不稳定,这也是很多团队测试难做的重要原因。不依赖于这些细节,那我们的测试总需要有一个实现出现在所需组件的位置上吧?或许你已经想到答案了,没错,这就是我们这一讲要讲的 Mock 框架。
从模式到框架
做测试,本质上就是在一个可控的环境下对被测系统/组件进行各种试探。拥有大量依赖于第三方代码,最大的问题就是不可控。
怎么把不可控变成可控?第一步自然是隔离,第二步就是用一个可控的组件代替不可控的组件。换言之,用一个假的组件代替真的组件。
这种用假组件代替真组件的做法在测试中屡见不鲜几乎成了标准的做法。但是因为各种做法又有细微的差别所以如果你去了解这个具体做法会看到很多不同的名词比如Stub、Dummy、Fake、Spy、Mock 等等。实话说,你今天问我这些名词的差异,我也需要去查找相关的资料,不能给出一个立即的答复。它们之间确实存在差异,但差异几乎到了可以忽略不计的份上。
Gerard Meszaros 写过一本《xUnit Test Patterns》他给这些名词起了一个统一的名字形成了一个新的模式Test Double测试替身。其基本结构如下图所示。
在这个图里SUT 指的是被测系统System Under TestTest Double 就是与 SUT 进行交互的一个组件。有了我们之前的讲解,这个图应该不难看懂。
然而,这个名字也没有在业界得到足够广泛的传播,你更熟悉的说法应该是 Mock 对象。因为后来在这个模式广泛流行起来之前Mock 框架先流行了起来。
Mock 框架
Mock 框架的基本逻辑很简单,创建一个模拟对象并设置它的行为,主要就是用什么样的参数调用时,给出怎样的反馈。虽然 Mock 框架本身的逻辑很简单,但前期也经过了很长一段时间的发展,什么东西可以 Mock 以及怎样去表现 Mock不同的 Mock 框架给出了不同的答案。
今天我们的讨论就以 Mockito 这个框架作为我们讨论的基础,这也是目前 Java 社区最常用的 Mock 框架。
要学习 Mock 框架,必须要掌握它最核心的两个点:设置模拟对象与校验对象行为。
设置 Mock 对象
要设置一个模拟对象,首先要创建一个模拟对象。在实战中,我们已经见识过了。
TodoItemRepository repository = mock(TodoItemRepository.class);
接下来就是设置它的行为,下面是从实战中摘取的两个例子。
when(repository.findAll()).thenReturn(of(new TodoItem("foo")));
when(repository.save(any())).then(returnsFirstArg());
一个好程序库其 API 要有很强的表达性,像前面这两段代码,即便我不解释,看语句本身也知道它做了些什么。
模拟对象的设置核心就是两点:参数是什么样的以及对应的处理是什么样的。
参数设置其实是一个参数匹配的过程核心要回答的问题就是判断给出的实参是否满足这里设置的条件。像上面代码中save 的写法表示任意参数都可以,我们也可以设置它是特定的值,比如像下面这样。
when(repository.findByIndex(1)).thenReturn(new TodoItem("foo"));
其实它也是一个参数匹配的过程,只不过这里做了些省略,完整的写法应该是下面这样。
when(repository.findByIndex(eq(1))).thenReturn(new TodoItem("foo"));
如果你有更复杂的参数匹配过程,甚至可以自己去实现一个匹配过程。但我强烈建议你不要这么做,因为测试应该是简单的。一般来说,相等和任意参数这两种用法在大多数情况下已经够用了。
设置完参数,接下来,就是对应的处理。能够设置相应的处理,这是体现模拟对象可控的关键。前面的例子我们看到了如何设置相应的返回值,我们也可以抛出异常,模拟异常场景。
when(repository.save(any())).thenThrow(IllegalArgumentException.class);
同设置参数类似,相应的处理也可以写得很复杂,但我同样建议你不要这么做,原因也是一样的,测试要简单。知道怎样设置返回值,怎样抛出异常,已经足够大多数情况下使用了。
校验对象行为
模拟对象的另外一个重要行为是校验对象行为,就是知道一个方法有没有按照预期的方式调用。比如,我们可以预期 save 函数在执行过程中得到了调用。
verify(repository).save(any());
这只是校验了 save 方法得到了调用,我们还可以校验这个方法调用了多少次。
verify(repository, atLeast(3)).save(any());
同样校验也有很多可以设置的参数但我同样不建议你把它用得太复杂了就连verify 本身我都建议你不要用得太多。
verify 用起来会给人一种安全感所以会让人有一种多用的倾向但这是一种错觉。我在讲测试框架时说过verify 其实是一种断言。断言意味着这是一个函数应该具备的行为,是一种行为上的约定。
一旦设置了 verify实际上也就约束了函数的实现。但 verify 约束的对象又是底层的组件,是一种实现细节。换言之,过度使用 verify 造成的结果就是把一个函数的实现细节约定死了。
过度使用 verify在写代码的时候你会有一种成就感。但是一旦涉及代码修改整个人就不好了。因为实现细节被 verify 锁定死,一旦修改代码,这些 verify 就很容易造成测试无法通过。
测试应该测试的是接口行为而不是内部实现。所以verify 虽好,还是建议少用。如果有一些场景不用 verify 就没有什么可断言的了,那该用 verify 还是要用。
如果按照测试模式来说,设置 Mock 对象的行为应该算是 Stub而校验对象行为的做法才是 Mock。如果按照模式的说法我们应该常用 Stub少用 Mock。
Mock 框架的延伸
Mock 框架的主要作用是模拟对象的行为,但作为一种软件设计思想,它却有着更大的影响。既然我们可以模拟对象行为,那本质上来说,我们也可以模拟其它东西。所以,后面也有一些基于这种模拟思想的框架,其中,目前行业中使用最为广泛的是模拟服务器。
模拟服务器顾名思义,它模拟的是服务器行为,现在在行业中广泛使用的模拟服务器主要是 HTTP 模拟服务器。HTTP 服务器的主要行为就是收到一个请求之后,给出一个应答,从行为上说,这与对象接受一系列参数,给出相应的处理如出一辙。
接下来我就以 Moco 为例简单介绍一下模拟服务器。Moco 是我自己编写的一个开源模拟服务器程序库,曾在 2013 年获得 Oracle 的 Duke 选择奖。(在《软件设计之美》中讲到程序库的设计时,我讲过 Moco 整个设计的来龙去脉。如果你有兴趣,可以去回顾一下。)
下面是一个使用了 Moco 的测试代码。
public void should_return_expected_response() {
// 设置模拟服务器的信息
// 设置服务器访问的端口
HttpServer server = httpServer(12306);
// 访问/foo 这个 URI 时,返回 bar
server.request(by(uri("/foo"))).response("bar");
// 开始执行测试
running(server, () -> {
// 这里用了 Apache HTTP库访问模拟服务器实际上可以使用你的真实项目
Content content = Request.Get("http://localhost:12306/foo")
.execute()
.returnContent();
// 对结果进行断言
assertThat(content.asString(), is("bar"));
});
}
在这段代码里,我们启动了一个 HTTP 服务器,当你访问 /foo 这个 URI 时,它会给你返回一个应答 bar。这其中最关键的一行代码就是设置请求应答的那行。
server.request(by(uri("/foo"))).response("bar");
Moco 的 API 本身也有很强的表达性,通过代码本身你就能看到,这里就是设置了一个请求以及相应的应答。
Moco 的配置支持很多的 HTTP 元素,像下面这段代码,你可以同时匹配请求内容和 URI也可以同时设置应答文本和 HTTP 的状态码。
server
.request(and(by("foo"), by(uri("/foo"))))
.response(and(with(text("bar")), status(200)));
在上面的例子里面running 是负责模拟服务器启停的代码,里面包含的代码就是,通过自己真实的服务代码发出的真实请求。
Moco 还支持 verify如果你想像 Mock 框架那样去校验服务器是否收到了相应的请求,就可以使用它。
RequestHit hit = requestHit();
final HttpServer server = httpServer(port(), hit);
running(server, () -> {
...
})
hit.verify(by(uri("/foo")), times(1));
虽然 Moco 支持这样的能力,但同使用 Mock 框架类似,我也建议你少用 verify。
Moco 最大的价值就是让原本不可控的第三方 HTTP 服务器,现在可以按照我们预期的方式执行。比如,在真实的集成过程,你很难要求第三方服务器给你一个错误的应答,或者一个超时的应答,但使用 Moco 你就可以让它模拟出这样的行为。
Moco 还有一个很大的价值,原本你要做集成,唯一的选项是把整个系统跑起来,基本上就到了系统集成的范畴。而现在使用 Moco验证工作可以用集成测试的代码就可以完成。作为程序员我们很清楚相比于系统测试这种做法轻太多了一旦出现问题定位起来也容易很多。从开发效率上看这简直是数量级的提升。
Moco 不仅仅支持模拟 HTTP 服务器,还做了进一步延伸,支持模拟 WebSocket 服务器。
HttpServer server = httpServer(12306);
webSocketServer = server.websocket("/ws");
webSocketServer.request(by("foo")).response("bar");
无论是模拟 HTTP 服务器,还是模拟 WebSocket 服务器,本质上来说,它都是模拟对象这一思想的延伸。而所有这一切的出发点都是,我们希望在测试中得到一个可控的环境。
总结时刻
今天我们主要讲了 Mock 框架。Mock 框架是源自 Test Double测试替身这种测试模式。我们希望自己有一个可控的环境对被测系统/组件进行测试,背后的思想就是用假的却可控的组件去代替真实不可控的组件。
现在 Mock 框架已经成为了测试的重要组成部分理解一个Mock框架核心就是要理解如何设置对象行为以及如何校验对象行为。设置对象行为主要是设置相应的参数以及对应的处理无论这个处理是给出返回值还是抛出异常。校验对象行为是一种断言是看对象是否按照预期方式执行。不过我给你提了一个醒verify 虽好,尽量少用。
最后,我们还以 Moco 为例讲到了 Mock 框架的延伸也就是模拟服务器。Moco 主要是模拟 HTTP 服务器,其核心就是对什么样的请求,给出什么样的应答。
如果今天的内容你只能记住一件事,那请记住:使用 Mock 框架,少用 verify。
思考题
今天我们讲了 Mock 框架,你在实际工作中用到过 Mock 框架吗?它解决了你怎样的问题,或是你在使用它的过程中遇到怎样的困难,欢迎在留言区分享你的经验。

View File

@ -0,0 +1,160 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 单元测试应该怎么写?
你好,我是郑晔!
经过前面的介绍,我们已经对测试的基础有了理解,已经会用自动化测试框架来写测试了。对于那些不可控的组件,我们也可以用 Mock 框架将其替换掉,让测试环境变得可控。其实,我们在前面介绍的这些东西都是为了让我们能够更好地编写单元测试。
单元测试是所有测试类型中最基础的,它的优点是运行速度快,可以尽早地发现问题。只有通过单元测试保证了每个组件的正确性,我们才拥有了构建系统的一块块稳定的基石。
按道理来说,我们应该尽可能多地编写单元测试,这可以帮助我们提高代码质量以及更准确地定位问题。但在实际的工作中,真正大面积编写单元测试的团队却并不多。前面我们已经提到了一部分原因(比如设计没有做好),也有团队虽然写了单元测试,但单元测试没有很好地起到保护网的作用,反而是在代码调整过程中成了阻碍。
这一讲,我们就把前面学到的知识串联起来,谈谈如何做好单元测试。
单元测试什么时候写
你是怎么编写单元测试的呢?很多人的做法是先把所有的功能代码都写完,然后,再针对写好的代码一点一点地补写测试。
在这种编写测试的做法中,单元测试扮演着非常不受人待见的角色。你的整个功能代码都写完了,再去写测试就成了一件为了应付差事不得不做的事情。更关键的一点是,你编写的这些代码可能是你几天的工作量,你已经很难记得在编写这堆代码时所有的细节了,这个时候补写的测试对提升代码质量的帮助已经不是很大了。
所以,想要写好单元测试,最后补测试的做法总是很糟糕的,仅仅比不写测试好一点。你要想写好单元测试的话,最好能够将代码和测试一起写。
你或许会说,我在功能写完后立即就补测试了,这不就是代码和测试一起写的吗?其中的差异在于,把所有的功能写完的这个粒度实在是太大了。为一个大任务编写测试,是一件难度非常大的事,这也是很多人觉得测试难写的重要因素。要想做好单元测试,关键就是工作的粒度要小。
如果你学过《10x 程序员工作法》,或许已经听出来了。没错,这里的关键点就是要做好任务分解,而任务分解的关键就是粒度要小。
Im not a great programmer; Im just a good programmer with great habits.-
我不是一个伟大的程序员,只是一个有着好习惯的优秀程序员。-
—— Kent Beck
任务分解是每个程序员都应该拥有的好习惯即便你想写好单元测试也要从任务分解开始。所以你需要把一个要完成的需求拆分成很多颗粒度很小的任务。粒度要小到可以在很短时间内完成比如半个小时就可以写完。只有能够把任务分解成微操作我们才能够认清有足够的心力思考其中的每个细节。千万不要高估自己对于任务把控的粒度一定要把任务分解到非常小这是能够写好代码写好测试的前提条件甚至可以说是最关键的因素如何具体分解一个需求我曾经在《10x 程序员工作法》中专门用了一讲的篇幅进行介绍,如果你有兴趣不妨去回顾一下)。
当我们把需求拆分成颗粒度很小的任务时,我们才开始进入到编码的状态。而从这里开始,我们进入到代码和测试一起写的状态。
编写单元测试的过程
对于一个具体的任务,我们首先要弄清楚的是,怎么样算是完成了。一个完整的需求我们需要知道其验收标准是什么。具体到一个任务,虽然没有业务人员给我们提供验收标准,我们自己也要有一个验收标准,我们要能够去衡量怎么样才算是这个代码写合格了。
经过我们这一系列关于测试的介绍,你应该已经知道我要说什么了:一个任务的代码要通过测试才算编码阶段的完成。
但测试用例从哪来呢?这就需要我们设计了。不同于业务测试的测试用例,我们现在要写的是单元测试。而我们要测的单元现在还没有写,所以,没有人会给我们提供测试用例,单元测试的用例只能我们自己来。
还记得我们在实战里怎么做的添加 Todo 项吗?接下来,我们就结合这个部分来谈谈具体怎么做。
我们首先要确定的是待测单元的行为,也就是要实现的类里的一个函数,它的行为是什么样的。或许你已经发现了,这其实就是一个软件设计的过程。这里的设计指的是微观的设计,就是具体的一个函数准备写成什么样子。通常到了动手写代码这一步,大的设计已经在前面做完了。
因为我们现在不仅仅要写代码,还要写测试。所以,我们在设计这个函数接口时,还必须增加一点考量:它要怎么测。
在添加一个 Todo 项时,我们经过设计出来的函数接口就是下面这样。
TodoItem addTodoItem(final TodoParameter todoParameter);
有了一个具体的函数接口设计,我们就可以针对它进行更具体的测试用例设计,也就是设计测试用例来描述这个接口的行为。
是的,这里我们并没有着急写代码。对很多人来说,写代码的优先级很高,但是,如果不在这里停一下的话,你可能就不会去思考是否还有要考虑的问题,而是直奔代码细节去了。而当我们专注于细节时,有限的注意力就会让你忽略掉很多东西。所以,先设计测试用例,后写代码,这是一个编码习惯的问题。
有了添加 Todo 项接口之后,我们就准备了两个测试场景:
添加正常的参数对象,返回一个创建好的 Todo 项;
添加空的参数对象,抛出异常。
有了测试场景,接下来把这些场景实例化出来,这个步骤相对来说就比较简单了。比如,对于添加正常的参数对象来说,那什么样的参数对象是正常的?我们就代入一个具体的正常参数(比如 foo。有了这个实例化过的参数我们就可以把具体的测试用例表现出来了。
@Test
public void should_add_todo_item() {
TodoItemRepository repository = mock(TodoItemRepository.class);
when(repository.save(any())).then(returnsFirstArg());
TodoItemService service = new TodoItemService(repository);
TodoItem item = service.addTodoItem(new TodoParameter("foo"));
assertThat(item.getContent()).isEqualTo("foo");
}
在实际的工作中,究竟是先写测试,还是先写实现代码,这是个人工作习惯的问题。当我们有了测试用例之后,其实就是把一个具体的任务进一步拆分成更小的子任务了。只要我们完成一个子任务,我们就可以做一次代码的提交,因为我们这个时候,既有测试代码又有实现代码,而且实现代码是通过了测试的。
测接口还是测实现?
不知道你是否注意到了,在前面我一直在说,我们要测的是函数接口的行为。我一直说,单元测试是一种白盒测试。在一些人的理解中,白盒测试的关注点应该是内部实现。那单元测试到底应该关注接口,还是应该关注实现呢?
或许你还不清楚二者之间的区别,让我们把前面添加 Todo 项的例子拿过来。如果采用更加面向实现的做法,我们应该对 addTodoItem 这个函数的内部实现有进一步的约束,就像下面这样。
@Test
public void should_add_todo_item() {
TodoItemRepository repository = mock(TodoItemRepository.class);
when(repository.save(any())).then(returnsFirstArg());
TodoItemService service = new TodoItemService(repository);
TodoItem item = service.addTodoItem(new TodoParameter("foo"));
assertThat(item.getContent()).isEqualTo("foo");
verify(repository).save(any());
}
这段代码中核心的差别就是增加了一句 verify这也就意味着我规定在 addTodoItem 的实现中必须要调用 repository 的 save 函数。
你或许会好奇repository 本来就要调用 save 方法,那我在这里校验它调用了 save 方法,似乎也没什么大不了的。
单独这么看确实看不出什么问题,但是,如果你有很多测试都是这么写,当你准备重构时,你就会发现问题了。很多团队代码一调整,测试就失败,一个重要的原因就是代码实现和测试之间紧紧地绑定在了一起。因为测试约束的是实现细节,而只要调整实现细节,测试当然就失败了。这也是很多团队抱怨单元测试问题很多的重要原因。
所以,在实际的项目中,我会更倾向于测试接口,尽可能减少对于实现细节的约束。其实,这个原则不仅仅是在接口层面上,在一些测试的细节上也可以这么约定,比如下面这行代码。
when(repository.save(any())).then(returnsFirstArg());
这其实是一种宽泛的写法,所以用了 any。如果严格限制的话应该严格限定一个非常具体的参数。
when(repository.save(new TodoItem("foo"))).then(returnsFirstArg());
同样,上一讲我们讲到了 Moco我们设置模拟服务器可以设置得非常具体像下面这样。
server
.request(and(by("foo"), by(uri("/foo"))))
.response(and(with(text("bar")), status(200)));
也可以设置得非常宽泛,像这样。
server.request(by(uri("/foo"))).response("bar");
除非这个测试里面有多个类似的请求,必须要做区分,否则,我倾向于使用宽泛一些的约束。这在某种程度上会降低未来重构代码时带来的影响。
不过实话说,要想完全消除对于实现细节的依赖,有时候也是很难的。比如在我们前面的 TodoItemService 的例子里面repository 本身也是 TodoItemService 的一种实现细节,一旦进行一些重构,把 repository 的依赖从 TodoItemService 中拿掉,很多测试代码也需要调整。所以,在实际的项目中,我们只能说尽可能减少对于实现细节的依赖。
其实关于实现细节的测试也是一种重复等于你用测试把代码又重新写了一遍。程序员的工作中有一种重要的原则DRYDont Repeat Yourself这不仅仅是说代码中不要有重复而且各种信息都不要重复我在《软件设计之美》中讲过 DRY 原则,有兴趣不妨回顾一下)。
我建议你在设计单元测试的时候不要面向实现细节。但反过来,有些时候测试确实会漏掉一些细节,尤其是一些实现代码中的分支。怎么样发现自己的代码中是否有遗漏呢?这就是我们下一讲要讲的内容:测试覆盖率。
总结时刻
今天我们讲了如何去写单元测试。很多团队由于多方面的原因(比如设计做得不好),导致单元测试写得少。但为了提高代码质量以及更准确地定位问题,我们应该多写单元测试。
单元测试最好是和实现代码一起写,以便减少后续补测试的痛苦。想写好测试,关键要做好任务分解,否则,面对一个巨大的需求,没有人知道如何去给它写单元测试。
编写单元测试的过程,实际上就是一个任务开发的过程。一个任务代码的完成,不仅仅是写了实现代码,还要通过相应的测试。一般而言,任务开发要先设计相应的接口,确定其行为,然后根据这个接口设计相应的测试用例,最后,把这些用例实例化成一个个具体的单元测试。
单元测试常见的一个问题是代码一重构,单元测试就崩溃。这很大程度上是由于测试对实现细节的依赖过于紧密。一般来说,单元测试最好是面向接口行为来设计,因为这是一个更宽泛的要求。其实,在测试中的很多细节也可以考虑设置得宽泛一些,比如模拟对象的设置、模拟服务器的设置等等。
如果今天的内容你只能记住一件事,那请记住:做好任务分解,写好单元测试。
思考题
今天我们讨论了如何写好单元测试,你在实际项目中写过单元测试吗?你遇到了哪些问题,或者有哪些经验可以分享呢?欢迎在留言区分享你的观点。

View File

@ -0,0 +1,120 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 测试覆盖率:如何找出没有测试到的代码?
你好,我是郑晔!
经过前面内容的介绍,相信你现在已经知道如何去编写单元测试了。上一讲,我们说编写单元测试应该面向接口行为来编写,不过这样一来,就存在一种可能:我预期的行为都对了,但是因为我在实现里写了一些预期行为之外的东西(比如有一些分支判断),在代码实际执行的时候,可能就会出现预期之外的行为。
如何尽可能消除预期之外的行为,让代码尽在掌控之中呢?这一讲,我们就来讲讲如何查缺补漏,找到那些测试没有覆盖到的代码。我们要来讨论一下测试覆盖率。
测试覆盖率
测试覆盖率是一种度量指标,指的是在运行一个测试集合时,代码被执行的比例。它的一个主要作用就是告诉我们有多少代码测试到了。其实更严格地说,测试覆盖率应该叫代码覆盖率,只不过大多数情况它都是被用在测试的场景下,所以在很多人的讨论中,并不进行严格的区分。
既然测试覆盖率是度量指标,我们就需要知道有哪些具体的指标,常见的测试覆盖率指标有下面这几种:
函数覆盖率Function coverage代码中定义的函数有多少得到了调用
语句覆盖率Statement coverage代码中有多少语句得到了执行
分支覆盖率Branches coverage控制结构中的分支有多少得到了执行比如 if 语句中的条件);
条件覆盖率Condition coverage每个布尔表达式的子表达式是否都检查过 true 和 false 的不同情况;
行覆盖率Line coverage代码中有多少行得到了测试。
以函数覆盖率为例,如果我们在代码中定义了 100 个函数,运行测试之后只执行 80 个,那它的函数覆盖率就是 80100=0.8,也就是 80%。
这几个指标基本上看一眼就知道是怎么回事,唯一稍微复杂一点就是条件覆盖率,因为它要测试的是在一个布尔表达式中每个子表达式所有真假值的情况,我们来看看下面这个代码。
if ((a || b) && c) {
...
}
就是这么一个看上去很简单的情况,因为它牵扯到 a、b、c 三个子表达式,又要把每个子表达式的真假值都要测试到,所以,就需要有 8 种情况。
在这么一个条件比较简单的情况下,其实条件覆盖率已经是很复杂了。如果条件进一步增多,复杂度会进一步提升,想要在测试里对条件进行全覆盖也不是一件容易的事。这也给了我们一个编码上的提示:尽可能减少条件。事实上,在真实的项目中,很多条件都是不必要的复杂,可以通过提前返回将一些复杂的条件做一个拆分。
其实,测试覆盖率的指标还有一些,不过上面这些已经足够我们在日常工作中使用了。而且,具体能够使用哪个指标,还要看我们使用的工具具体支持哪些指标。
JaCoCo一个 Java 的测试覆盖率工具
下面我就以 Jacoco 为例,讲讲如何实际地使用一个测试覆盖率工具。
JaCoCo 是 Java 社区常用的一个测试覆盖率工具,这个名字一看就是 Java Code Coverage 的缩写。开发它的团队原本是开发一个叫 EclEmma 的 Eclipse 插件,这个插件本身就是用来做测试覆盖率的。只不过,后来团队发现开源社区虽然有不少测试覆盖率的实现,但大多绑定在特定工具上,于是,他们决定启动 JaCoCo 这个项目,把它当做一个不绑定在特定工具上的独立实现,让它成为 JVM 环境中的标准技术。
我们已经知道了测试覆盖率有好多不同的指标,学习一个具体的测试覆盖率工具,主要就是把指标做一个对应,知道如何设置相应的指标。
在 JaCoCo 里,指标对应的概念是 counter。我们要在覆盖率中使用哪些指标也就是要指定哪些不同的 counter。
每个 counter 提供了不同的配置比如覆盖的数量COVEREDCOUNT没有覆盖的数量MISSEDCOUNT等等但我们最关心的只有一件事覆盖率COVEREDRATIO
有了 counter选定了配置接下来要确定的就是取值的范围也就是最大值maximum和最小值minimum是多少。比如我们这里关注的就是覆盖率的值应该是多少一般就是配置它的最小值minimum是多少。
覆盖率是一个比例,所以,它的取值范围就是从 0 到 1。我们可以根据自己项目的需要来进行配置。根据上面的介绍如果我们要求行覆盖率达到 80%,我们就可以这样配置。
counter: "LINE", value: "COVEREDRATIO", minimum: "0.8"
好,你现在已经有了对于 JaCoCo 的基本了解。但通常在项目中,我们很少会直接使用它,而是会把它与我们项目的自动化过程结合起来。
在项目中使用测试覆盖率
其实,我们在前面的实战中每次执行提交之前的检查命令时,都会运行到 JaCoCo。只不过在大多数情况下只要测试写得好这项检查很容易就通过了。不过在第二讲当我们处理到 Jackson 时,我们被测试覆盖率挡住了,当时是发现了异常处理的问题。
这就是自动化检查的价值。一般情况下,只要你工作做得好,它就默默地在下面工作,并不会影响到你,而一旦你因为一些疏忽忘记了一些事情,它就会跳出来提醒你。
无论是 Ant还是 Maven抑或是 GradleJava 社区主流的自动化工具都提供了对于 JaCoCo 的支持,我们可以根据自己选用的工具进行配置。大部分情况下,配置一次,全团队的人就都可以使用了。
这里面的关键点在于,把测试覆盖率与提交过程联系起来。我们在实战中,提交之前要运行检查过程,测试覆盖率检查就在这个过程里。这样,就保证了它不是一个独立的存在,不仅在我们开发过程中起作用,更进一步,在持续集成的过程中也能够起到作用。
在日常开发中,真正与我们经常打交道的是测试覆盖率不通过的时候,比如,在我们的实战中,运行脚本对代码进行检查时,如果测试覆盖率不够,我们就会得到下面这样的提示。
Rule violated for package com.github.dreamhead.todo.cli.file: lines covered ratio is 0.9, but expected minimum is 1.0
这里会有哪些报错,取决于我们配置了多少个 counter。按照我通常的习惯我会把所有的 counter 都配置上去,这样就可以发现更多的问题了。
不过这个提示只是告诉我们测试覆盖率不够但具体哪不够我们还需要查看测试覆盖率的报告。一般来说测试覆盖率的报告是我们在与工具集成的时候配置好的。JaCoCo 可以提供好多种报告类型XML、CSV、HTML 等等。按照一般使用习惯来说,我会优选使用 HTML 的报告,这样就可以直接用浏览器打开看了。如果你有工具需要其它格式的报告,也可以配置不同的格式。
生成报告的位置也是可以配置的,我在实战项目中,把它配置在 \(buildDir/reports/jacoco 这个目录下,这里的 \)buildDir 指的是每个模块构建生成物的目录,一般来说,就是 build 目录。所以,每次当我看到因为测试覆盖率造成构建失败,就要就可以打开这个目录下的 index.html 文件,它会给你所有这个模块测试覆盖情况的总览。
在实战项目中,我们配置的覆盖率要求是 100%,所以,我们很容易就发现没有覆盖到的地方在哪里,就是那个有红色的地方。然后我们可以一路追踪进去,找到具体类,再找到具体的方法,最终定位到具体的语句,下面就是我们在实战中定位到的问题。
找到了具体的测试覆盖不足的地方,接下来,就是想办法提高测试率。一般来说,在简单的情况里通过增加或调整几个测试,就可以把这些场景覆盖到。但也有一些不是那么容易覆盖的,比如在实战中,我们看到 Jackson API 中抛出的 IOException。
不过,具体如何解决这个问题,对不同的同学来说,会有各自的解决方案。这个地方真正容易引起争议的地方是为什么测试覆盖率要设置成 100%。
在真实的项目中,很多不愿意写测试的人巴不得这个数字越低越好,但实际上我们也很清楚,这个数字设置得很低就没有任何意义了。
先不说一个既有的项目应该设成多少如果是一个全新的项目测试覆盖率应该设成多少呢我在这里已经给出了我的答案是100%。这不是我为了这个实战故意设置的值,而是我在真实的项目中就是这样要求的。估计有人看到这个数字已经有一种快要疯了的感觉,在真实的项目中,设置成 100%怎么可能达到吗?
预告一下这就是下一讲的主题我们来讨论为什么100%的测试覆盖率是可能的。
总结时刻
这一讲我们讲了测试覆盖率。测试覆盖率是帮我们发现在测试中没有覆盖到的代码,也就是帮助我们在测试之外查缺补漏。
测试覆盖率实际上是一组不同指标的组合,所谓覆盖率就是运行一组测试,执行到的元素和总的元素比例。大部分指标都比较好理解,只是条件覆盖率要求比较高,与其通过测试覆盖那么多的条件,不如把代码本身写简单,降低测试的难度。
我以 JaCoCo 为例,给你介绍了一个测试覆盖率工具,其中的 counter 对应着测试覆盖率的指标。在实际的项目中使用测试覆盖率工具,关键是要把它与自动化的过程结合起来,让它不是独立的存在。每次提交,每次 CI 过程都要进行测试覆盖率的检查。
最后我们还讲到了如何通过测试覆盖率的报告找到未覆盖的代码,定位到问题之后,补齐测试对于大多数程序员来说还是相对容易的。
如果今天的内容你只能记住一件事,那请记住:将测试覆盖率的检查加入到自动化过程之中。
思考题
今天我们讲到了测试覆盖率,你的项目中用到了测试覆盖率吗?你对于测试覆盖率是怎样要求的呢?欢迎在留言区分享你的经验。

View File

@ -0,0 +1,86 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 为什么 100% 的测试覆盖率是可以做到的?
你好,我是郑晔!
上一讲我们谈到了测试覆盖率,讲了如何在实际的项目中利用测试覆盖率发现没有覆盖到的代码。最后,我们留下了一个问题:测试覆盖率应该设置成多少?我给出的答案是 100%,但这显然是一个令很多人崩溃的答案。别急,这一讲我们就来说说怎样向着 100%的测试覆盖率迈进。
很多人对测试覆盖率的反对几乎是本能的核心原因就是测试覆盖率是一个数字。我在《10x 程序员工作法》中曾经说过,要尽可能地把自己的工作数字化。本来这是一件好事,但是,很多管理者就会倾向于把它变成一个 KPIKey Performance Indicator关键绩效指标。KPI 常常是上下级博弈的地方,上级希望高一点,下级希望低一点。所以,从本质上说,很多人对测试覆盖率的反对,首先是源于对 KPI 本能的恐惧。
抛开这种本能的恐惧,我们先来分析一下,如果我们想得到更高质量的代码,测试肯定是越多越好。那多到什么程度算最多呢?答案肯定是 100%。如果把测试覆盖率设置成 100%,就没有那么多扯皮的地方了。比如,你设成了 80%,肯定有人问为啥不设置成 85%;当你设置成 85%的时候,就会有人问为啥不是 90%,而且他们的理由肯定是一样的:测试覆盖率越高越好。那我设置成 100%,肯定不会有人再问为啥不设置成更高的。
现在你知道了,我们把覆盖率设置成 100% 这应该是极限的标准了。接下来,要回答的一个问题就是,怎么把覆盖率做成 100%。
向 100% 迈进
首先,我们需要明确的一点是,我们用测试覆盖的代码主要是我们自己编写的代码。为什么要强调这一点呢?因为很多时候,我们会涉及使用第三方程序库,而第三方程序库的功能不应该由我们来验证。比如 Jackson 将对象转换为 JSON 是否转得正确,其实我们是不关心的,这是 Jackson 这个程序库要来保证的。
之所以要先强调这一点,因为在很多人编写的代码中,自己编写的业务代码和第三方程序库的代码常常是混杂在一起的。我们工作的重点是,保证自己编写的代码 100% 测试覆盖。这意味着什么呢?
首先,让自己可控的代码有完全的测试保证,其次,如果有第三方的代码影响到测试覆盖,我们应该把第三方的代码和我们的代码隔离开。
我知道,很多人已经准备强调 100%的测试覆盖是如何困难了。其实,不知道你有没有注意,我们在实战环节中,已经完成了一次 100%的测试覆盖。你可以去看看实战环节的构建脚本,其中用到的测试覆盖率工具就是 JaCoCo而覆盖率的要求就是 100%,也就是 1.0。问题是我们是怎么做到的呢?
我们不妨一起回想一下,在做好了整体的设计之后,我们每实现一个具体的功能,都考虑了测试的场景,测试用例和代码是同步在实现。最后通过测试覆盖率检查,找出没有覆盖到的代码。对于一些不方便测试的第三方程序库代码,我们进行了隔离,而且要求隔离是非常薄的一层。这样,就保证了我们所有编写业务代码都能够很好地得到测试覆盖。
说起来并不复杂,但你或许会说,这是因为我们只实现了基本的功能,代码复杂度比较低,如果是实现了更为复杂的功能,是不是就没办法覆盖了呢?
我们在前面的内容中说过,要想写好测试,一个关键点是要有良好的软件设计,而且代码本身要尽可能地消除坏味道。到这里你就清楚了,其实程序员写测试不单单是写测试,同时,也是在发现自己代码中的不足,无论是设计上,还是代码本身。
所以说,即便是再复杂的功能,通过软件设计和良好的编码,也可以落实到一个一个小代码块上。这里的重点是小,代码能否写短小,这是一个程序员编码基本功的问题。
你让我给一个长达几百上千的代码去写测试,我也很难做到 100%覆盖,因为代码写得太复杂了,我们理解起来很吃力,为它写测试当然也很吃力。所以,我们会把讨论先集中在一个新项目该如何写测试上。如果一个程序员不能够在干干净净的代码库上写好代码,你就很难指望他在面对一个遗留代码库时能够写好代码。
不知道你注意到了没有,我们说在实战中达成 100%测试覆盖时,还有一个工作习惯,就是测试和代码同步写。为什么要这么做呢?因为没有人愿意补测试,无论这个代码是你写的还是别人写的。
这也就是为什么要把测试放在自动化过程中,这样,我们每完成一个任务,就要确保编写了相应的测试。而且,我前面也强调过,任务的关键是小,比如,小到半个小时就可以提交一次,这样,你写测试的负担相对来说是小的。小事相比大事更容易坚持,这是符合人性的做法。
你现在已经知道了,一个新项目想要达到 100%的测试覆盖,首先,要有可测试的设计,要能够编写整洁的代码;其次,测试和代码同步写。
测不到的代码
关于 100%测试覆盖率很多人有一个误区100%覆盖了,是不是就意味着代码没问题了?答案是否定的。即便我们有了 100%的测试覆盖还是会有你想不到的场景出现。100%的覆盖只是保证我们已经写的代码没有场景遗漏,不会有异常场景没有处理,不会有分支条件没有考虑到,仅此而已。
100%的测试覆盖只是程序员做好了本职工作,保证了在这个环节内没有出错。而软件整体质量是一个系统性的工程,首先要保证我们尽可能多地考虑到了各种测试场景,这是我们在第 3 讲中讨论的内容。
对程序员来说,通过把测试覆盖率设置 100%,我们就有了一个查缺补漏的机会。一旦发现有些缺漏很难补上怎么办?就像我们在实战环节中见到的那样,模拟 Jackson 的异常成本过高,我们就会采用隔离的方式,将不好测试的地方隔离开来,形成一个封装层。实际上,我们是在用软件设计的方式在解决问题。
理解了达成 100%测试覆盖的基础之后我还必须再强调一下。第一点是前面提到的封装层这一层一定要非常薄。很多情况下可能就是直接的方法调用。如果有复杂的逻辑比如在防腐层代码中有对象之间的转换我们都可以把转换的逻辑拿出来单独地去写测试因为这个转换逻辑多半是可以测试的。100%的测试覆盖率我们不是说说而已,而是要坚持做到能覆盖的尽量去覆盖。
另外还有一点,隔离出来的代码怎么办呢?我们要在测试覆盖的检查中将它们排除,具体的做法就是在构建文件中,把这个文件标记为不需要测试覆盖。
coverage {
excludeClasses = [
"com.github.dreamhead.todo.util.Jsons"
]
}
在我的项目中,我会要求这里只能有那个薄薄的封装层。有些初次接触项目的人,常常会把这里理解成项目中有我不想测的代码,却还要保证 100%测试覆盖,这里就是一种妥协。绝对不是这个意思!所以,一方面,我们要在团队中强调这个纪律,另一方面,我们也要经常性地做代码评审,保证这个用来隔离封装层的地方不会遭到滥用。
100%虽然要求很高,但要想做到,首先是理念上的认同,然后,我们就可以想各种办法去做到。在实际的项目中,很多人先从理念去否定,认为不可能做到,只要有一点困难就放弃,这其实才是 100%测试覆盖率难以达成的最主要原因。
总结时刻
今天我们延续了上一讲测试覆盖率的话题,讨论了在一个新项目中,测试覆盖率应该设置成多少,我给出的答案就是 100%。
100%的测试覆盖率会遭到很多人的反对,但这种反对首先是对 KPI 行为的一种本能恐惧。在真实项目中,大家都认同的观点是测试覆盖率越高越好,最高的覆盖率肯定是 100%。
我们强调的 100%测试覆盖,主要指的是对自己编写的代码 100%测试覆盖。这就意味着我们一方面要保证自己的代码完全可控另一方面对于影响到测试覆盖的第三方代码要进行隔离。要想做到100%的测试覆盖,技术上说,要有可测试的设计以及编写整洁的代码,实践上看,要测试和代码同步产出。
100%的测试覆盖并不是说代码没有问题了,而应该是程序员对自己编写代码的一种质量保证,它是一个帮助我们查缺补漏的过程。
对于无法测试到第三方代码,要用一个薄薄的隔离层将代码隔离出去,在构建脚本中将隔离层排除在外。有一点需要注意的是,排除脚本千万别被滥用了。
如果今天的内容你只能记住一件事那请记住100%的测试覆盖率是程序员编写高质量代码的保证。
思考题
今天我们讲了如何达到 100%的测试覆盖,你在实际工作中遇到过哪些难以测试的情况呢?期待在留言区看到你的想法。

View File

@ -0,0 +1,96 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 集成测试:单元测试可以解决所有问题吗?
你好,我是郑晔!
前面我们花了大量的篇幅在讲单元测试。单元测试是所有测试类型中,运行速度最快,整体覆盖面可以达到最大的测试。因为单元测试的存在,我们甚至可以把测试覆盖率拉高到 100%。测试覆盖率都已经 100%了,是不是我们用单元测试就可以解决所有的问题了?
正如我们在上一讲强调的那样100%的测试覆盖率并不代表代码没有问题。同样,即便是 100% 的单元测试也不能解决所有的问题。有一个重要的原因在于,我们在编写每个单元时都会假设这些单元彼此之间能够很好地协同,但这个假设是不是一定成立呢?答案是不一定。
让一个个单元正常运行,我们靠的不是美好的预期,而是单元测试。同样,各个单元能够很好地协同,我们也不能靠预期,而是要靠集成测试。这一讲,我们就来讨论一下集成测试。
代码的集成
在具体讨论集成测试之前,我们澄清一下概念。集成测试到底测的是什么?答案很显然是集成。问题是,集成要集成什么呢?一种是代码之间的集成,一种是代码与外部组件的集成。说白了,集成测试就是把不同的组件组合到一起,看看它们是不是能够很好地配合到一起。
我们先来看代码的集成。代码之间的集成,主要看我们编写的各个单元能否很好地彼此协作。既然集成测试是为了测试单元之间的配合,那是不是只要有单元之间的协作,我们就要为它们编写一个集成测试呢?比如按照常规的架构分层,一个 REST 服务会有一个 Resource或者叫 Controller一个 Service一个 Repository那是不是要 Service 和 Repository 的集成要写一个集成测试Resource 和 Service 的集成测一次Resource、Service 和 Repository 的集成再测一次呢?
如果我们按照前面讨论的方式来编写了单元测试,其实,这就意味着我们每个组件都已经经过了测试。所以,集成测试的重点就不再是组件之间两两协同进行测试了。一般来说,在实践中,我们可以选择的测试方式是,选择一条任务执行的路径,把路径上用到的组件集成到一起进行测试。比如在前面提到的那种情况中,我们只要把 Resource、Service 和 Repository 都组装到一起就可以了。
如果所有的代码都是我们自己编写,那么我们就编写一个个的单元,然后组装到一起进行测试,这个很好理解。但是,现在很多人都在使用框架,比如我们在实战中处理命令行时使用了 Picocli 这个框架,所有的命令解析的过程都是由这个框架完成;再比如,很多人在开发后端服务时,使用了 Spring Boot一些路由匹配甚至参数检查都是由框架完成的。那么我们在集成测试中要不要把这个部分集成进来呢
我对此的答案是,取决于你是否能够把这个框架集成进来,如果能,最好是做一个完整的集成测试。在实战中,我们已经展示过如何去集成 Picocli因为这个框架本身比较简单很容易找到这个框架的外部入口我们就把它集成起来做了一个完整的测试。
有的框架可能就没有那么简单了,就像当年 Java EE 盛行时,我们编写的代码需要部署到一个 Java EE 的容器里面才能运行。在这种情况下,如果强行把 Java EE 容器也加到集成测试里,对于大多数人来说,这是非常有难度的一件事情。换言之,像这种有单独运行时的框架,做整体的集成难度很大,我们只能退而求其次,做大部分的代码集成。
现在的很多框架替我们做了很多的事情有些甚至是业务验收标准上的事情比如Spring Boot 会替我们做参数检查,利用好 Spring Boot 给我们提供的机制,我们甚至不用写什么代码,只要给字段加上一些 Annotation 就够了。这些 Annotation 加的是否正确,我们其实是需要验证的,因为它是业务验收标准的一部分。
所以我希望尽可能地去集成,如果我们能够把整个框架集成起来,这些东西也就可以验证了。从代码上来看,这种测试只是针对一个单元在测试,在某种程度上说,这种集成测试其实是一种单元测试,只不过,它必须把系统集成起来才行,所以,它兼具单元测试和集成测试的特点。
小小预告一下Spring Boot 在测试上的支持是真的很不错,让我们可以很容易地在测试里对框架处理过程进行集成,在后面的课程里你会看到如何使用 Spring Boot 提供的测试基础设施进行测试。
你也看到了,我们希望尽可能地把框架集成进来,但市面上的各种框架层出不穷,不是所有的框架都提供了对测试很好地支持。所以,一个框架设计得好坏与否,对测试的支持程度也是一个很重要的衡量标准,这能很好地体现出框架设计者的品味。
能够方便测试的框架,通常来说都是很轻量级的,这样的框架对开发非常友好,我们能够在一个普通的 IDE 里很方便地进行调试,对于定位问题也是极其友好的。而各种有运行时需要部署的框架,相对来说,就是重量级的框架,对于开发非常不友好。如果你用过一些 IDE 支持的远程调试功能,你会发现这些功能跟本地调试相比,便捷程度完全不在一个档次上。
好消息是,我们还是能看到一些框架的进步,即便重如 Java EE 这样的框架,现在也有了嵌入式容器的概念。今天,我们之所以能够很方便地使用 Spring Boot 这样的框架,嵌入式容器给我们提供了非常好的基础。
集成外部组件
说完了代码的集成,我们再来看看与外部组件的集成。
在真实世界的开发中,我们经常会遇到与外部组件打交道的情形,最简单是数据要写到数据库里,还有发消息可能会用到消息队列,甚至还可能会涉及与第三方系统的集成。
理想情况下,我们当然希望把所有相关的组件都集成到一起,但是,一旦牵扯到外部组件,测试的难度立刻就增大了。比如在测试中添加了 Todo 项,如果我的断言写的是先判断数据库里 Todo 项表里有唯一的一条记录,执行之前,你因为其它操作在数据库里插入了数据,这个断言就失败了。即便没有人操作,这个测试执行第一次成功了,再执行一次,可能就失败了,因为第二次执行测试又插入了一条数据。
所以,与外部组件集成,难点就在于外部组件的状态如何控制。
如果能够控制外部组件的状态,在系统里集成它是没有问题的。比如拿数据库集成来说,通常的做法是一方面,我们会建立单独的数据库,保证不与其他部分冲突。比如在 MySQL 里面,我们会建立一个测试用的数据库。
CREATE DATABASE todo_test;
另一方面,我们要保证它在每个测试之后,都能够恢复到之前的状态。一种做法就是使用数据库的回滚技术,每个测试完成之后就回滚掉,保证数据的干净。后面讲到 Spring Boot 测试的时候,我们会看到具体的做法。
相对来说,数据库在测试方面的实践已经算是比较成熟了。这也让我们可以去验证 Repository也就是数据访问层的代码实现。不管使用什么样的框架写了 SQL 之后,我们都需要验证其正确性。只不过,很多人的选择是把整个系统跑起来,人工去验证 SQL 的正确性,这种做法一方面有些小题大做了,另一方面还是不够自动化。
有了数据库在测试上的实践,我们就可以用自动化测试的方式进行测试了。其实,从某种意义上说,这也是一种单元测试,因为它的代码只涉及到了一个单元,只不过它需要集成数据库,所以,它还是集成测试
还有一些外部组件在这方面的支持相对来说,就不那么令人满意了。比如第三方系统。即便是服务做得很完善的第三方系统,也很少有专门为测试提供支持的。
遇到这种情况,我们就要分析一下,看看有没有什么替代方案。很多第三方系统对外提供服务的方式都是 REST API对于这种情况我们就可以用通用的模拟服务器来代替。模拟服务器的价值就在于能够替代这样的第三方服务。
在这种情况下,我们该怎么做呢?我们需要按照我们的使用场景去访问第三方服务,把整个访问的报文记录下来,作为设置模拟服务器的参考依据。我介绍过的 Moco 甚至提供了代理proxy功能你可以让你的服务去连接 Moco然后用 Moco 连接第三方的服务,只要查看 Console 输出,所有的报文就清清楚楚地展现在你面前了。
如果外部组件没有现成的替代方案怎么办?有两个角度看待这个问题。一个角度是,这也许是一个做新项目的机会,我在《软件设计之美》中讲过 Moco 的开发过程,其起始点就是一个没有很好解决的问题。
另一角度,估计是大多数人的选择,那就是既然这里测不了,我可以选择在集成测试里使用模拟对象,而不是真实的对象。在这样的情况下,我们的系统在测试方面其实有一个漏洞没有被测试很好地覆盖。也就是说,我们要把这个漏洞留到更上一层的测试。如果这个漏洞是一个简单的逻辑(比如一个消息队列发消息的接口),这样还好。如果里面有逻辑,我们必须把它作为一个重点的风险提示加以重视。不过,好在这种情况并不是很多,毕竟像 SQL 这种有复杂逻辑的东西,我们已经有了解决方案。
总结时刻
今天我们讲了集成测试,相对于单元测试只关注单元行为,集成测试关注的多个组件协同工作的表现。今天我们讨论了两类典型的集成问题,一种是代码之间的集成,一种是代码与外部组件的集成。
对代码之间的集成来说,一方面要考虑我们自己编写的各个单元如何协作;另一方面,在使用各种框架的情况下,要考虑与框架的集成。如果我们有了单元测试,这种集成主要是关心链路的通畅,所以一般来说我们只要沿着一条执行路径,把相关的代码组装到一起进行测试就可以了。
如果涉及框架,最好是能够把框架集成一起做了,设计得比较好的框架是对于测试的支持比较好的(比如像 Spring Boot可以让我们很方便地进行测试。
对于外部组件的集成而言,难点在于如何控制外部组件的状态。数据库在这方面相对已经有比较成熟的解决方案:使用单独的数据库,以及在测试结束之后进行回滚。
但大部分系统没有这么好的解决方案,尤其是第三方的服务。这时候,我们就要看有没有合适的替代方案。对于大多数 REST API我们可以采用模拟服务器对服务进行模拟。
通过今天的讨论你会发现,严格地说,有些代码由于基础设施的问题是不容易在自动化场景覆盖的,这也是我们为什么要强调与框架结合的代码一定要薄,让这种代码的影响尽可能少。这也是在减少用上层测试覆盖的工作量。
到这里,大部分的场景我们都已经可以用自动化测试进行覆盖了,我们对自己的系统已经有了更完整的理解。其实,测试的种类还有更多,比如系统测试,把整个系统集成起来测试;验收测试,交由业务人员或测试人员进行测试。但这些测试对于很多团队来说,已经到了测试人员的工作范畴了。作为程序员,我们能够把单元测试和集成测试做好,整个软件的质量已经是初步合格了。
如果今天的内容你只能记住一件事,那请记住:想办法将不同组件集成起来进行测试。
思考题
今天我们讲了集成测试,你也看到了集成测试难点就在于如何集成。在实际工作中,你遇到过哪些难以在测试中集成的情况吗?欢迎在留言区分享你的经验。

View File

@ -0,0 +1,326 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 实战:将 ToDo 应用扩展为一个 REST 服务
你好,我是郑晔!
经过了基础篇的介绍,相信你已经对在日常开发中测试应该做到什么程度有了一个初步的认识。有了基础固然好,但对于很多人来说,面对常见的场景还是不知道如何下手。在接下来的应用篇中,我们就用一些开发中常见的场景,给你具体介绍一下怎么样把我们学到的知识应用起来。
在后端开发中,最常见的一种情况就是开发一个 REST 服务,将数据写到数据库里面,也就是传说中的 CRUD 操作。这一讲,我们就把前面已经写好的 ToDo 应用扩展一下,让它变成一个 REST 服务。
扩展前的准备
具体动手写任何代码之前,我们先要搞清楚我们要把这个应用改造成什么样子。把 ToDo 应用扩展为一个 REST 服务也就是说,原来本地的操作现在要以 REST 服务的方式提供了。另外,在这次改造里面,我们还会把原来基于文件的 Repository 改写成基于数据库的 Repository这样就和大多数人在实际的项目中遇到的情况是类似的了。
有人可能会想,既然是 REST 服务,那是不是要考虑多用户之类的场景。你可以暂时把它理解成一个本地运行的服务(也就是说只有你一个人在使用),所以我们可以不考虑多用户的情况。这样做可以让我们把注意力更多放在测试本身上,而增加更多的能力是需求实现的事情,你可以在后面拿这个项目练手时,做更多的尝试。
确定好了需求目标接下来我们就要进入到具体的实现过程里面了。RESTful API 不同于命令行应用,不应该把它的代码同命令行的代码混杂在一起,所以,我们可以建一个单独的模块来放置这些代码,我把这个模块叫 todo-api。至于具体采用的技术栈我们就使用在 Java 社区最常用的 Spring BootSpring Boot 能够极大简化了 REST 服务的开发。
同之前一样,我们先实现 Repository 的部分,然后再来做接口。或许你会有一个疑问,难道不是要实现业务核心部分吗?别忘了,我们在之前的实现中特意将业务核心部分隔离了出来,让它不依赖于任何具体的外部实现。虽然我们是将一个命令行应用改成一个 RESTful API但业务核心部分并没有发生任何改变所以我们也不需要重新编写一份。这就是软件设计的价值所在。
数据访问
前面说过,我们要把之前基于文件版本的 Repository 实现改成基于数据库的版本,所以我们要先来确定数据访问相关的技术。我选择 MySQL 这个大家最常用的数据库,访问数据库的程序库我选择的是 Spring Data JPA因为它可以让我尽可能少编写代码。
技术选型
两种常见的访问数据库的方式分别是 MyBatis 和 JPA。MyBatis 倾向于让人手工编写 SQL 语句,而 JPA 则采用更加面向对象的角度,它访问数据库的 SQL 语句常常是由框架生成的。二者的差异主要是 MyBatis 更加面向具体的实现,而 JPA 则提供了更好的抽象能力。
目前国内的现状是很多团队会使用 MyBatis他们给出的理由大多是自己写 SQL 比较好控制,尤其是对一些复杂场景来说更容易优化。不过,实际情况往往是,如果采用 JPA 的话,很多团队对于生成什么样的代码自己完全心里没有数,因为欠缺建模能力才用 MyBatis。而对于很多建模做得比较好的团队来说使用 JPA 往往开发效率更高。
Spring Data JPA 在 JPA 上提供了进一步的封装,一些常见的数据访问甚至不需要去编写代码,因为访问数据库的 SQL 都是由框架生成的,是一个标准操作。因为不是我们编写的代码,我们也无需验证它的正确性,只要保证我们自己写的代码正确地表达了我们的意图即可。如果真的有一些比较复杂的 SQL 逻辑要实现Spring Data JPA 也允许我们自己手写 SQL这是框架留给我们的优化手段。
所以,我们这里选择 Spring Data JPA。下面我们就来开始我们的实现之旅。
数据库迁移
在开始编码测试工作之前,我们要先确定 Todo 项存储的结构。所以,我们要在数据库中创建一个表。
CREATE TABLE todo_items (
`id` int auto_increment,
`content` varchar(255) not null,
`done` tinyint not null default 0,
primary key (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
我们已经在实战中看见过实体的样子了,所以,这里的表结构并不难理解。唯一需要稍微解释一下的就是在表里面我们用了 id而在 Todo 项的实体中,它对应的是 index。其实只要你稍微仔细地想一下就不难发现在我们之前的设计中index 就是起到了 id 的作用。对应的实体就是下面这样:
@Entity
@Table(name = "todo_items")
public class TodoItem {
@Id
@Column(name = "id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long index;
@Column
private String content;
@Column
private boolean done;
...
}
在项目自动化中,数据库迁移脚本我们采用了 Flyway它可以很方便地将数据库的变更管理起来。我们只要在 $rootDir/gradle/config/migration 这个位置创建一个迁移脚本,把上面的 SQL 写进去就好,具体的细节你可以参考我们的开源项目。
有了迁移脚本,我们就可以执行命令将这个表创建出来。
./gradlew flywayMigrate
好,基础已经准备好了,我们准备要动手写测试了。
编写测试
我在上一讲说过测试数据库相关的内容属于兼具集成测试和单元测试两种属性的测试一方面它要对数据库做集成另一方面它要测的内容本身属于验证一个单元代码是否编写正确的范畴。对于数据库相关的测试Spring 提供了很好的支持,让我们可以更好地完成验证工作。
下面就是一个测试。如果你还记得之前文件版本 Repository 的测试,这个测试你可能会很眼熟。没错,这里的测试我几乎就是原封不动地把前面的测试搬了过来,因为 Repository 接口的行为几乎是一致的。这也是我这里并没有做测试场景分析的原因。
@ExtendWith(SpringExtension.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource("classpath:test.properties")
public class TodoItemRepositoryTest {
@Autowired
private TodoItemRepository repository;
@Test
public void should_find_nothing_for_empty_repository() {
final Iterable<TodoItem> items = repository.findAll();
assertThat(items).hasSize(0);
}
...
}
看到 @Autowired,如果你熟悉 Spring 应该感到非常亲切,它表示这个字段由框架自动绑定的。那这个自动绑定为什么能起作用呢?这就要拜前面几个 Annotation 所赐了。
@ExtendWith(SpringExtension.class),在这里面,@ExtendWith 是 JUnit 5 提供的扩展机制,让第三方有机会编写自己的代码。而 SpringExtension 就是 Spring 提供的扩展,用来做一些 Spring 自己需要的准备和清理之类的工作,比如依赖注入就是通过它完成的。
@DataJpaTest,表示这个测试采用 Spring Data JPA。有了这个 AnnotationSpring 框架会替我们把 Repository 的实例生成出来。因为使用 Spring Data JPA 的时候,我们只编写了接口。还记得 TodoItemRepository 这个接口吗?现在它变成了下面这个样子。
public interface TodoItemRepository extends Repository<TodoItem, Long> {
TodoItem save(TodoItem item);
Iterable<TodoItem> findAll();
}
同之前相比,这里的方法没用任何变化,只是扩展了一个接口 Repository这是一个标记接口也就是意味着只有接口没有方法。实现这个接口是 Spring Data JPA 的要求,它会在运行时为这个接口生成相应的实例,换言之,我们不需要为此编写具体的实现。
其实Spring Data JPA的函数名是有一些约定的在前面给 Repository 的函数命名的时候,我就是参考了 Spring Data JPA 的命名规则,所以,我们在这里可以无缝地与 Spring Data JPA 对接在一起。
按照 Spring Data JPA 的要求,我们要让 Spring 在启动的时候能够找到我们配置的实体和 Repository。因为我们这里的实体和 Repository 不在缺省的扫描路径上,所以这里需要单独配置一下。下面就是我们的配置,这是一个典型的 Spring Boot 的应用。
@SpringBootApplication
@EnableJpaRepositories({"com.github.dreamhead.todo.core"})
@EntityScan({"com.github.dreamhead.todo.core"})
public class Bootstrap {
...
}
万事俱备,我们现在可以运行测试了,如果一切顺利的话,测试会一次性运行通过。
这里其实有个实现的细节,测试并没有在数据库留下任何痕迹,正如我们在讲集成测试中说过的那样,这里的测试在运行之后回滚了在测试过程中插入的数据,这是 DataJpaTest 的缺省行为,大大简化了测试的难度。
你会发现,其实我们并没有写多少有逻辑的代码:表是 SQL 语句生成的,测试是从前面的测试搬过来的,主要的工作都是配置,而数据库访问的过程是框架生成的。减少自己编码的工作量,我们的测试压力也就小了很多。
RESTful API
有了 Repository接下来我们就要来设计实现 API 接口了。对于一个服务而言,对外提供哪些接口是很重要的。任何一个提供后端服务的团队都要仔细地设计其服务接口,确定它应该提供哪些能力,而不仅仅是围绕着前端需求去做。
设计 RESTful API
还记得我们的 ToDo 应用提供了哪些能力吗?我们回顾一下:
添加一个 Todo 项;
完成一个 Todo 项;
Todo 项列表。
接下来,我们就把它们设计成 API 接口。所有这三个能力都是围绕着 Todo 项进行的,所以,我们可以把它们设计在一个资源下,不妨就把它的 URI 设计成 /todo-items一般来说这里一般会使用复数表示这有一堆资源。
有了最基础的资源,接下来,就是一个一个地按照 RESTful API 的方式设计出来。首先是添加一个 Todo 项。按照通常 RESTful API 接口的设计方式,相当于在服务端创建了一个新的资源,而创建的语义一般会用 POST 请求表示。创建一个 Todo 项,主要包含的就是 Todo 项的内容,其格式我们就采用 RESTful API 常用的 JSON 格式了。
POST /todo-items
{
"content": "foo"
}
有了创建 Todo 项的服务,接下来就是完成一个 Todo 项了。完成一个 Todo 项,按照 RESTful API 的设计方式,这个动作相当于对已有资源的修改,修改对应的 HTTP 动词是 PUT。不同于 POSTPUT 操作需要指定一个具体的资源我们这里使用索引作为唯一标识其对应的内容就是完成字段done置为 true。目前来说我们也不支持其它的处理所以严格地说这里的内容其实意义不大。
PUT /todo-items/{index}
{
done: true
}
最后是一个 Todo 项列表。列表操作实际上是一种查询,在 RESTful API 设计中,查询对应的 HTTP 动词是 GET。在我们的实战需求中Todo 项列表还分为查询未完成的 Todo 项和查询所有,从查询的角度来看,就是查询的参数不同。我们这里设置查询参数为 all缺省情况下 all 的值为 false如果显示设置了这个值则按照设置的值进行查询。
GET /todo-items?all=true
测试 RESTful API
做好了基本的设计工作,接下来我们就该进入代码编写的环节了。
同 Repository 部分一样,我们在这个部分的测试也准备从之前的测试中借鉴过来。所以,我们这里不把重点放在测试场景的分析上,而是来讨论如何编写测试。下面就是一个测试。
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
public class TodoItemResourceTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private TodoItemRepository repository;
...
.
@Test
public void should_add_item() throws Exception {
String todoItem = "{ " +
"\"content\": \"foo\"" +
"}";
mockMvc.perform(MockMvcRequestBuilders.post("/todo-items")
.contentType(MediaType.APPLICATION_JSON)
.content(todoItem))
.andExpect(status().isCreated());
assertThat(repository.findAll()).anyMatch(item -> item.getContent().equals("foo"));
}
...
}
从测试的名字便不难看出,这个测试是用来测试添加 Todo 项的。在这个类的开头有几个 Annotation
@SpringBootTest,它告诉我们,接下来的测试是把所有组件都集成起来的集成测试。在前面的实战中,我说过最外面的接口很薄,所以我把集成测试和单元测试的工作量放到了一起。
@AutoConfigureMockMvc,表示我们要使用的是模拟的网络环境,也就不是真实的网络环境,这样做可以让访问速度快一些。
@Transactional,说明这个测试是事务性的,在缺省的测试事务中,执行完测试之后,数据是要回滚,也就是不对数据库造成实际的影响。这要单独标记,否则就会有数据写入到数据库里面。而之前的 @DataJpaTest 自身就包含了这个 Annotation所以不用特别声明。
有了这些基础准备,我们就可以测试了。你可以认为,当我们执行测试时服务已经起好了,我们这里就像一个普通的客户端一样去访问一个服务,核心的部分就是下面这段代码。
todoItem = "{ " +
"\"content\": \"foo\"" +
"}";
mockMvc.perform(MockMvcRequestBuilders.post("/todo-items")
.contentType(MediaType.APPLICATION_JSON)
.content(todoItem))
.andExpect(status().isCreated());
我们创建了一个请求,设置了这个请求的基本信息,用什么样的 HTTP 动词POST 访问哪个地址(/todo-items具体的内容是什么等等。然后预期返回的参数是什么状态码是 201也就是 CREATED
这里我们用的是 MockMVC因为我们配置了@AutoConfigureMockMvc,它给我们创建了一个模拟的网络环境。这就是 Spring 在测试方面做得好的地方,作为框架的使用者,我们面对的都是编程的接口,支撑这些接口的实现在正常情况下是标准的网络环境,但 Spring 为我们提供了测试专用的实现,也就是不同的运行时,这就是做好了软件设计的结果。
不同于直接调用接口进行单元测试,这里的测试是集成测试,走的是完整的路径。所以,我们可以测试一些属于外部接口的行为,比如我们可以测试传入空的字符串该怎么办。
@Test
public void should_fail_to_add_unknown_request() throws Exception {
String todoItem = "";
mockMvc.perform(MockMvcRequestBuilders.post("/todo-items")
.contentType(MediaType.APPLICATION_JSON)
.content(todoItem))
.andExpect(status().is4xxClientError());
}
编写 RESTful API
有了测试,接下来就是实现相应的代码了。
@RestController
@RequestMapping("/todo-items")
public class TodoItemResource {
private TodoItemService service;
@Autowired
public TodoItemResource(final TodoItemService service) {
this.service = service;
}
@PostMapping
public ResponseEntity addTodoItem(@RequestBody final AddTodoItemRequest request) {
if (Strings.isNullOrEmpty(request.getContent())) {
return ResponseEntity.badRequest().build();
}
final TodoParameter parameter = TodoParameter.of(request.getContent());
final TodoItem todoItem = this.service.addTodoItem(parameter);
final URI uri = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(todoItem.getIndex())
.toUri();
return ResponseEntity.created(uri).build();
}
...
}`
如果你熟悉 Spring Boot 的话,这段代码对你来说应该不难。即便你不熟悉,仅仅是通过阅读代码,也很容易理解这段代码的含义:
@RestController,告诉 Spring 这是一个 REST 服务的入口类。这个类的命名是 TodoItemResource因为在 REST 服务中,资源是一个很重要的概念,而这里的 Controller可以说是从历史遗留的产物。
@RequestMapping(“/todo-items”),说明服务入口的地址是 /todo-items这是这个类里所有服务的根。
每个具体的方法都会有自己相应的配置,对应着一个具体的服务,比如,在 addTodoItem 中是 @PostMapping,表示这个方法接收的是 POST 请求。
POST 服务一般都会有一个请求体,在这个方法中,我们使用 AddTodoItemRequest 的实例来接收这个请求体。在 HTTP 传输过程中传输的是文本Spring 框架会替我们将文本转换成一个对象。只要我们把转换规则声明出来Spring Boot 采用的 JSON 处理框架是 Jackson所以我们要在类的声明时采用 Jackson 的规则,就像下面这样。
public class AddTodoItemRequest {
@Getter
private String content;
@JsonCreator
public AddTodoItemRequest(@JsonProperty("content") final String content) {
this.content = content;
}
}
在这里,@JsonCreator 表示这是一个 JSON 对象的构造方法,而@JsonProperty 则表示将对应属性的值赋值给这里的参数。
从软件设计的角度说Resource 是一个防腐层AddTodoItemRequest 是一个外部请求对象。把外部对象和内部对象分开,这是很重要的(我在《代码之丑》中分析过这种做法的原因)。所以,在具体的函数中,我们首先要做就是把外部对象转换成内部对象。
final TodoParameter parameter = TodoParameter.of(request.getContent());
好,到这里,我们把这段代码中主要的设计考量都已经分析过了。这段代码完整的实现,你可以参考我们的开源项目。
总结时刻
这一讲,我们将原本的 ToDo 应用从一个命令行应用扩展为一个 REST 服务。因为我们已经构建好了业务核心,所以这里的工作同之前是一样的:要增加一个 Repository要编写服务的入口。
在增加 Repository 方面,我们选择了 Spring Data JPA目的是减少代码的编写。然后我们增加了相应的数据库迁移脚本这里采用 Flyway 管理数据库迁移的工作。
因为选择了 Spring Data JPA我们在测试里用@DataJpaTest,它会帮我们设置好 Repository也会帮我们在测试运行之后回滚数据。
对外的接口我们采用 RESTful API 的设计。这里我们同样采用了集成测试代替单元测试的做法,集成测试是靠@SpringBootTest 把各种组件都集成起来。这里我们还用到了 MockMVC 让我们的测试不依赖于真实的环境,访问速度可以稍微快一点点。
接口层本身是一个典型的防腐层,所以一般来说这层会做得非常薄,会把外部请求与业务层分隔开来。
如果今天的内容你只能记住一句话,那么请记住,集成测试回滚数据,保证测试的可重复性。
思考题
今天我们用 Spring 的基础设施演示了如何进行测试。你使用过 Spring 吗?有哪些测试特性让你印象深刻的?或者你用哪个框架给你提供了很好地测试支持呢?欢迎在留言区分享你的经验。

View File

@ -0,0 +1,160 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 在 Spring 项目中如何进行单元测试?
你好,我是郑晔!
上一讲,我们将 ToDo 应用从命令行扩展为了 REST 服务。在这个应用里,我们用到了 Spring 这个在 Java 世界中广泛使用的框架。大多数人对于 Spring 这个框架的认知都停留在如何使用它完成各种功能特性上,而 Spring 更大的价值其实在对于开发效率的巨大提升上,其中就包含了对测试的支持。
在接下来的两讲,我们就把注意力从一个具体的项目上挪开,放到 Spring 框架本身,看看它对开发效率提升的支持。
轻量级开发的 Spring
很多人对于 Spring 的理解是从依赖注入容器开始的但是Spring 真正对行业的影响却是从它对原有开发模式的颠覆开始。
在 21 世纪初的时候Java 世界的主流开发方式是 J2EE也就是 Java 的企业版。在那个时候,企业版代表软件开发的最高水准。在这个企业版的构想中,所有的复杂都应该隐藏起来,写代码的程序员不需要知道各种细节,需要的东西拿过来用就好了。
这种想法本身是没有问题的时至今日很多平台和框架也是这么想的。到了具体的做法上J2EE 提供了一个应用服务器,我把这些复杂性都放在这个应用服务器里实现,你写好的程序部署到这个应用服务器上就万事大吉了。但正是因为应用服务器的存在,使用 J2EE 进行开发变成了一件无比复杂的事情。
将程序打包部署这件事说起来很简单,但在实际的工作中,如果一个团队没有做好自动化,打包部署会非常麻烦。再者,除了自己的业务代码,所有相关的第三方 JAR 包都需要打到最终的发布包中,造成的结果就是发布包非常大。在那个网络带宽还不是特别大的年代,传输这个发布包也要花很长的时间。
更关键的是,一旦出了问题怎么去定位也是个令人头疼的问题。
程序员最熟悉定位问题的方式就是调试代码。之前所有的代码都是在本地,调试起来还比较容易,现在代码运行在应用服务器上,我们必须连接到远程应用服务器上进行调试,而要连接应用服务器进行调试,还需要一些配置,总之,这件事真的是非常麻烦。
对于麻烦的事情,人们倾向于少做或不做,但是 J2EE 让这件麻烦事成了必选项。所以,那个年代的 Java 程序员处于一种痛苦不堪的状态,开发效率极其低下。
就在整个 Java 社区饱受折磨之际Spring 横空出世。对于 J2EE 提出的问题Spring 是承认的但对其给出的解决方案它却是不认的。因为应用服务器太重了Spring 给社区带来了轻量级开发。
Spring 的逻辑很简单这些东西通过程序库的方式就可以完成为什么非要弄一个应用服务器呢采用程序库的方式最大的优势就在于可以在本地开发环境中进行开发和调试这就极大地降低开发的难度。于是对于同样的问题Spring 抛弃了 J2EE 中的大部分内容,给出了自己的程序库解决方案,应用服务器变得可有可无了。
事实证明,人们更喜爱简单的解决方案,即便 J2EE 有强大的官方背书程序员们还是义无反顾地抛弃了它。Spring 从此成了 Java 社区的主流,也成了轻量级开发的代名词。
Spring 不仅是恰当地把握了时机,占据了 Java 世界中的关键位置,更重要的是,在随后的发展中,一直凭借对于轻量级开发的追求以及良好的品位,使得它在 Java 程序员心目中占据着无可替代的位置。即便中间有部分地方其它的程序库做得稍微好一些,它也能很快地学习过来。
前面我说过,虽然 Spring 抛弃了 J2EE 中的大部分内容,基于 Web 服务器的开发还是得到了保留。因为当时确实没有什么更好的选择,虽然大部分代码可以在本地测试,但很多时候我们还是要打成一个 WAR 包部署到像 Tomcat 这样的 Web 服务器上。不过,随着 Tomcat 和一众 Web 服务器提供了可嵌入的 API打包部署这种 J2EE 残留方式就彻底成为了过去,也就诞生今天很多 Java 程序员熟悉的 Spring Boot可以说 Spring Boot 是 Spring 多年努力的集大成者!
Spring 的测试
不过在 Spring Boot 出现之前,正是因为无法摆脱打包部署的这样的模式,基于这条路走下去开发难度依然不小,可以说并没有从根本上改变问题。但 Spring 的轻量级开发理念是支撑它一路向前的动力,既然那个时候 Web 服务器不能舍弃,索性 Spring 就选择了另外一条路:从测试支持入手。
所以 Spring 提供了一条测试之路,让我们在最终打包之前,能够让自己编写的代码在本地得到完整验证。你在实战环节中已经见识过如何使用 Spring 做测试了。简单来说就是使用单元测试构建稳定的业务核心,使用 Spring 提供的基础设施进行集成测试。
严格地说,构建稳定的业务核心其实并不依赖于 Spring但 Spring 提供了一个将组件组装到一起基础设施也就是依赖注入Dependency Injection简称 DI容器。通常我们会利用 DI 容器完成我们的工作,也正是因为 DI 容器用起来很容易,所以常常会造成 DI 容器的误用,反而会阻碍测试。
在第6讲中我们讨论过要编写能够组合的代码。依赖注入的风格会引导我们编写能够组合的代码也就是不要在类的内部创建组件而是通过依赖注入的方式将组件注入到对象之中。
所以,在一个使用 Spring 项目进行单元测试的关键就是,保证代码可以组合的,也就是通过依赖注入的。你可能会说,我们都用了 Spring那代码肯定是组合的。这还真不一定有些错误的做法就会造成对依赖注入的破坏进而造成单元测试的困难。
不使用基于字段的注入
有一种典型的错误就是基于字段的注入,比如像下面这样。
@Service
public class TodoItemService {
@Autowired
private TodoItemRepository repository;
}
@Autowired 是一个很好用的特性,它会告诉 Spring 自动帮我们注入相应的组件。在字段上加Autowired 是一个容易写的代码,但它对单元测试却很不友好,因为你需要很繁琐地去设置这个字段的值,比如通过反射。
如果不使用基于字段的注入该怎么做呢?其实很简单,提供一个构造函数就好,把@Autowired 放在构造函数上,像下面这样子。
@Service
public class TodoItemService {
private final TodoItemRepository repository;
@Autowired
public TodoItemService(final TodoItemRepository repository) {
this.repository = repository;
}
...
}
这样一来,编写测试的时候我们只要像普通对象一样去测试就好了,具体的做法你要是记不清了,可以去回顾一下实战环节。
这种构造函数一般我们都可以利用 IDE 的快捷键生成,所以这段代码对我们来说也不是很重的负担。如果你还嫌弃这种代码的冗余,也可以用 LombokLombok 是一个帮助我们生成代码的程序库)的 Annotation 来简化代码,像下面这样。
@Service
@RequiredArgsConstructor
public class TodoItemService {
private final TodoItemRepository repository;
...
}
不依赖于 ApplicationContext
使用 Spring 还有一种典型的错误,就是通过 ApplicationContext 获取依赖的对象,比如像下面这样。
@Service
public class TodoItemService {
@Autowired
private ApplicationContext context;
private TodoItemRepository repository;
public TodoItemService() {
this.repository = context.getBean(TodoItemRepository.class);
}
...
}
我们可以把 ApplicationContext 理解成 DI 容器,原本使用 DI 容器的优点就是可以不知晓依赖是怎么产生的,而在这段代码里,却知晓了 DI 容器,这就完全打破了 DI 容器设计的初衷(关于 Spring 的设计初衷,我在《软件设计之美》中专门有一讲分析过,如果你有兴趣可以去了解一下)。
在业务核心代码中出现 ApplicationContext 是一种完全错误的做法。一方面,它打破了 DI 容器原本的设计,另一方面,还让业务核心代码对第三方代码(也就是 ApplicationContext产生了依赖。
我们再从设计的角度看一下AppliationContext 的出现使得我们在测试这段代码时,必须引入 ApplicationContext。要想在代码里获取到相应的组件需要在测试中向 ApplicationContext 里添加相应的组件,这会让一个原本很简单的测试变得复杂起来。
你看,一个正常的测试是如此简单,但正是因为引入了 Spring许多人反而会做错。Spring 最大的优点是可以在代码层面上不依赖于 Spring而错误的做法反而是深深地依赖于 Spring。
我们前面讨论了这么多,其实并没有针对 Spring 对单元测试的支持进行讲解,但 Spring 其实还真提供了一个对单元测试的支持,也就是@MockBean,也就是帮我们进行 Mock 对象的初始化,像对于下面这行代码来说:
@MockBean
private TodoItemRepository repository;
它就等同于下面这段。
@BeforeEach
public void setUp() {
this.repository = mock(TodoItemRepository.class);
...
}
但是我并不想特意强调这种做法。一方面,这种初始化的代码清晰且不复杂,另一方面,即便我们真的打算节省这两行的代码,更好的做法是根据你使用的 Mock 框架采用其对应的做法。比如使用 Mockito我们可以像下面这么写。
@ExtendWith(MockitoExtension.class)
public class TodoItemServiceTest {
@Mock
private TodoItemRepository repository;
}
不过@MockBean 并非一无是处,我们在集成测试中会用到它,让它参与到依赖注入的过程中去。下一讲,我们就来讨论一下如何使用 Spring 进行集成测试。
总结时刻
这一讲我们讲到了 Spring 这个 Java 世界使用最广泛的框架,它最大的贡献是对开发模式的颠覆:由原来 J2EE 依赖于部署的重量级开发模式,到可以在本地开发环境完成主要工作的轻量级开发方式。
轻量级的开发方式是 Spring 一以贯之的追求,采用 Spring 开发可以在部署到容器之前就完成所有代码的验证,其中对测试的支持是非常重要的一环。
虽然我们今天的主题是如何使用 Spring 进行单元测试,但实际上真正做好的业务测试和普通代码的测试是没有区别的,所以,我们更多地是在谈如何规避过度使用 Spring 框架犯下的错误。比如不要使用基于字段的注入,也不要依赖于 ApplicationContext 获取相应的依赖,这些做法都会让原本简单的测试变得复杂。
如果今天的内容你只能记住一件事,那请记住:业务代码不要过度依赖于框架。
思考题
今天我们的重点是错误使用了框架,你在实际的工作中,遇到过度使用框架特性,反而让代码陷入难以调整的困境吗?欢迎在留言区分享你的经验。

View File

@ -0,0 +1,214 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 在 Spring 项目如何进行集成测试?
你好,我是郑晔!
上一讲我们讲了 Spring 对轻量级开发的支持。不同于传统的开发方式Spring 希望可以做到开发不依赖于应用服务器。为了达成这个目标Spring 提供了各种支持,能够让你在部署到容器之前完成所有代码的基础验证工作。在核心业务部分,只要我们能够不过分依赖于 Spring 的种种特性,测试就和普通的单元测试差别不大。
不过在真实世界的软件开发中我们总要与其它的外部组件集成。一旦牵扯到集成测试的难度就上来了。不过正如前面所说Spring 要尽可能让你在不依赖于容器的情况下进行测试。Spring 的做法就是提供一套自己的方案,替代掉对于容器的依赖。
这一讲,我们就来看看采用 Spring 的项目如何做集成测试。
数据库的测试
今天数据库几乎成了所有商业项目的标配所以Spring 也提供了对于数据库测试很好的支持。我们之前说过,一个好的测试要有可重复性,这句话放到数据库上就是要保证测试之前的数据库和测试之后的数据库是一样的。怎么做到这一点呢?
测试配置
通常有两种做法,一种是采用嵌入式内存数据库,也就是在测试执行之后,内存中的数据一次丢掉。另一种做法就是采用真实的数据库,为了保证测试前后数据库是一致的,我们会采用事务回滚的方式,并不把数据真正地提交进数据库里。
我们做测试的一个关键点就是不能随意修改代码,切记,不能为了测试的需要而修改代码。如果真的要修改,也许应该修改的是设计,而不仅仅是代码。
虽然不能修改代码但我们可以提供不同的配置。只要我们给应用提供不同的数据库连接信息它就会连到不同的数据库上。Spring 就给了我们一个提供不同配置的机会,只要我们在测试中声明一个不同的属性配置即可,下面就是一个例子。
@ExtendWith(SpringExtension.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource("classpath:test.properties")
public class TodoItemRepositoryTest {
...
}
在这段代码里,我们提供了一个测试用的配置,也就是 @TestPropertySource 给出的一个配置。这是在用 classpath 上的 test.properties 这个文件中的配置,去替换掉我们缺省的配置(也就是我们真实的数据库)。
嵌入式内存数据库
正如我们前面所说,我们要保证数据库的可重复性有两种做法:嵌入式内存数据库和事务回滚。要想使用嵌入式内存数据库,我们需要提供一个嵌入式内存数据库的配置。在 Java 世界中,常见的嵌入式内存数据库有 H2、HSQLDB、Apache 的 Derby 等。我们配置一个测试的依赖就好,以 H2 为例,像下面这样。
testImplementation "com.h2database:h2:$h2Version"
然后,再提供一个相应的配置,像下面这样。
jdbc.driverClassName=org.h2.Driver
jdbc.url=jdbc:h2:mem:todo;DB_CLOSE_DELAY=-1
hibernate.dialect=org.hibernate.dialect.H2Dialect
hibernate.hbm2ddl.auto=create
如果运气好的话,你的测试就可以顺利地运行了。是的,运气好的话。
之所以把软件开发这么严肃认真的事归结到运气,这就不得不说说使用嵌入式内存数据库的问题了。
严格地说,这不是嵌入式内存数据库的问题,这其实是只要运行在不同的数据库上都会有的问题,也就是 SQL 的不一致。虽然我们知道 SQL 有一个统一的标准,然而,几乎每个数据库引擎为了某些特点都有一些特殊的处理。造成的结果就是,虽然理论上说 SQL 可以运行在所有的数据库引擎上,然而真实情况却是总有一部分 SQL 只能运行在特定的引擎上。
如果你用的是 JPA 这种技术,因为 JPA 会根据数据库引擎替我们生成真实的 SQL这个问题体现得还不是特别明显。但如果你用的 MyBatis 或者是其它需要手写 SQL 的技术,一旦发现了不能运行的 SQL你就不得不在此权衡一下如何去面对两个有差异的数据库。
所以,嵌入式内存数据库这种技术看上去很美,但我在实际的项目中用得并不多,我更多会采用事务回滚的方式。
事务回滚
在事务回滚的方式中,我们的配置几乎与标准的应用配置是一样的,下面是我们在实战中所采用的配置。
spring.datasource.url=jdbc:mysql://localhost:3306/todo_test?useUnicode=true&characterEncoding=utf-8&useSSL=false&allowPublicKeyRetrieval=true
spring.datasource.username=todo
spring.datasource.password=geektime
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
通常来说,为了不让测试过程和开发过程造成数据冲突,我们会创建两个不同的数据库,在 MySQL 中,这就是两条 SQL 语句。
create database todo_dev;
create database todo_test;
这样,一个用来做手工测试用,另外一个交由自动化测试使用,你从数据库后缀名上就可以看出二者的差异。顺便说一下,这种做法在业界的普遍流行是源自 Ruby on Rails一个 Ruby 的 Web 开发框架),当年它在软件开发实践上给整个行业带来了极大的颠覆。
采用这种做法,我们的代码面对的是同样的数据库引擎,也就不必担心 SQL 不兼容的问题了。
我们所说的事务回滚体现在 @DataJpaTest 上,它把数据库回滚做成缺省的配置,所以我们什么都不用做,就可以获得这样的能力。
与大多数测试一样,测试与数据库的集成时,我们也要做一些准备。需要准备的往往是一些数据,提前插入到数据库里。我们可以使用 Spring 给我们准备的基础设施TestEntityManager向数据库中完成这个工作下面是一个例子。
@ExtendWith(SpringExtension.class)
@DataJpaTest
public class ExampleRepositoryTests {
@Autowired
private TestEntityManager entityManager;
@Test
public void should_work() throws Exception {
this.entityManager.persist(new User("sboot", "1234"));
...
}
}
如果你用的不是 JPA 而是其它的数据访问方式Spring 也给我们提供了 @JdbcTest,这相当于是一个更基础的配置,因为只要有 DataSource 它就可以很好地工作起来,这适用于绝大多数的测试情况。相应地,数据工作也更加地直接,采用 SQL 就可以,下面是一个例子。
@JdbcTest
@Sql({"test-data.sql"})
class EmployeeDAOIntegrationTest {
@Autowired
private DataSource dataSource;
...
}
Web 接口测试
除了数据库,另外一个几乎成了今天标配的就是 Web。Spring 对于 Web 测试也提供了非常好的支持。
如果按照我在实战中的方式工作,你会发现到了编写 Web 接口这步,我们基本上完成了几乎所有的工作,只差给外界一个接口让它和我们的系统连接起来。在前面的实战中,我们采用整体集成的方式对系统进行测试,这里的关键点就是@SpringBootTest,它把所有的组件都连接了起来。
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
public class TodoItemResourceTest {
...
}
在讲集成测试的时候我曾经说过,集成测试分为两种,一种把所有代码都集成起来的测试,另外一种是针对外部组件的集成。从代码上来看,后一种测试只是针对一个单元在测试,所以它兼具单元测试和集成测试的特点。其实,测试 Web 接口也有一种类似于单元测试的集成方式,它采用的 @WebMvcTest
@WebMvcTest(TodoItemResource.class)
public class TodoItemResourceTest {
...
}
正如你在这段代码中看见的那样,这里我们指定了要测试的组件 TodoItemResource。在这个测试里它不会集成所有的组件只会集成与 TodoItemResource 相关的部分,但整个 Web 处理过程是完整的。
如果把它视为单元测试,服务层后面的代码都是外部的,我们可以采用模拟对象把它控制在可控范围内,这个时候,上一讲遗漏的 MockBean 就开始发挥作用了。
@WebMvcTest(TodoItemResource.class)
public class TodoItemResourceTest {
@MockBean
private TodoItemService service;
@Test
public void should_add_item() throws Exception {
when(service.addTodoItem(TodoParameter.of("foo"))).thenReturn(new TodoItem("foo"));
...
}
}
在这里,@MockBean 标记的 TodoItemService 模拟对象会参与到组件组装的过程中,成为 TodoItemResource 的组成部分,我们就可以设置它的行为。如果 Web 接口同服务层有比较复杂的交互,那这种做法就能够很好的处理。当然,正如我们一直在说的,我不建议这里做得过于复杂。
@WebMvcTest 这种偏向于单元测试的做法,执行速度相对于@SpringBootTest 这种集成了所有组件的做法而言要快一些。所以如果测试的量大起来,采用@WebMvcTest 会有一定的优势。
理解 Web 接口测试还有一个关键点。正如我在之前内容中说过,当年 Spring 摆脱了大部分对于应用服务器的依赖,但是 Web 却是它一直没有摆脱的。所以,怎么更好地不依赖于 Web 服务器进行测试,就是摆在 Spring 面前的问题。答案是 Spring 提供了模拟的 Web 环境。
具体到我们的测试上,它就是 MockMvc 对象发挥的作用。我们用下面的代码回顾一下它的用法。
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
public class TodoItemResourceTest {
@Autowired
private MockMvc mockMvc;
...
@Test
public void should_add_item() throws Exception {
String todoItem = "{ " +
"\"content\": \"foo\"" +
"}";
mockMvc.perform(MockMvcRequestBuilders.post("/todo-items")
.contentType(MediaType.APPLICATION_JSON)
.content(todoItem))
.andExpect(status().isCreated());
assertThat(repository.findAll()).anyMatch(item -> item.getContent().equals("foo"));
}
}
这里的关键是 @AutoConfigureMockMvc,它为我们配置好了 MockMvc剩下的就是我们使用这个配置好的环境进行访问。
从程序库的角度看MockMvc 可以理解成客户端的 Moco同样是设置请求和应答。和 Moco 不同的点在于,它的请求是设置好的,而应答要匹配。
从实现的角度理解,它就是那个模拟的 Web 环境。所谓模拟的环境是因为它根本没有启动真正的Web服务器而是直接去调用了我们的代码省略了请求在网络上走一遭的过程。但请求进到服务器之后的主要处理都在所以相应的处理都在无论是各种 Filter 的处理还是从请求体到请求对象的转换。现在你应该明白了MockMvc 是 Spring 轻量级开发的一个重要的组成部分。
到这里,我给你介绍了 Spring 集成测试中最常用到的两种:数据库测试和 Web 接口测试。这里介绍的也是推荐你去使用的做法。还有一些细节的做法我在这里没有提到,比如可以取消数据的回滚,再比如使用真实的 Web 环境(走网络的那种),不提是因为它们并不是值得推荐的做法。
正如我在最近两讲一直说的那样Spring 在支持轻量级开发上做了很大的努力所以在把整个系统集成起来之前绝大部分内容我们都已经验证过了。我在这里介绍的只是其中最为典型的用法Spring 的测试绝对是一个值得挖掘的宝藏,你可以阅读它的文档去发掘更多有趣的用法。
现在我们对怎样在真实项目中做好单元测试和集成测试已经有了一个基本的理解,但在实际的项目中,不同类型的测试该怎么配比呢?这就是我们下一讲要讨论的内容。
总结时刻
今天我们讨论了在 Spring 项目中怎么进行集成测试,主要讲解了如何做数据库和 Web 接口的集成测试。
做数据库测试,难点在于如何在测试之后恢复环境。有两种典型的做法:使用嵌入式内存数据库或是使用事务回滚的机制。无论是哪种做法,重点是给测试提供不同的配置,保证代码不变。
因为不同数据库引擎对 SQL 兼容程度不同,我更建议你使用事务回滚的做法。
Web接口测试通常是最外层的测试可以做整体的集成测试@SpingBootTest),或对一个单元进行测试的集成测试(@WebMvcTest)。
在Web接口测试中一个关键点是采用模拟 Web 环境,这样可以在不启动 Web 服务器的前提下进行测试。这种做法不依赖于部署过程,测试速度可以大幅度提升。
如果今天的内容你只能记住一件事,那请记住:采用轻量级的测试手段,保证代码的正确性。
思考题
今天我们讲了 Spring 对于集成测试的支持,希望你可以通过阅读文档,了解它的更多特性。如果你在阅读文档的过程中发现了哪些有趣的特性,欢迎在留言区分享你的所得。

View File

@ -0,0 +1,95 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 测试应该怎么配比?
你好,我是郑晔!
经过前面内容的讲解,相信你对在实际项目中如何编写单元测试和集成测试已经有了一个基本的认识。无论你是经验丰富的老程序员还是初入职场的新程序员,如果只是单独写几个测试,相信你都可以手到擒来。但真实的项目中我们不是要编写几个测试,而是要大批量地编写测试。
一旦编写的测试增多,你脑海里必然会出现一个疑问:有一些内容用单元测试覆盖可以,用集成测试覆盖也可以,如果只写单元测试总有些不放心,如果同时用单元测试和集成测试去覆盖,工作量似乎又会增大,不同的测试应该怎样配比呢?这就是我们这一讲要讨论的内容。
测试的特点
在讨论如何配比测试之前,我们需要先了解各种类型测试的特点,毕竟正是因为它们有着不同的特点,我们才需要对不同的测试按照不同的比例进行配比。
首先来看单元测试。单元测试是针对一个单元的测试,因为涉及面很小,所以单元测试要进行的设置会比较少。单元测试不牵扯到外部组件,一般而言只在内存中执行,执行速度很快。所以谈及单元测试的特点我们一般会说,它成本低、速度快、单个测试的覆盖面小,但整体覆盖面大。
再来看集成测试。相比于单元测试来说,集成测试的涉及面要广一些,设置起来就比较麻烦。有的集成测试还会集成外部组件,这也就意味着设置起来要更麻烦,比如你在上一讲见识过的数据库测试,就要准备各种配置信息。同时,无论是组件多还是集成外部组件,这都意味着执行速度要比单元测试慢。所以相比于单元测试,集成测试成本要高一些、速度要慢一点;单个测试的覆盖面要大一些,但整体覆盖面要小一些。
虽然我们主要讨论的是单元测试和集成测试,但实际上,还有一种测试有的团队也会做,就是系统测试(把整个系统集成起来进行测试)。
系统测试的设置会更加复杂,比如,为了让各种组件配合到一起,要配置各种信息。而执行系统测试,先要把系统启动起来,然后要走完整的执行路径,执行时间会更长。所以,系统测试的特点就是成本高、速度慢,但单个测试覆盖面大,整体覆盖面小。
顺便说一下,在实际的项目中,有时候我会用系统测试去验证系统组装的过程,保证改了配置或者调整了代码之后,系统依然能够正常启动。
前面说到的一些特点都是非常容易想到的。其实,如果把测试放到软件开发的生命周期中,我们还会发现一些特点。比如,单个系统测试覆盖面大,反过来看,覆盖面中任何一点出了问题,或是有调整,这个测试都会受到影响,所以,相对来说系统测试是脆弱的。而低层一些的测试因为覆盖面小,只有它覆盖到的代码有变化时它才会受到影响,相对而言,稳定度就要高一些。
再比如,一旦测试出错,需要定位具体的问题。使用系统测试定位问题就如同大海捞针,难度系数很大,而单元测试因为只有一个单元,定位问题就要容易许多。我把刚刚讨论的内容整合成了一个表格,你可以对照着再复习一下。
好,到这里,你已经对常见的测试特点有了一个了解,接下来,我们就来看看不同的测试配比模型。
测试配比模型
所谓不同的测试配比,其实就是什么样的测试多写一些。而决定什么样的测试多写一些,主要是不同人的不同出发点。有人认为一个测试应该尽可能覆盖面广一些,所以,要多写系统测试,有人认为测试应该考虑速度和成本,所以,要多写单元测试。
正是有不同的出发点,行业中有两种典型的测试配比模型,一种是冰淇淋蛋卷模型,一种是测试金字塔模型。
我们先来看冰淇淋蛋卷模型,如下图所示。
在这个图里,单元测试在最下面,表示它是底层的;然后层次逐渐升高,系统测试,也就是图上的端到端测试就是高层测试,在最上面。所有自动化测试形成了蛋卷部分,而外面的冰淇淋部分则是手工的测试。
这里面每一层的宽窄表示了测试数量的多少。从图中我们不难看出,它对测试配比的预期:少量的单元测试,大量的系统测试。
冰淇淋蛋卷的出发点就是从单个测试的覆盖面考虑的,只要一些系统测试,就足以覆盖系统的大部分情况。当然,对于那些系统测试无法覆盖的场景就需要有低层的测试配合,比如,集成测试和单元测试。在冰淇淋蛋卷模型里,主力就是高层测试,低层测试只是作为高层测试的补充。
了解了冰淇淋蛋卷模型,我们再来看测试金字塔,下面这张图表示的就是测试金字塔。
在表现形式上测试金字塔和冰淇淋蛋卷模型是一致的,都是下面表示低层测试,越往上测试的层次越高,而每一层的宽窄表示了测试数量的多少。
测试金字塔这个概念是 Mike Cohn 在自己的著作《Succeeding with Agile》中提出但大多数人都是通过 Martin Fowler 的文章知道的这个概念。从图的整体形状我们不难看出,测试金字塔同冰淇淋蛋卷正相反,它的重点是多写单元测试,而上层的测试数量则逐层递减。
测试金字塔的出发点是低层测试成本低、速度快、整体覆盖面广,所以要多写。因为低层测试覆盖了几乎所有的情况,高层的测试就可以只做一些大面上的覆盖,保证不同组件之间的协作是没有问题的。在这个模型里,主力是单元测试,而高层的测试则是作为补充。
好,有了对于测试配比模型的理解,接下来我们要回答的问题就是怎样使用这两个模型。
从行业的最佳实践角度看,测试金字塔已经是行业中的最佳实践。测试金字塔以单元测试为基础,因为成本低、速度快等特点,单元测试可以让我们在开发过程中迅速得到反馈。对于一个想要编写测试的团队而言,测试金字塔模型也是更容易坚持做到的。
实际上,我们在实战环节中采用的就是测试金字塔模型,也就是以单元测试为主,附以少量的集成测试或系统测试。所以,如果你准备开始一个新项目,最好采用测试金字塔模型,而具体的做法我们在实战环节中已经见识过了,那就是一层一层地写测试。每完成一个功能,代码和测试总是同步写出来的,代码总是得到验证的,这样我们就可以稳步向前。
既然测试金字塔都成为了行业的最佳实践,那我们为什么还要了解冰淇淋蛋卷模型呢?因为不是所有项目都是新项目。
因为各种历史原因,很多遗留项目是没有测试的。当项目发展了一段时间之后,团队开始关注产品质量,于是大家开始补测试。
在这种情况下,补测试是希望能够快速地建立起安全网,那必然是从系统测试入手来得快。只要写上一些高层测试,就能够覆盖到系统的大部分功能,属于“投资少见效快”的做法。这也是很多人喜欢冰淇淋蛋卷模型的重要原因。
但是,我们必须知道一点,在补测试的情况下,这么做是没问题的。如果我们把它当作开发的常态,那就有问题了。这就像治病和健身的关系一样,虽然去医院能在短时间内快速解决一定问题,但你不能没事就去医院,只有日常多运动,才能减少去医院的次数。
所以,对于冰淇淋蛋卷模型,我的建议是,它是遗留项目写测试的起点。在有了一个安全网的底线之后,我们还是要向测试金字塔方向前进,以单元测试作为整体的基础。新写的代码都是要按照测试金字塔的方式来组织测试,这才是一个可以持续的方向。具体如何在遗留系统上写测试,这是我们下一讲要讨论的主题。
总结时刻
今天我们讨论了各种不同的测试在项目中应该如何配比,因为从实用的角度上看,我们不太可能用各种类型的测试做所有代码的覆盖,这是一种浪费。
在决定如何配比各种类型的测试前,你首先要了解各种测试的特点。比如,单元测试速度快成本低,但覆盖面小;集成测试和系统测试覆盖面大,但速度慢成本高。
行业中目前有两种典型的测试模型:冰淇淋蛋卷和测试金字塔。二者对于测试的配比要求刚好相反,冰淇淋蛋卷要求多写高层测试,而测试金字塔则希望多写低层测试。
行业中的最佳实践是测试金字塔,这是每个新项目都应该做到的。对于遗留项目,我们可以在一开始的时候,先采用冰淇淋蛋卷建立基础的安全网,在有了最低保障之后,开始向测试金字塔方向努力。
如果今天的内容你只能记住一件事,那请记住:新项目采用测试金字塔,遗留项目从冰淇淋蛋卷出发。
思考题
你的团队写哪种测试比较多呢?你们是怎样考虑的呢?欢迎在留言区分享你的思考。

View File

@ -0,0 +1,217 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 怎么在遗留系统上写测试?
你好,我是郑晔!
迄今为止,我们讨论的话题主要是围绕着如何在一个新项目上写测试。但在真实世界中,很多人更有可能面对的是一个问题重重的遗留系统。相比于新项目,在一个遗留系统上,无论是写代码还是写测试,都是一件有难度的事。
在讨论如何在遗留系统上写测试前我们首先要弄清楚一件事什么样的系统算是遗留系统。在各种遗留系统的定义中Michael Feathers 在《修改代码的艺术》Working Effectively with Legacy Code中给出的定义让我印象最为深刻——遗留系统就是没有测试的系统。
根据这个定义你会发现,即便是新写出来的系统,因为没有测试,它就是遗留系统。由此可见测试同遗留系统之间关系之密切。想要让一个遗留系统转变成为一个正常的系统,关键点就是写测试。
给遗留系统写测试
众所周知,给遗留系统写测试是一件很困难的事情。但你有没有想过,为什么给遗留系统写测试很困难呢?
如果代码都写得设计合理、结构清晰,即便是补测试也困难不到哪去。但大部分情况下,我们面对的遗留系统都是代码冗长、耦合紧密。你会不会一想到给遗留系统写测试就头皮发麻?因为实在是太麻烦了。由此我们知道,给遗留系统写测试,难点不在于测试,而在于它的代码。
如果不能了解到一个系统应该长成什么样子,我们即便努力做到了局部的一些改进,系统也会很快地退化成原来的样子。这也是为什么我们学习写测试要从一个新项目开始,因为我希望你对一个正常系统的样子有个认知,写测试不只是写测试的事,更是写代码的事。
在遗留系统上写测试,本质上就是一个系统走向正常的过程。对于一个系统来说,一旦能够正常运行,最好的办法就是不动它,即便是要给它写测试。但在真实世界中,一个有生命力的系统总会有一些让我们不得不去动它的理由,可能是增加新特性,可能是要修改一个 Bug也可能是要做系统的优化。
我们不会一上来就给系统完整地添加测试,这也几乎是不可能完成的任务。所以,本着实用的态度,我们的做法是,动到哪里,给哪里写测试。
要动哪里,很大程度上就是取决于我们对既有代码库的理解。不过,既然是遗留代码,可能出现的问题是,你不一定理解你要修改的这段代码究竟是怎么起作用的。最有效的解决办法当然是找懂这段代码的人请教一番,但如果你的代码库生命周期够长,很有可能已经没有人知道这段代码是怎么来的了。在如今这个时代里,摸黑看代码时,我们可以使用 IDE 提供的重构能力,比如提取方法,将大方法进行拆分,这样有助于降低难度。
至于给哪里写测试,最直观的做法当然是编写最外层的系统测试。这种做法可行,但正如我们在上一讲所说,越是外层的测试,编写的成本越高,执行速度越慢。虽然覆盖面会广一些,但具体到我们这里要修改代码而言,存在一种可能就是控制得不够准确。换言之,很有可能我们写了一个测试,但是我们改不改代码,对这个测试影响不大。所以,只要有可能,我们还是要努力地降低测试的层次,更精准地写测试。也就是能写集成测试,就不写系统测试;能写单元测试,就不写集成测试。
或许你会说,我也知道能写单元测试很好,但通常遗留系统最大的问题就在于单元测试不好写。造成测试不好写的难点就是耦合,无论是代码与外部系统之间的耦合,还是代码与第三方程序库的耦合,抑或是因为代码写得不好,自己的代码就揉成了一团。所以,想在遗留系统中写好测试,一个关键点就是解耦。
一个解耦的例子
我们在专栏前面中讲过,测试的关键就在于构建一个可控的环境。对于编写单元测试来说,可控环境很关键的一步就是使用模拟对象,也就是基于 Mock 框架生成的对象。
同样,在遗留系统上如果想要编写单元测试,模拟对象也很关键。换言之,我们要给一个类编写单元测试,首先要把它周边的组件由模拟对象替换掉,让它有一个可控的环境。说起来很简单,但面对遗留系统时,想要用模拟对象替换掉真实对象就不是一件轻松的事。
下面我们就用一个例子看看如何在一个遗留系统上进行解耦,然后又是如何给代码写测试。我们有一个订单服务,完成了下单过程之后,要发出一个通知消息给到 Kafka以便通知下游的服务。
public class OrderService {
private KafkaProducer producer;
public void placeOrder(final OrderParameter parameter) {
...
this.producer.send(
new ProducerRecord<String, String>("order-topic", DEFAULT_PARTITION, Integer.toString(orderId.getId()))
);
}
}
很显然,这段代码我们直接依赖了 KafkaProducer这是 Kafka 提供的 API如果要想测试 OrderService 这个类,我们就需要把 Kafka 加到这个测试里,而我们的测试重点是下单的过程,这个过程本身同 Kafka 没有关系。要测试这个类,我们必须把 Kafka 从我们的代码中解耦开。
首先我们用提取方法Extract Method这个重构手法把 Kafka 相关的代码调用封装起来,通过使用 IDE 的重构功能就可以完成。
public class OrderService {
private KafkaProducer producer;
public void placeOrder(final OrderParameter parameter) {
...
send(orderId);
}
private void send(final OrderId orderId) {
this.producer.send(
new ProducerRecord<String, String>("order-topic", DEFAULT_PARTITION, Integer.toString(orderId.getId()))
);
}
}
接下来,我们要把 KafkaProducer 与我们的业务代码分离开。正如我们在之前讨论的内容所说,我们需要有一个封装层,把对第三方程序库的访问封装进去。所以,我们在这里引入一个新的类承担这个封装层的作用。我们可以使用**提取委托Extract Delegate创建出一个新的类提取的时候我们还要选上生成访问器Generate Accessors**的选项,它会为我们生成对应的 Getter。
public class KafkaSender {
private KafkaProducer producer;
public KafkaProducer getProducer() {
return producer;
}
...
}
而 OrderService 的 send 方法就变成了下面的样子。
class OrderService {
...
private void send(final OrderId orderId) {
this.kafkaSender.getProducer().send(
new ProducerRecord<String, String>("order-topic", DEFAULT_PARTITION, Integer.toString(orderId.getId()))
);
}
}
很显然,从当前的实现看,它只与 KafkaSender 相关接下来我们可以使用搬移实例方法Move Instance Method把它搬移到 KafkaSender 中。
class KafkaSender {
...
public void send(final OrderId orderId, OrderService orderService) {
getProducer().send(
new ProducerRecord<String, String>("order-topic", DEFAULT_PARTITION, Integer.toString(orderId.getId()))
);
}
}
class OrderService {
...
public void placeOrder(final OrderParameter parameter) {
...
kafkaSender.send(orderId, this);
}
}
从代码上我们可以看到,虽然 KafkaSender 的 send 方法有 OrderService 这个参数但是我们并没有用它可以安全地删除它Safe Delete这也是一个快捷键就可以完成的工作。还有这里用到 getProducer 方法,因为我们在 KafkaSender 这个类里面了,所以,我们就不需要通过 Getter 访问了可以通过内联方法Inline Method将它去掉。
class KafkaSender {
...
public void send(final OrderId orderId) {
producer.send(
new ProducerRecord<String, String>("order-topic", DEFAULT_PARTITION, Integer.toString(orderId.getId()))
);
}
}
class OrderService {
...
public void placeOrder(final OrderParameter parameter) {
...
kafkaSender.send(orderId);
}
}
到这里我们的业务代码OrderService已经不再依赖于 KafkaProducer 这个第三方的代码而是依赖于我们自己的封装层这已经是一个进步了。不过从软件设计上讲KafkaSender 是一个具体的实现,它不应该出现在业务代码中。所以,我们还需要再进一步,提取出一个接口,让我们的业务类不依赖于具体的实现。回到代码上,我们可以在 KafkaSender 这个类上执行提取接口Extract Interface这个重构动作创建出一个新的接口。
public interface Sender {
void send(OrderId orderId);
}
public class KafkaSender implements Sender {
@Override
public void send(final OrderId orderId) {
producer.send(
new ProducerRecord<String, String>("order-topic", DEFAULT_PARTITION, Integer.toString(orderId.getId()))
);
}
}
public class OrderService {
private final Sender sender;
public OrderService(Sender sender) {
this.sender = sender;
}
...
}
经过这番改造OrderService 这个业务类已经与具体的实现完全无关了。我们就可以用模拟对象模拟出 sender用完全可控的方式给这个类添加测试了。
class OrderServiceTest {
private OrderService service;
private Sender sender;
@BeforeEach
public void setUp() {
this.sender = mock(Sender.class);
this.service = new OrderService(this.sender);
}
...
}
到这里,你或许会有一个疑问,我在这里改动了这么多的代码,真的没问题吗?如果这些代码是我们手工修改,这确实是个问题。不过,现在借助 IDE 的重构功能,我们并没有手工修改任何代码,相比于过去,这也是今天做遗留系统调整的优势所在。由此可见,理解重构,尤其是借助 IDE 的重构功能,是我们更好地去做遗留系统调整的基础。否则,我们必须先构建更外层的测试,无论是系统测试还是人工测试。
现在我们来回顾一下前面做了些什么。首先,我们有一个大目标:为了能够有效地测试,我们需要把具体实现和业务解耦开。在前面的例子中,主要就是要把 KafkaProducer 从业务类中分开。
把具体实现的代码从业务实现中隔离开我们采用的手法是提取方法这一步是为了后面把具体实现从业务类中挪出去做准备。通过引入一个封装类KafkaSender我们将具体的实现KafkaProducer从业务类中挪了出去。
到这里,我们的业务类已经完全依赖自己编写的代码。不过,这个封装类还是特定于具体的实现,让业务依赖于一个具体实现在设计上也是不恰当的。所以,我们这里再进一步,提取出一个接口。
从软件设计的角度看,这个提取出来的接口就是这个设计中缺失的一个模型,所以,提取这个接口不是画蛇添足,而恰恰是补齐了之前在设计上的欠缺。
换个角度看,模拟对象模拟的是接口行为,而很多遗留代码只有具体的类,而没有接口。虽然有些具体类也是可以模拟的,但出于统一原则的考虑,我们应该针对所有具体类提取一个接口出来,而让原来的类成为实现这个接口的一个实现类。有了接口,我们也就可以使用模拟对象,做行为可控的测试了。
这一系列的做法非常有用比如业务代码中调用了static方法它在测试中也不好模拟。我们也可以通过提取方法把它隔离出来然后把它挪到一个封装类里面引入一个新的接口让一段无法模拟的代码变得可以模拟。如果你真的能够理解这种做法已经可以消灭掉很多设计不好的代码了。
当然这里没有涵盖在遗留系统上写测试的各种做法但你已经掌握了最精髓的部分先隔离再分离。如果你有兴趣了解更多的做法推荐一本书给你就是前面提到的《修改代码的艺术》Working Effectively with Legacy Code。虽然它是一本介绍处理遗留代码的书在我看来它更是一本教人如何写测试的书。
总结时刻
今天我们谈到了在遗留系统上写测试。遗留系统就是那些没有测试的系统,给遗留系统写测试就是让一个系统恢复正常的过程。
在遗留系统上做改进,关键是要知道改进成什么样子。在一个遗留系统上写测试,不仅是写测试,还会牵扯到写代码。
完整地给一个遗留系统写测试是比较困难的。一个实用的改进策略是,动到哪里,改哪里。具体如何写测试,最好是测试的层次越低越好,但低层次的测试就会涉及代码耦合的问题,而这里就需要我们对代码进行解耦。
解耦,主要是把业务代码和具体实现分开。通过提取方法,把一段耦合紧密的代码隔离开,再创建一个新的封装类把它挪进去。如果代码里有很多具体类,我们还可以通过引入接口进行解耦。这里面的关键是利用 IDE 给我们提供的重构功能,减少手工改代码的操作。
如果今天的内容你只能记住一件事,那请记住:改造遗留系统的关键是解耦。
思考题
你有遗留系统改造的经验吗?你是怎么保证改造的正确性的呢?欢迎在留言区分享你的经验。

View File

@ -0,0 +1,96 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 TDD 就是先写测试后写代码吗?
你好,我是郑晔!
到这里,我已经给你介绍了在真实项目中做好测试需要的基础知识。写测试远远不是用 xUnit 框架写代码就能做好的一件事,只有在工作方式、软件设计、编写代码、测试理念等方方面面都做好,我们才能做好测试。不过随之而来的是,我们有了一个强大的测试基础,这可以让我们放心大胆地不断向前,因为我们已经进入到编写高质量代码的正向循环之中。越写测试越安心,越安心也就越有时间编写高质量的代码。
有了这些基础,应对日常工作已经绰绰有余。不过在行业里总有人在探索着更好的做法,所以在最后的扩展篇,我将给你介绍 TDD 和 BDD 两项实践。在你学有余力的情况下,可以挑战一下,让自己再向前走一步。这一讲,我们先来说说 TDD也就是测试驱动开发Test Drvien Development
TDD 的节奏
或许你已经迫不及待地要举手了“TDD 我知道就是先写测试后写代码。”但真的是这样吗严格地说“先写测试、后写代码”的做法叫测试先行开发Test First Development而不是测试驱动开发。
测试驱动开发不也是先写测试后写代码吗?二者之间有什么区别呢?
要回答这个问题,我们需要知道 TDD 的一个关键要素TDD 的节奏:红-绿-重构。
红表示写了一个新的测试,测试还没有通过的状态;绿表示写了功能代码,测试通过的状态;而重构就是在完成基本功能之后,调整代码的过程。
这里说到的红和绿源自单元测试框架。因为在很多单元测试框架运行测试的过程中,测试不过时会用红色展示测试结果,而通过时则采用绿色进行展示,这已经成了单元测试框架约定俗成的规则。
在前面内容中我们说过,让单元测试框架流行起来的是 JUnit其作者之一是 Kent Beck。TDD 走进大众视野则依赖于极限编程这个软件工程方法论的兴起,而极限编程的创始人也是 Kent Beck。Kent Beck 在 JUnit 和 TDD 两件事都有着重大贡献,也就不难理解为什么 TDD 的节奏叫“红-绿-重构”了。
先写测试然后写代码完成功能在第一步和第二步上测试先行开发和测试驱动开发是一样的。二者的差别在于测试驱动开发并没有就此打住它还有一个更重要的环节重构refactoring
也就是说,在功能完成而且测试跑通之后,我们还会再次回到代码上,处理一下代码中写得不理想的地方,或是消除新增代码与旧有代码之间的重复。你或许会问,那为啥不在第二步“绿”的时候就把代码写好呢?因为“绿”的关注点只是让测试通过,把功能完成。
所以我们说,测试先行开发和测试驱动开发的差异就在重构上。
很多人只记住了“先写测试后写代码”因为在很多人的印象中写代码唯一重要的事就是完成功能。通过了测试就是完成了功能也就意味着万事大吉了。然而这种想问题的方式会让人忽略新增代码可能带来的坏味道Code Smell坏味道会让代码逐渐腐坏这是一个工程问题也就是会有长期影响的问题。
人的注意力是有限的让人在一个阶段把所有事情都做好很难。事实上我们会看到很多团队代码变乱的一个重要原因就是把全部的注意力都放到完成功能上根本无暇顾及代码本身的质量。从这个角度上看TDD 是更符合人性的做法,它把完成功能和代码调整当成了两个阶段。
重构就是一个消除代码坏味道的过程。一旦你有了测试,你就可以大胆地重构了,因为任何修改错误,测试都会替你捕获到。
在测试驱动开发中,重构与测试是相辅相成的:没有测试,修改代码只能是提心吊胆;没有重构,代码的混乱程度会逐步增加,测试也会变得越来越不好写。
现在,你已经理解了测试驱动开发不只是“先写测试,后写代码”。但这只是破除了概念上的误区,我们还需要再进一步,知道测试怎么“驱动”开发。
测试“驱动”开发
不难理解,重构和测试相互配合,这个过程就会“驱动”着我们把代码写得越来越好。不过,这只是对“驱动”一词最粗浅的理解。
首先,我来问你一个问题,测试驱动开发,从哪里开始呢?很多人会说,测试驱动开发不是从测试开始的吗?这个答案非常直观,我们可以接着追问下去,写测试要从哪里开始呢?
对很多人来说TDD 是一种难以接受的做法,抛开理念上的差异,更重要的原因是,写测试无从下手。学习过我们这个专栏你会发现,很多时候写不出测试,主要是面对的需求太大了。所以,真正动手做开发的第一步是任务分解,把一个规模很大的需求拆分成若干小任务。面对一个具体的小任务,我们才有动手写测试的基础。测试驱动开发要从任务分解开始。
具体到了写测试的环节,即便面对的是一个小任务,对很多人来说,这依然不是一件容易完成的事。同样,我们在前面分析过,想要写出测试,需要有可测试的代码。这意味着,我们的代码需要有一个可测试的设计。如果不能写测试,我们就要调整代码,让代码变得可以测试,这是我们上一讲中谈遗留系统测试所讲的内容。
从这里你可以看出,从测试出发考虑问题的这种思考方式,会彻底颠覆掉我们原有的工作习惯,甚至是为了测试调整设计。但结果是我们得到了一个更好的设计,所以,很多懂 TDD 的人会把 TDD 解释为测试驱动设计Test Driven Design
现在你可以理解了,为了写测试,首先“驱动”着我们把需求分解成一个一个的任务,然后会“驱动”着我们给出一个可测试的设计,而在具体的写代码阶段,又会“驱动”着我们不断改进写出来的代码。把这些内容结合起来看,我们真的是在用测试“驱动”着开发。
TDD 这么好,为什么行业里采用 TDD 这种工作方式的人并不多呢?首先,很多人本身对 TDD 的理解是错误的这是我在前面分析过的其次TDD 看似简单的节奏中其实需要很多前置的基础比如任务分解、可测试的设计等等而这些能力是很多人不具备的。换个角度看TDD 只是冰山一角,露在海面之上的是 TDD 的节奏,而藏在海面下的是任务分解、软件设计这些需要一定时间积累的能力。
我们这个专栏介绍TDD的方式相比于传统介绍 TDD 的方式,还是挺不一样的。学过我们这个专栏之后,你其实已经具备了 TDD 的基础。因为在前面部分的介绍中,我已经把这些基础能力给你串讲过了。到了这里,你只需要知道 TDD 的节奏是怎样的,如果想尝试 TDD那么按照 TDD 的节奏练习一段时间,你就知道 TDD 是怎么回事了。
最后,再给你补充一个知识点。前面说过 TDD 是来自极限编程,那极限编程为什么要叫极限编程呢?
极限编程之所以叫“极限”,它背后的理念就是把好的实践推向极限:
如果集成是好的,我们就尽早集成,推向极限就是每一次修改都集成,这就是持续集成。
如果程序员写测试是好的,我们就尽早测试,推向极限就是先写测试,再根据测试调整代码,这就是测试驱动开发。
如果代码评审是好的,我们就多做评审,推向极限就是随时随地代码评审,这就是结对编程。
如果客户交流是好的,我们就和客户多交流,推向极限就是客户与开发团队时时刻刻在一起,这就是现场客户。
极限编程本身的实践值得我们好好学习,但极限编程背后这种理念其实也非常值得我们学习。我们在日常工作中也不妨多想想,有哪些做法是好的,如果把它推向极致会是什么样子。这种想问题的方式会在很大程度上拓宽你的思路。
总结时刻
今天我们讲了 TDD也就是测试驱动开发。我们在专栏前面的内容中学习的是在代码层面上如何写测试而 TDD 这种实践探索的是在开发过程中如何写测试。
测试驱动开发已经是行业中的优秀实践,学习测试驱动开发的第一步是记住测试驱动开发的节奏:红——绿——重构。
知道了 TDD 的节奏之后,我们还需要理解测试怎样驱动开发。在 TDD 的过程中,我们要先进行任务分解,把大需求拆成小任务,然后考虑代码的可测试性,编写出整洁的代码,这一切都是在“测试”驱动下产生的。
正是因为视角的转变,为了编写可测的代码,我们甚至要为此调整设计,所以,有人也把 TDD 称为测试驱动设计。
无论你是否采用 TDD 的实践,在动手写代码之前,从测试的角度进行思考都是非常有价值的一件事,这也是编写高质量代码的重要一环。
如果今天的内容你只能记住一件事,那请记住:从测试的视角出发看待代码。
思考题
你尝试过 TDD 吗?如果没有,不妨试试,然后在留言区分享一下你采用 TDD 进行开发的感受。

View File

@ -0,0 +1,176 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 BDD 是什么东西?
你好,我是郑晔!
在扩展篇中,我们要讨论的是在不同方向上的写测试探索。在上一讲里,我给你介绍了 TDD。TDD 是在写测试的时机上进行了不同的探索。这一讲我们再来讲另一个实践——BDD它是在写测试的表达方式上进行的不同探索。
我们都知道,在软件开发中最重要的一个概念就是分层,也就是在一些模型的基础上,继续构建新的一些模型。程序员最耳熟能详的分层概念就是网络的七层模型,只要一层模型成熟了,就会有人基于这个模型做延伸的思考,这样的做法在测试上也不例外。
当 JUnit 带来的自动化测试框架风潮迅速席卷了整个开发者社区,成了行业的事实标准,就开始有人基于测试框架的模型进行延伸了。各种探索中,最有影响力的就是 BDD。
行为驱动开发
BDD 的全称是 Behavior Driven Development也就是行为驱动开发。BDD 这个概念是2003年由 Dan North 提出来的。
单元测试框架写测试的方式更多的是面向具体的实现这种做法的层次是很低的BDD 希望把这个思考的层次拉高。拉到什么程度呢软件变化的源动力在业务需求上所以最好是能够到业务上而校验业务的正确与否的就是业务行为。这种想法很大程度上是受到当时刚刚兴起的领域驱动设计Domain Driven Design中通用语言的影响。在 BDD 的话语体系中,“测试”的概念就由“行为”所代替,所以,这种做法称之为行为驱动开发。
Dan North 不仅仅提出了概念,而且为了践行他的想法,他还创造了第一个 BDD 的框架JBehave。后来又改写出基于 Ruby 的版本 RBehave这个项目后来被并到 RSpec 中。
好,了解了 BDD 的由来,接下来,我们就来看看采用 BDD 的方式进行开发,测试会写成什么样子。
今天最流行的 BDD 框架应该是 Cucumber它的作者就是 RSpec 的作者之一 Aslak Hellesøy。从最开始基于 Ruby 的 BDD 框架发展成今天Cucumber 已经变成了支持很多不同程序设计语言的 BDD 测试框架,比如常见的 Java、JavaScript、PHP 等等。
下面是一个 BDD 的示例,其场景就是我们前面实战的内容。
Scenario: List todo item
Given todo item "foo" is added
And todo item "bar" is added
When list todo items
Then todo item "foo" should be contained
And todo item "bar" should be contained
从这个例子我们不难看出BDD 的测试用例有很强的可读性。即便我们不熟悉技术,单凭这段文字,我们也能看出这个用例想表达的含义。这也就是我们前面说 BDD 测试用例更贴近业务的原因。它希望成为业务人员和技术团队之间沟通的桥梁,所以,它的表述方式更贴近于业务。
虽然这个表述已经很贴近业务了但它并不是自然语言描述而是有一种特定的格式其实这是一门领域特定语言Domain Specific Language简称 DSL称之为 Gherkin。
不要看到一门新的语言就被吓退其实它非常简单。这里的核心点就是它的描述格式“Given…When…Then”。Given 表示一个假设前提When 表示具体的操作Then 则对应着这个用例要验证的结果。
我们在第 5 讲谈到测试的结构时说过测试一般包含四个阶段准备、执行、断言和清理。把它对应到这里Given 对应着准备When 对应执行,而 Then 对应断言。至于清理,这个阶段会做一些资源释放的工作,不过这个工作属于实现层面的内容,在业务层面上意义不大,所以在以业务描述为主要目标的 BDD 中,这个阶段是不存在的。
了解了格式我们再来关注具体的内容。首先这里描述的行为都是站在业务的角度进行叙述的。其次Given、When、Then 都是独立的,可以自由组合。这也就意味着,一旦基础框架搭好了,有人就可以使用这些基础语句来编写新的测试用例,甚至可以不需要技术人员参与。
从这里我们不难看出Gherkin 语言本身有一个很好的目标,与其说它是为了技术人员设计的,不如说它是为了业务人员设计的。
Gherkin 语言这层只提供了业务描述作为程序员我们很清楚这层描述并不能直接发挥作用必须要有一个具体的实现。那具体的实现要放在哪里呢这就轮到胶水层Glue发挥作用了这个将测试用例与实现联系起来的胶水层在 Cucumber 的术语里称之为步骤定义Step Definition下面就是一个步骤定义的示例。
public class TodoItemStepDefinitions ... {
private RestTemplate restTemplate;
public TodoItemStepDefinitions() {
...
Given("todo item {string} is added", (String content) ->
addTodoItem(content)
);
...
}
private void addTodoItem(final String content) {
AddTodoItemRequest request = new AddTodoItemRequest(content);
final ResponseEntity<String> entity =
restTemplate.postForEntity("http://localhost:8080/todo-items", request, String.class);
...
}
}
既然步骤定义是 Gherkin 文件与具体实现之间的胶水所以理解步骤定义的关键就是知道它是如何将二者关联起来的。在这段代码中Given 就是这样的连接点。对比一下我们就会发现, Given 里面的参数就是我们在前面 Gherkin 文件中的描述,不同的点是,这里把其中的一部分变成了参数。由此我们可以知道,对于同样一个描述,可以根据用例的差异,采用不同的参数。
如果说 Gherkin 语言部分几乎在各种 BDD 框架之间是通用的,那步骤定义部分则是框架强相关。这里我们采用 Cucumber Java 8 的方式进行了步骤定义,也就是采用 Given 方法进行定义,如果你去看其它的资料,也会看到基于 Annotation 的定义,这就是选择不同依赖程序库的结果。
到了具体的实现上,程序员就很有底气了。在这里我们根据业务动作进行相应的处理。在上面这段代码中,添加 Todo 项就是向自己编写的服务发出了一个 POST 请求。
这些东西理解起来都很容易,唯一需要稍微注意一点的是,给 Then 编写代码时,因为它是表示断言的,在这个部分我们一定要写出断言,比如像下面这样。
Then("todo item {string} should be contained", (String content) -> {
assertThat(Arrays.stream(responses)
.anyMatch(item -> item.getContent().equals(content))).isTrue();
});
上面这段代码的更多细节实现,你可以去参考我们的实战项目。
实战中的 BDD
现在我们已经有了对 BDD 的初步了解,接下来,我们就来看看在实际的项目中可以怎样使用 BDD。
前面我们已经知道了Gherkin 语言是面向业务人员的。不同于写代码我们只能用英文Gherkin 在设计时就考虑到了业务人员的实际需要,所以它的设计本身是本地化的。我们甚至可以用中文编写测试用例,下面就是一个登录的测试用例。
假定 张三是一个注册用户,其用户名密码是分别是 zhangsan 和 zspassword
当 在用户名输入框里输入 zhangsan在密码输入框里输入 zspassword
并且 点击登录
那么 张三将登录成功
这个用例怎么样呢或许你会说这个用例写得挺好。如果你这么想说明你是站在程序员的视角。我在前面已经说过了BDD 需要站在业务的角度,而这个例子完全是站在实现的角度。如果登录方式有所调整,用户输完用户名密码自动登录,不需要点击,那这个用例是不是需要改呢?下面我换了一种方式描述,你再感受一下。
假定 张三是一个注册用户,其用户名密码是分别是 zhangsan 和 zspassword
当 用户以用户名 zhangsan 和密码 zspassword 登录
那么 张三将登录成功
这是一个站在业务视角的描述,除非做业务的调整,不用用户名密码登录了,否则这个用例不需要改变。即便实现的具体方式调整了,需要改变的也是具体的步骤定义。所以,想写好 BDD 的测试用例,关键点在用业务视角描述。
既然 BDD 的用例更多偏向业务视角,所以在真实的项目中使用它时,我们更多偏向于把它当做验收测试的工具来用。这里就会有一个我们常常忽略的点:业务测试的模型。很多人的第一直觉是,一个测试要啥模型?
既然 BDD 更多的使用场景是复杂的验收场景,所以,相应地我们也要为测试场景进行建模。还记得我们讲好测试应该具备的属性吗?其中一点就是专业性。对于复杂场景而言,想要写好测试同写好代码是一样的,一个好的模型是不可或缺的。
这方面一个可以作为参考的例子是做 Web 测试常用的一个模型Page Object。它把对页面的访问封装了起来即便你在写的是步骤定义你也不应该在代码中直接操作 HTML 元素,而是应该访问不同的页面对象。
以前面的登录为例,我们可能会定义这样的页面对象。
public class LoginPage {
public boolean login(String name, String password) {
...
}
}
如此一来,在步骤定义中,你就不必关心具体怎么定位到输入框会让代码的抽象程度得到提升。当然这只是一个参考,面对你自己的应用时,你要考虑构建自己的业务测试模型。
BDD 的延伸
最后,我们再来说说 BDD 的一些延伸。从上面的内容我们可以知道BDD 的用例和普通测试的用例只是在表述方式上有所差异,从结构上看,二者几乎是完全等价的。所以,只要你想,完全可以采用 BDD 的方式进行从单元测试到系统测试所有类型的测试。
所以我们会看到,在行业里还有一些 BDD 风格的单元测试框架,其中最典型的就是 RSpec。我从 RSpec 的文档上截取了一段代码,你可以感受一下。
RSpec.describe Order do
it "sums the prices of its line items" do
order = Order.new
order.add_entry(LineItem.new(:item => Item.new(
:price => Money.new(1.11, :USD)
)))
order.add_entry(LineItem.new(:item => Item.new(
:price => Money.new(2.22, :USD),
:quantity => 2
)))
expect(order.total).to eq(Money.new(5.55, :USD))
end
end
其实,它与前面的 Cucumber 用例还是有很大差异的,因为它属于单元测试的范畴,所以没有像 Gherkin 部分那种面向于业务人员的描述。但同时你也能看到,它同传统的 xUnit 框架有着很大的不同,主要是框架本身会引导你写出更具描述性的代码。
BDD 的另外一个延伸方向是对需求进行文档化的表述。既然 BDD 是在朝着业务方向靠近争取让业务人员能够很好地理解这些测试用例那从本质上来说它就起到了文档的作用这个文档和真实实现是紧密相关的是一种“活”文档Living Document。活文档指的是持续更新的文档这个概念本身不局限于技术领域。Cucumber 本身有对活文档的支持,它可以与 JIRA 去集成,可以直接把 Cucumber 测试用例变成文档。
既然要写文档,那就不局限于是否采用 BDD 这样的格式,所以,还出现了像 Concordion 这样的工具,甚至可以让我们把验收用例写成一个完整的参考文档。最开始它支持用 HTML 的方式写文档,现在也支持用 Markdown 的方式来编写文档。
无论是 BDD 也好活文档也罢它们背后还有一个概念叫做实例化需求Specification by ExampleSbE也就是用实例的方式对需求进行阐述你可以看到 BDD 和活文档就是通过这种方式在将需求表现出来。
总之,如果你对这个方向有兴趣,前面还是有很多东西可以探索。总的来说,它就是让技术团队不再局限于技术本身,更加贴近业务,这和整个行业的发展趋势是高度吻合的。
总结时刻
这一讲,我们讲了 BDD也就是行为驱动开发。这种思想是站在 xUnit 的框架基础之上,让测试用例的表达更贴近业务行为。
我用 Cucumber 这个今天最为流行的 BDD 框架给你介绍了如何编写测试用例,你只要记住 “Given…When…Then” 的格式,就算抓住了 Gherkin 语言表述的核心。
在实际的项目中使用 BDD 我们可以采用本地化的表述方式不过重点是要让测试用例贴近业务而非实现细节。一般来说BDD 多用于验收测试,所以相应地,我们在编写步骤定义时,对于复杂业务可以考虑构建业务测试模型,对实现细节进行封装。
最后,我们还谈到了 BDD 的延伸,无论是 BDD 风格的单元测试框架,还是活文档、实例化需求,这些都是你可以进一步探索的东西。
如果今天的内容你只能记住一件事,那请记住:技术团队要更加贴近业务。
思考题
今天我们讨论的 BDD 更多是用在验收测试中的,你的团队是怎么做验收测试呢?欢迎在留言区分享你的经验。

View File

@ -0,0 +1,101 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
答疑解惑 那些东西怎么测?
你好,我是郑晔!
到现在,《程序员的测试课》的正文内容我已经全部交付给你了。在专栏上线这段时间里,感谢你的一路相伴。有不少同学在专栏的问了一些非常典型的问题,引发了我的一些思考。所以,我准备了这期加餐,把我的这些延伸思考分享给你。
实战项目
这个专栏是我第一次用完整实战的方式给你演示如何做一个项目。秉承我写专栏一贯的特点,我写实战的重点是做事情的思路,而非具体的源码。
程序设计语言
不过只要涉及具体的代码就会有各种问题产生。最典型的问题就是在实战中我采用了Java有一些擅长其他语言的同学会问到我能不能提供其他语言的版本。
首先我必须向这些同学说声抱歉因为时间和能力有限我没法提供各种程序设计语言的实现版本。这个ToDo 应用原本是《代码之丑》中的练习题,在那一讲里,同学们给出了很多不同的实现,有各种语言的版本,如果你有兴趣不妨去看看留言区里其他同学是怎么实现的。
当然,这个问题本身并不复杂,最好是你自己实现一遍,然后再对比我的实现过程,看看有哪些有差异的地方。编程这件事,讲道理远远不如动手实践来得感受更深刻。至于我写的代码本身,重要性没有那么强,只是一个参考。
之所以采用 Java 语言一方面是因为它受众极广另一方面更重要的是Java 语言在工程上的能力非常好。无论是各种工具和框架还是工程实践上Java 社区往往是走在整个行业的前列。
写这个专栏的时候,我已经尝试尽量降低理解的难度,把解决问题的过程和使用的工具尽量做到通用,尤其是前两讲的中用到的。即便你用的是其他程序设计语言,也可以找到对应的解决方案,比如命令行解析的框架、覆盖率检查的工具、构建工具等等。我们学习任何一门语言,要学的内容都不应局限于语言本身,还应该学习这个语言相关的生态。所以,如果你使用的是其它语言,却不知道我提到的这些东西,不妨借着这个机会,给自己补充一下。
不过,像 Spring 这种框架不是每个语言都有的严格地说Spring 已经超过了框架的范畴,它已经是一个完整的生态了。
关于程序设计语言,我一贯的建议就是多学几门,千万别局限在某一门语言里。所以,如果你擅长的是其它语言,可以借这个机会了解一下 Java 程序是怎么写的。
程序库
除了程序设计语言,还有一类问题是关于其它程序库的,也就是借着实战的项目做了一下延展,比如使用了某某程序库的程序该怎么测。
其实这个问题我在集成测试里已经说过了,首先要有一个单独的防腐层,将第三方代码隔离开,先保证逻辑的正确,再来看第三方程序库。与第三方程序库的集成,能测就尽量测,不能测就只能靠更高层的测试来覆盖了。
其实,现在很多的程序库或是中间件都有自己对于测试的支持,一种简单的方案就是用要集成的东西加上 Mock 作为关键字去搜索,比如 Mock Kafka、Mock Dubbo 等,如果你运气好的话,会得到相应的答案,剩下就是阅读文档的工作了。
有一些地方是我在写专栏的过程中没注意到的,可能会给你造成困扰。比如我用到 Lombok 这个程序库,它会替我们生成一些代码,节省编码的工作量,没用过 Lombok 的同学看到这样的代码可能会有些懵。
说到 Lombok我再多补充一点。在测试的过程中有一些典型的样板代码是让人很难受的比如 getter、equals、hashCode、toString这种代码不写行为不对就会有测试覆盖率的问题。给这些方法写测试是非常麻烦的一件事这里的麻烦就是单纯的麻烦实现细节是标准的写的测试也几乎长一个模样。
所以在实际的项目中,我现在常常会采用 Lombok 这个程序库,它会替我生成这些标准的方法,就像下面这样。
@EqualsAndHashCode
@Getter
@ToString
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class TodoItem {
...
}
我们只要在相应的位置加上 Annotation在编译的过程中Lombok 就会替我们生成相应的代码。所以,虽然我没有一行一行地把代码写出来,但我们依然可以在程序中使用相应的方法。这就是一个好的程序库价值所在,它可以极大地简化代码的编写。
这些方法是生成的而不是我们手工编写的所以我们也没有必要去检查这些方法是否正确在执行测试覆盖率检查时我们可以忽略掉这些方法。Lombok 和 Jacoco 之间已经有了这种默契,所以,你会在 Lombok 的配置文件 lombok.config 看到下面这样一句。
lombok.addLombokGeneratedAnnotation = true
它会给生成的方法加上一个 AnnotationJacoco 看到了这个 Annotation 就会知道它是一个生成的方法,进而在测试覆盖的统计中忽略掉它。
那些东西怎么测?
还有一类的问题相对来说就很宽泛了,比如有人问怎么测试大数据模型的正确性。要回答这个问题,我们要先看看自动化测试究竟能解决什么问题。
首先,我们需要明确一点,程序员在日常开发中写测试除了我们之前说过的种种原因之外,还有一点就是为我们的开发保驾护航,这些测试要能够时时刻刻起到警戒线的作用。这种测试写出来要常常跑的,而不是束之高阁。所以,它还有一个好搭档就是持续集成。多说一句,只有做好了自动化测试,持续集成才能发挥出它最大的价值。
有了前面的理解,我们再来看哪些东西能起到这样的作用:那些能够让人形成稳定预期的东西。我们前面写的各种测试,都是能够有稳定预期的,这也是我们说测试一定要有断言的重要原因。
回过头来看前面的问题。大数据模型这种东西,你能有稳定的预期吗?你或许会说,我希望得到一个好的结果,然而,这样说法一点都不具体,什么叫好的结果呢?这就像我希望身体健康一样,这只是美好的愿望,它并不会因为我们“希望”了,它就能够实现出来。对于这种你连用语言都无法描述的东西,就不要指望用测试描述了。
其实,你真正关心的不是模型的正确性,而且它的效用,也就是说,它到底有什么用。
效用这种东西,我们没有办法对它形成稳定的预期。举个例子,你说 100 万多吗?对大部分普通人来说,这已经是很大一笔钱了,但对于世界首富来说,这笔钱就不算多了。所以,同样是得到 100 万,对不同的人来说,效用完全不同。即便是对同一个人,当他人生处于不同的阶段,这笔钱对他来说意义也是完全不同的。
效用甚至都很难达成共识,那就更别说预期了,所以,大数据模型不是靠自动化测试能解决的。类似的东西还有很多,比如用户界面好不好看、软件的体验好不好,这些东西都属于效用。
那效用的东西就不能测了吗?也能,只不过,不是自动化测试。严格地说,效用的好坏要依赖于反馈。数据模型的有效性要靠业务来反馈,软件的好用还是好看要靠用户来反馈。
在软件开发的实践中有一种实践叫用户测试,简单说就是让用户参与到软件开发的过程中。还有一种实践叫 A/B 测试,就是把不同的东西给不同的用户看,把用户行为当作下一步决策的基础。你会发现,无论是哪种做法都不是靠简单的自动化测试能够覆盖的,因为这个过程中要有人的参与,人会根据反馈回来的信息进行判断。
既然这些依赖于人的测试不好自动化,是不是技术类的就都可以了呢?比如有人问,性能测试能不能放到程序员的测试里?
性能测试是一种可以用技术覆盖的测试,现在有很多程序库支持我们进行性能测试,比如 JMH、Gatling 等等。大到系统,小到单元,都可以使用性能测试进行覆盖。也正是这些程序库的存在,让我们可以通过代码来写性能测试,也让性能测试用测试框架覆盖成为可能。
不过,有一点我必须提醒你,性能测试最难处理的是断言怎么写。你或许会说,不就是和其他测试一样写吗?不尽然。
有很多团队开发用的是 Windows 或者是 Mac而实际的项目是在 Linux 运行。我们知道,性能这个东西在不同的机器上跑出来的结果差异很大。即便是同一台机器,因为负载的差异,跑出来的结果也会有很大差异。所以,我们很难写出一个放在所有环境上都可用的断言。
从实践的角度上看,我不会把性能测试归到程序员的测试范畴。当然,如果你愿意用单元测试框架去写一个自动化性能测试,也是可以的,但请把它同其他测试隔离开来,其他的测试要在持续集成的全过程中运行,而这种自动化性能测试只在单一的机器上运行,比如持续集成服务器,这样做至少可以保证前后运行的结果不会因为机器原因产生很大的差异。
测试固然有用,但它不是万能的。作为程序员,我们只有分辨清楚自己面对的究竟是什么问题,才能使用相应的工具去解决问题。
思考题
关于程序员的测试,你还有哪些问题或者哪些经验可以分享吗?欢迎在留言区分享你的思考。

View File

@ -0,0 +1,87 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 对代码的信心要从测试里来
你好,我是郑晔!
《程序员的测试课》到这里已经接近尾声了,经过整个专栏的学习,相信你对“程序员该如何做测试”这件事已经有了一个更加完整的认识。这一讲我们不去深入技术细节,我想先从一本书和你聊起。
无知之错和无能之错
这本书叫《清单革命》,作者是阿图·葛文德,他是一名医生,曾是白宫最年轻的健康政策顾问。在书的开篇作者提到,人类的错误可以分为两大类型。第一类是“无知之错”,我们犯错是因为没有掌握相关知识。第二类是“无能之错”,我们犯错并非因为没有掌握相关知识,而是因为没有正确使用这些知识。无知之错,可以原谅,无能之错,不可原谅。
在作者看来,目前这个世界上还有很多疾病没有很好的治疗方案,这个算是无知之错。但在真实世界中,很多治疗的失败却是因为医疗团队没有做好该做的事,那就属于无能之错了。
这个分类方式给了我很大的震撼,让我一下子想明白很多事。有了分类,针对不同的错误,我们可以采用不同的修正方式。无知之错,因为欠缺的是知识,所以如果要修正这类错误,需要补充相关的知识。而无能之错并非知识的欠缺,所以,要修正这类错误,需要改进的是工作的方法和流程。
身为程序员,我们是幸运的,我们生活在一个对软件有巨大需求的时代。但实事求是地说,软件也是各种问题的高发地带,我们在其中不停犯着各种错误,很多甚至是低级错误。
这些错误应该归为哪种类型的错误呢?
在外人看来,软件开发团队就应该有做好软件的能力,没做好肯定就是各种疏忽造成,这种错误是一种无能之错,要解决无能之错,显然就应该从改进工作方法和流程入手。于是,我们看到很多公司一旦觉得自己的公司需要提升软件质量了,首先是引入一套新的流程,无论是拼命写文档,还是引入专人负责。然而结果是软件团队疲于应付各种流程,软件的实际质量却并未得到有效改善。虽然初衷是好的,但因为诊断错了病因,用错了药方,治不好病也就在所难免了。
软件质量的病不在外部而在内部。一个没有质量意识的团队只靠外部的推动很难做出高质量软件这就像一个孩子如果仅仅靠家长的逼迫很难取得长期的好成绩一样。然而软件质量靠流程却成了行业中的常态不得不说这是一个悲哀的结果。孩子想取得好成绩归根结底是要有自己对学习的热爱同样软件质量要想得到真正的提升要将做到内建质量Build Quality In
内建质量
内建质量,就是将质量的思考内建于软件开发的全生命周期中。
说起来很简单,然而,产品经理一拍脑袋,程序员拼命加班,把一个漏洞百出的软件送到了测试人员手里。然后,在业务强大的压力之下,测试人员闭着眼睛,把一个质量不彰的软件送上了线。每个环节都在放水,结果就是水漫金山。线上系统问题不断,新的需求接踵而来,团队疲惫不堪。然而,没有人真正地想问题到底出在哪里。这才是很多团队的真实情况。
在这样的团队中,质量只是测试人员的事,相当于把整个团队的责任压在了少数人的头上,线上出问题就是情理之中的事了。内建质量就是要把软件开发中的每一个环节都加入质量的考虑:
业务负责人不能只要求上线日期,也要给出需求验证的业务目标和业务的验收标准;
产品经理不只是要给出产品说明,更要给出每个需求点的验收标准;
程序员不只给出代码,还要给出覆盖每行代码的自动化测试。
所谓内建质量,本质上就是用任务分解的方式,让每个环节都交付满足一定质量标准的交付物。其实,现在软件行业已经懂得了用迭代交付替代瀑布式交付,把大的需求拆分为小需求的集合,逐步交付给市场,尽早收集反馈,避免走过多的弯路。而内建质量则是通过在每个环节中加入对质量的思考,在每一个环节都要验证交付物是否符合目标,尽早发现问题。这样才不至于让测试人员成为最后的防洪堤坝,才不至于把大招憋成内伤,才有可能拿出一个高质量的软件。
在软件研发的环节加入质量的思考,对很多人来说,是一件有难度的事。因为在他们看来,这么做是增加了工作量。比如很多程序员会说“写测试就是浪费时间”。然而,真的是这样吗?
一个内建质量的团队,可以在工作的诸多环节规避掉很多问题。从软件生命周期的角度看,规避了这些问题可以从整体上节省时间。虽然很多人学过软件工程的基本理论,但这种东西实在太反直觉,就像不相信 0.99… = 1也有很多人不相信前期的投入会给团队带来长期的回报。
但这种不相信其实更多是一种不愿意相信,因为相信了就意味着要做出改变,而改变才是很多人真正惧怕的。是的,很多人真正的不愿意是“改变”这件事。正是因为有太多的人不愿意改变,才使得愿意改变的人很容易脱颖而出。
一旦你想明白了这一点,你就能理解软件研发中暴露的很多错误根本不是无能之错,而是无知之错。换言之,正是因为很多人只愿意墨守成规,所以,他们根本看不到自己其实是欠缺了一个质量的维度,而这个维度上也有着一张知识网。
把无知之错当做无能之错去解决,根本就是走错了方向,再多的流程改进也不会让人学会写测试。因为程序员欠缺的是写测试的知识,而很少有人会意识到原来很多程序员不会写测试。解决无知之错要从知识的补充入手,而前提条件是你愿意改变。
简单的代码
感谢你一路学到这里,我相信你是愿意改变的。现在你已经具备了改变所需的基础知识,相比于还在努力改变流程的人,你已经领先了很多。对你来说,接下来要做的是花更多的时间来练习,并在练习的过程中发现自己欠缺的知识,进行相应的补充。
《程序员的测试课》虽然给了你一个写好测试的知识结构,但估计你也发现了,其实写好测试要具备的知识储备并不小,就像我在形容 TDD 所说:
TDD 只是冰山一角,露在海面之上的是 TDD 的节奏,而藏在海面下的是任务分解、软件设计这些需要一定时间积累的能力。
同样,写出来的测试也是冰山一角,背后是那些需要时间积累的能力。但是,不要被这些东西吓到。其实,你在实战中已经见识过了,我写的代码很简单。有人会认为,这是一个演示的例子,所以写出来会很简单。但实际上,我在真实项目中也是这么写代码的,只不过业务逻辑更复杂一些。是的,业务逻辑复杂和代码复杂是两回事。不管业务逻辑多复杂,代码都可以写得清晰而简洁。只有那些写得不好的代码才是复杂的,会有着各种各样奇怪的写法。
优秀的代码平平无奇,糟糕的代码千奇百怪。
随着经验的丰富,我越能理解简单的价值,能坚持把代码写简单是一种能力。这需要我们不仅要有把代码写简单的意识,还要有把代码写简单的能力。好消息是,简单的代码也是容易写测试的代码,无论后续添加新功能还是修改已有的问题,难度都会下降很多,所以,它也是高质量的代码。
你只要不断地用测试作为一把尺子衡量你写的代码,你的代码质量就会越来越高。当你不知道该怎么办时,不妨回到专栏里,看看我们在专栏里是怎样解决问题的:把问题还原到简单的情形,再去想办法解决。化繁为简,是一个优秀程序员应该具备的品质。
如果整个专栏你只能记住一件事,那请记住:写代码时问问自己,这段代码应该怎么测。
在这篇结束语的末尾,我来讲个小八卦,跟你说说这个专栏是怎么来的。我在自己的公众号“郑大晔校”中写了一篇文章《为什么程序员大多不写测试?》,说的就是程序员不会写测试这件事。在文章的最后,我说程序员没有太好的方式学习写测试。《极客时间》的主编看到这篇文章就说,既然没有,那你来写一个吧。于是,有了这个专栏,也算是我自己挖坑自己填了。
这个专栏从开始构思到上线只用了一个月的时间,创下了我写作专栏的记录。算起来,这已经是我在《极客时间》写的第四个专栏了,我能够梳理成体系的结构化思考都以专栏的形式呈现了。当然,如果你还对我零散思考有兴趣,不妨关注我的公众号“郑大晔校”。
这次的《程序员的测试课》之旅就暂告一段落了!如果以后有机会,我会再来与你分享我对软件开发的理解。
最后是我们的小福利环节,我给你留了一个有奖小问卷,希望你花两分钟来填写一下,你的反馈意见对我来说很重要,我会根据你的意见持续维护这个专栏。
再见!