引导语:
测试,这一项工作似乎是和测试人员相关,但其实测试也是程序员的本职工作之一。
测试也是程序员的工作
为什么写测试,测试不是测试同学的工作吗?我们在日常开发中可能是这样,和业务或者产品同学确认需求后,开始设计,开发,然后稍微运行下,符合预期就认为完成了。然后提测后,就开始收到测试同学提的Bug单。
那我们如何避免这些bug呢?是不是做好自测就好了?先来看看是怎么造成的这些bug。我们可以先对bug分下类
- 粗心大意,例如一些条件分支没走过,一些边界异常,改动不小心影响了其他模块等
- 认知偏差,分为需求认知偏差(例如测试同学的完成标准不同)和代码API认知偏差(如线程池API中的参数)
- 熵增问题,架构腐烂,导致修改不可控,程序规模变大,复杂度变高之后,再去修改程序或添加功能就更容易引发未知的 Bug。
那么怎么防止这些bug,可能我们的第一反应是,自测不够充分,不够细心,下次注意。这种靠人工的检查,是一定会出错的,那怎么去保障呢?
或者说换个问题,怎么确保你的代码是高质量的,怎么验证我们代码的正确性。是不是通过某种测试,例如运行系统查看对应的逻辑,但这种方式只能验证当前修改的部分,原先验证的可能就遗漏了,又或者是不是随便在当前函数写个main函数,跑一下被测的函数符合预期就完事了,这种方式也是不可重复的,如果要验证其他的函数又得修改。
所以问题就来到了,我们要怎么去写测试。要知道怎么写好测试,那么我们是不是得知道什么是好的测试?
什么是好的测试
好的测试
首先它也应该是简单,不应该有复杂的逻辑,一个测试应该只测试一个方法,否则复杂了我们就无法保障这个测试案例是否正确了。下面我通过代码来看下结构
@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 项的内容是否是我们添加进去的内容。
清理。清理是一个可能会有的部分。如果在测试中使到了外部资源,在这个部分要及时地释放掉,保证测试环境被还原到一个最初的状态,就像什么都没发生过一样。比如,我们在测试过程中向数据库插入了数据,执行之后,要删除测试过程中插入的数据。一些测试框架对一些通用的情况已经提供支持,比如之前我们用到的临时文件。
A-TRIP
一段旅程(A-TRIP)有了对测试结构的基本认知,我们再进一步,看看如何衡量一个测试有没有做好?
有人把好测试的特点总结成一个说法:A-TRIP。这其实是五个单词的缩写,分别是:
- Automatic,自动化;
- Thorough,全面的;
- Repeatable,可重复的;
- Independent,独立的;
- Professional,专业的。
Automatic,自动化。经过上一讲的讲解,这一点你应该已经很容易理解了。自动化测试相比传统测试,核心增强就在自动化上。这也是为什么测试一定要有断言,因为只有在有断言的情况下,机器才能够帮我们判断测试是否成功。
Thorough,全面的。这一点其实是测试的要求,应该尽可能用测试覆盖各种场景。不管什么样的自动化测试,它的本质还是测试,前面我们讲了向测试人员学习,关键点就在于这有助于我们写出更全面的测试。理解全面还有一个角度,就是测试覆盖率。我们在实战环节中已经见识了如何通过测试覆盖率工具,帮我们去发现代码中测试中没有覆盖到地方。
Repeatable,可重复的。它要求测试能够反复运行,并且结果都应该是一样的。这是保证测试简单可靠的前提。如果一个测试不是可重复的,我们就没法相信它的运行结果,测试的价值也就荡然无存了。一旦测试报错,我们没法确定是我们程序出错了,还是其它什么地方出错了。
Independent,独立的。测试和测试之间不应该有任何依赖。什么叫有依赖?就是一个测试要依赖于另外一个测试运行的结果。比如两个测试都要依赖于数据库,第一个测试运行时往数据库里写了一些数据,而第二个测试在执行时要用到这些数据。也就是说,第二个测试必须在第一个测试执行之后再执行,这就叫做有依赖。
Professional,专业的。这一点是很多人观念中缺失的,测试代码也是代码,也要按照代码的标准去维护。这就意味着你的测试代码也要写得清晰,比如良好的命名(should_xxx_when_xxx_given_xxx)、把函数写小、要重构甚至要抽象出测试的基础库、测试的模式。在 Web 测试中常见的 PageObject 模式,就是这种理念的延伸。
测试与设计
我们所说的代码不好测,其实就是可测试性不好。对一个可测试性好的系统而言,应该每个模块都可以进行独立的测试。而不是需要整个系统都集成起来进行系统测试。
编写可测试的代码
编写可测试的代码,最简单的回答就是让自己的代码符合软件设计原则,一个通用规则,那就是编写可组合的代码
第一个推论:是不要在组件内部去创建对象
我们考虑从构造函数把它作为参数传进来。
第二个推论:不要编写 static 方法。
不要使用全局状态;不要使用 Singleton 模式。
与第三方代码集成
对于调用程序库的情况,我们可以定义接口,然后给出调用第三方程序库的实现,以此实现代码隔离;
如果我们的代码由框架调用,那么回调代码只做薄薄的一层,负责从框架代码转发到业务代码。
怎么写?
测试类型和配比
测试根据类型可以分为以下几种
- 单元测试,单元测试是所有测试类型中最基础的,它的优点是运行速度快,可以尽早地发现问题。只有通过单元测试保证了每个组件的正确性,我们才拥有了构建系统的一块块稳定的基石
- 集成测试,让一个个单元正常运行,我们靠的不是美好的预期,而是单元测试。同样,各个单元能够很好地协同,我们也不能靠预期,而是要靠集成测试
- 系统测试,把整个系统集成起来进行测试。
我们这里重点关注单元测试,和集成测试。
单元测试什么时候写
大多数情况我都是写完代码,然后再去补充相关测试案例,但是这种最后补测试的做法总是很糟糕的,存在以下问题
- 整个功能代码都写完了,再去写测试就成了一件为了应付差事不得不做的事情
- 代码实现细节模糊,容易遗漏
这种仅仅比不写测试好一点。你要想写好单元测试的话,最好能够将代码和测试一起写。要想做好单元测试,关键就是工作的粒度要小。想写好单元测试也要从任务分解开始,一定要把任务分解到非常小,这是能够写好代码,写好测试的前提条件,甚至可以说是最关键的因素
编写单元测试的过程
除了业务人员提供验收标准,我们自己也要有一个验收标准,我们要能够去衡量怎么样才算是这个代码写合格了。一个任务的代码要通过测试才算编码阶段的完成。
找到测试用例,首先要确定的是待测单元的行为,也就是要实现的类里的一个函数,它的行为是什么样的。
我们在设计这个函数接口时,还必须增加一点考量:它要怎么测。
只要我们完成一个子任务,我们就可以做一次代码的提交,因为我们这个时候,既有测试代码又有实现代码,而且实现代码是通过了测试的。
所以,先设计测试用例,后写代码,这是一个编码习惯的问题。
面向接口测试
在实际的项目中,我会更倾向于测试接口,尽可能减少对于实现细节的约束。单元测试常见的一个问题是代码一重构,单元测试就崩溃。这很大程度上是由于测试对实现细节的依赖过于紧密。一般来说,单元测试最好是面向接口行为来设计,因为这是一个更宽泛的要求。
集成测试:单元测试可以解决所有问题吗?
让一个个单元正常运行,我们靠的不是美好的预期,而是单元测试。同样,各个单元能够很好地协同,我们也不能靠预期,而是要靠集成测试,集成分为
- 代码的集成
- 集成外部组件
与代码集成,集成测试就是把不同的组件组合到一起,看看它们是不是能够很好地配合到一起。选择一条任务执行的路径,把路径上用到的组件集成到一起进行测试。例如接口测试Web层,把service层和Repository层联合起来
与外部组件集成,难点就在于外部组件的状态如何控制。
以数据库集成来说,通常的做法是一方面,我们会建立单独的数据库,保证不与其他部分冲突。比如在 MySQL 里面,我们会建立一个测试用的数据库。
另一方面,我们要保证它在每个测试之后,都能够恢复到之前的状态。一种做法就是使用数据库的回滚技术,每个测试完成之后就回滚掉,保证数据的干净
测试应该怎么配比?
有一些内容用单元测试覆盖可以,用集成测试覆盖也可以,如果只写单元测试总有些不放心,如果同时用单元测试和集成测试去覆盖,工作量似乎又会增大,不同的测试应该怎样配比呢?
测试的特点
首先来看单元测试。单元测试是针对一个单元的测试,因为涉及面很小,所以单元测试要进行的设置会比较少。单元测试不牵扯到外部组件,一般而言只在内存中执行,执行速度很快。所以谈及单元测试的特点我们一般会说,它成本低、速度快、单个测试的覆盖面小,但整体覆盖面大。再来看集成测试。
相比于单元测试来说,集成测试的涉及面要广一些,设置起来就比较麻烦。有的集成测试还会集成外部组件,这也就意味着设置起来要更麻烦,比如你在上一讲见识过的数据库测试,就要准备各种配置信息。同时,无论是组件多还是集成外部组件,这都意味着执行速度要比单元测试慢。所以相比于单元测试,集成测试成本要高一些、速度要慢一点;单个测试的覆盖面要大一些,但整体覆盖面要小一些。
虽然我们主要讨论的是单元测试和集成测试,但实际上,还有一种测试有的团队也会做,就是系统测试(把整个系统集成起来进行测试)。
测试配比模型
正是有不同的出发点,行业中有两种典型的测试配比模型,一种是冰淇淋蛋卷模型,一种是测试金字塔模型。
冰淇淋蛋卷的出发点就是从单个测试的覆盖面考虑的,只要一些系统测试,就足以覆盖系统的大部分情况。当然,对于那些系统测试无法覆盖的场景就需要有低层的测试配合,比如,集成测试和单元测试。在冰淇淋蛋卷模型里,主力就是高层测试,低层测试只是作为高层测试的补充
测试金字塔的出发点是低层测试成本低、速度快、整体覆盖面广,所以要多写。因为低层测试覆盖了几乎所有的情况,高层的测试就可以只做一些大面上的覆盖,保证不同组件之间的协作是没有问题的。在这个模型里,主力是单元测试,而高层的测试则是作为补充。
怎么使用这两个模型
从行业的最佳实践角度看,测试金字塔已经是行业中的最佳实践。测试金字塔以单元测试为基础,因为成本低、速度快等特点,单元测试可以让我们在开发过程中迅速得到反馈。对于一个想要编写测试的团队而言,测试金字塔模型也是更容易坚持做到的。
既然测试金字塔都成为了行业的最佳实践,那我们为什么还要了解冰淇淋蛋卷模型呢?因为不是所有项目都是新项目。
因为各种历史原因,很多遗留项目是没有测试的。当项目发展了一段时间之后,团队开始关注产品质量,于是大家开始补测试。对于冰淇淋蛋卷模型,它是遗留项目写测试的起点。在有了一个安全网的底线之后,我们还是要向测试金字塔方向前进,以单元测试作为整体的基础。
给遗留系统写测试
动到哪里,给哪里写测试。至于给哪里写测试,最直观的做法当然是编写最外层的系统测试
虽然覆盖面会广一些,但具体到我们这里要修改代码而言,存在一种可能就是控制得不够准确。换言之,很有可能我们写了一个测试,但是我们改不改代码,对这个测试影响不大。
所以,只要有可能,我们还是要努力地降低测试的层次,更精准地写测试。也就是能写集成测试,就不写系统测试;能写单元测试,就不写集成测试。
造成测试不好写的难点就是耦合,无论是代码与外部系统之间的耦合,还是代码与第三方程序库的耦合,抑或是因为代码写得不好,自己的代码就揉成了一团。所以,想在遗留系统中写好测试,一个关键点就是解耦。(先隔离,再分离)
框架
除了JUnit,来看看和测试相关的支持都有哪些优秀的第三方框架
Mock框架
从模式到框架
做测试,本质上就是在一个可控的环境下对被测系统 / 组件进行各种试探。拥有大量依赖于第三方代码,最大的问题就是不可控。怎么把不可控变成可控?第一步自然是隔离,第二步就是用一个可控的组件代替不可控的组件。换言之,用一个假的组件代替真的组件。
Gerard Meszaros 写过一本《xUnit Test Patterns》,他给这些名词起了一个统一的名字,形成了一个新的模式:Test Double(测试替身)。其基本结构如下图所示。
在这个图里,SUT 指的是被测系统(System Under Test),Test Double 就是与 SUT 进行交互的一个组件。
然而,这个名字也没有在业界得到足够广泛的传播,你更熟悉的说法应该是 Mock 对象。因为后来在这个模式广泛流行起来之前,Mock 框架先流行了起来。
Mock 框架
要学习 Mock 框架,必须要掌握它最核心的两个点:设置模拟对象与校验对象行为。以Mockito为基础
设置 Mock 对象
模拟对象的设置核心就是两点:参数是什么样的以及对应的处理是什么样的。设置对象
TodoItemRepository repository = mock(TodoItemRepository.class);
接下来就是设置它的行为
when(repository.findAll()).thenReturn(of(new TodoItem("foo")));
when(repository.save(any())).then(returnsFirstArg());
校验对象行为
模拟对象的另外一个重要行为是校验对象行为,就是知道一个方法有没有按照预期的方式调用。比如,我们可以预期 save 函数在执行过程中得到了调用。
verify(repository).save(any());
测试应该测试的是接口行为,而不是内部实现。
如果按照测试模式来说,设置 Mock 对象的行为应该算是 Stub,而校验对象行为的做法,才是 Mock。如果按照模式的说法,我们应该常用 Stub,少用 Mock。
Mock 框架的延伸
模拟服务器顾名思义,它模拟的是服务器行为,现在在行业中广泛使用的模拟服务器主要是 HTTP 模拟服务器。HTTP 服务器的主要行为就是收到一个请求之后,给出一个应答,从行为上说,这与对象接受一系列参数,给出相应的处理如出一辙。Moco框架是模拟HTTP服务的
测试覆盖率:如何找出没有测试到的代码?
如何查缺补漏,找到那些测试没有覆盖到的代码。我们要来了解一下测试覆盖率
测试覆盖率
测试覆盖率是一种度量指标,指的是在运行一个测试集合时,代码被执行的比例。它的一个主要作用就是告诉我们有多少代码测试到了
常见的测试覆盖率指标有下面这几种:
- 函数覆盖率(Function coverage):代码中定义的函数有多少得到了调用;
- 语句覆盖率(Statement coverage):代码中有多少语句得到了执行;
- 分支覆盖率(Branches coverage):控制结构中的分支有多少得到了执行(比如 if 语句中的条件);
- 条件覆盖率(Condition coverage):每个布尔表达式的子表达式是否都检查过 true 和 false 的不同情况;
- 行覆盖率(Line coverage):代码中有多少行得到了测试。
JaCoCo:一个 Java 的测试覆盖率工具
在 JaCoCo 里,指标对应的概念是 counter。我们要在覆盖率中使用哪些指标,也就是要指定哪些不同的 counter。
覆盖率是一个比例,所以,它的取值范围就是从 0 到 1。我们可以根据自己项目的需要来进行配置。根据上面的介绍,如果我们要求行覆盖率达到 80%,我们就可以这样配置。
counter: "LINE", value: "COVEREDRATIO", minimum: "0.8"
在实际的项目中使用测试覆盖率工具,关键是要把它与自动化的过程结合起来,让它不是独立的存在。每次提交,每次 CI 过程都要进行测试覆盖率的检查。
100%覆盖率
怎么保证自己编写的代码 100% 测试覆盖
首先,让自己可控的代码有完全的测试保证,其次,如果有第三方的代码影响到测试覆盖,我们应该把第三方的代码和我们的代码隔离开。
测不到的代码 ,隔离出来,隔离出来的代码怎么办呢?我们要在测试覆盖的检查中将它们排除,具体的做法就是在构建文件中,把这个文件标记为不需要测试覆盖
coverage {
excludeClasses = [
"com.github.dreamhead.todo.util.Jsons"
]
}
Spring 的测试
使用单元测试构建稳定的业务核心,使用 Spring 提供的基础设施进行集成测试。保证代码可以组合的,也就是通过依赖注入的
不使用基于字段的注入
@Autowired 是一个很好用的特性,它会告诉 Spring 自动帮我们注入相应的组件。在字段上加 Autowired 是一个容易写的代码,但它对单元测试却很不友好,因为你需要很繁琐地去设置这个字段的值,比如通过反射。
如果不使用基于字段的注入该怎么做呢?其实很简单,提供一个构造函数就好
不依赖于 ApplicationContext
在业务核心代码中出现 ApplicationContext 是一种完全错误的做法。一方面,它打破了 DI 容器原本的设计,另一方面,还让业务核心代码对第三方代码(也就是 ApplicationContext)产生了依赖。
集成测试
以数据库的测试为例
- 根据配置使用测试数据库
- 事务回滚@RollBack
Web 接口测试
Web 接口测试通常是最外层的测试,可以做整体的集成测试(@SpingBootTest),或对一个单元进行测试的集成测试(@WebMvcTest)。
TDD和BDD
TDD 就是先写测试后写代码吗?
TDD,也就是测试驱动开发(Test Drvien Development)。让单元测试框架流行起来的是 JUnit,其作者之一是 Kent Beck。TDD 走进大众视野则依赖于极限编程这个软件工程方法论的兴起,而极限编程的创始人也是 Kent Beck。Kent Beck 在 JUnit 和 TDD 两件事都有着重大贡献,也就不难理解为什么 TDD 的节奏叫“红 - 绿 - 重构”了。
TDD 的节奏
“先写测试、后写代码”的做法叫测试先行开发(Test First Development),而不是测试驱动开发。
TDD 的节奏:红 - 绿 - 重构。
红表示写了一个新的测试,测试还没有通过的状态;绿表示写了功能代码,测试通过的状态;而重构就是在完成基本功能之后,调整代码的过程。
先写测试,然后写代码完成功能,在第一步和第二步上,测试先行开发和测试驱动开发是一样的。二者的差别在于,测试驱动开发并没有就此打住,它还有一个更重要的环节:重构(refactoring)。
测试“驱动”开发
测试驱动开发要从任务分解开始。从测试出发考虑问题的这种思考方式,会彻底颠覆掉我们原有的工作习惯,甚至是为了测试调整设计。但结果是我们得到了一个更好的设计,所以,很多懂 TDD 的人会把 TDD 解释为测试驱动设计(Test Driven Design)。
为了写测试,首先“驱动”着我们把需求分解成一个一个的任务,然后会“驱动”着我们给出一个可测试的设计,而在具体的写代码阶段,又会“驱动”着我们不断改进写出来的代码。把这些内容结合起来看,我们真的是在用测试“驱动”着开发。
换个角度看,TDD 只是冰山一角,露在海面之上的是 TDD 的节奏,而藏在海面下的是任务分解、软件设计这些需要一定时间积累的能力
TDD 是来自极限编程,那极限编程为什么要叫极限编程呢?极限编程之所以叫“极限”,它背后的理念就是把好的实践推向极限:
- 如果集成是好的,我们就尽早集成,推向极限就是每一次修改都集成,这就是持续集成。
- 如果程序员写测试是好的,我们就尽早测试,推向极限就是先写测试,再根据测试调整代码,这就是测试驱动开发。
- 如果代码评审是好的,我们就多做评审,推向极限就是随时随地代码评审,这就是结对编程。
- 如果客户交流是好的,我们就和客户多交流,推向极限就是客户与开发团队时时刻刻在一起,这就是现场客户。
极限编程本身的实践值得我们好好学习,但极限编程背后这种理念其实也非常值得我们学习。我们在日常工作中也不妨多想想,有哪些做法是好的,如果把它推向极致会是什么样子。这种想问题的方式会在很大程度上拓宽你的思路。
BDD 是什么东西?
BDD 的全称是 Behavior Driven Development,也就是行为驱动开发。BDD 这个概念是 2003 年由 Dan North 提出来的。
单元测试框架写测试的方式更多的是面向具体的实现,这种做法的层次是很低的,BDD 希望把这个思考的层次拉高。拉到什么程度呢?软件变化的源动力在业务需求上,所以,最好是能够到业务上,而校验业务的正确与否的就是业务行为。这种想法很大程度上是受到当时刚刚兴起的领域驱动设计(Domain Driven Design)中通用语言的影响。在 BDD 的话语体系中,“测试”的概念就由“行为”所代替,所以,这种做法称之为行为驱动开发。
BDD核心点就是它的描述格式:“Given…When…Then”。Given 表示一个假设前提,When 表示具体的操作,Then 则对应着这个用例要验证的结果。
测试一般包含四个阶段:准备、执行、断言和清理。把它对应到这里,Given 对应着准备,When 对应执行,而 Then 对应断言。至于清理,这个阶段会做一些资源释放的工作,不过这个工作属于实现层面的内容,在业务层面上意义不大,所以在以业务描述为主要目标的 BDD 中,这个阶段是不存在的。
重点是要让测试用例贴近业务而非实现细节。一般来说,BDD 多用于验收测试,所以相应地,我们在编写步骤定义时,对于复杂业务可以考虑构建业务测试模型,对实现细节进行封装。
结束
无知之错和无能之错
在《清单革命》的开篇作者提到,人类的错误可以分为两大类型。第一类是“无知之错”,我们犯错是因为没有掌握相关知识。第二类是“无能之错”,我们犯错并非因为没有掌握相关知识,而是因为没有正确使用这些知识。无知之错,可以原谅,无能之错,不可原谅。
软件质量的病不在外部,而在内部
内建质量
内建质量,就是将质量的思考内建于软件开发的全生命周期中。就是要把软件开发中的每一个环节都加入质量的考虑:
- 业务负责人不能只要求上线日期,也要给出需求验证的业务目标和业务的验收标准;
- 产品经理不只是要给出产品说明,更要给出每个需求点的验收标准;
- 程序员不只给出代码,还要给出覆盖每行代码的自动化测试。
通过在每个环节中加入对质量的思考,在每一个环节都要验证交付物是否符合目标,尽早发现问题,软件研发中暴露的很多错误根本不是无能之错,而是无知之错。
简单的代码
TDD 只是冰山一角,露在海面之上的是 TDD 的节奏,而藏在海面下的是任务分解、软件设计这些需要一定时间积累的能力。
化繁为简,是一个优秀程序员应该具备的品质。
参考:
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 951488791@qq.com