first commit

This commit is contained in:
张乾
2024-10-16 09:22:22 +08:00
parent 206fad82a2
commit bf199f7d5e
538 changed files with 97223 additions and 2 deletions

View File

@ -0,0 +1,88 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 为什么写测试是程序员的本职工作?
你好,我是郑晔!
看到开篇词的标题,你或许会疑惑,测试不是测试人员的本职工作吗?什么时候也成了程序员的本职工作了?
别急,让我换个问题来问问你。你说,程序员应该懂设计模式吗?大部分程序员都会说应该。而且很多人会说,这难道不是程序员的基本功吗?但你要知道,如果我们把时间往回拨,在 21 世纪初,程序员不懂设计模式才是常态,很多人会嘲笑设计模式让代码变得复杂了。
时代在要求我们写测试
之所以要说设计模式这个例子,主要是说程序员的职责范围是随着时间逐步变化的。这样的例子还有很多,迭代开发也好,开源项目也罢,这原本都不是程序员需要了解的。想当初,哪里有什么迭代开发,一个软件不开发个几年,怎么好意思出来见人。一个项目如果不是所有的代码都自己编写,怎么能有完全的掌控感,谁敢轻易使用别人开发的开源项目。而今天,哪个程序员不知道这些东西呢?
之所以程序员的职责范围一点点在拓展,关键原因就是,软件开发正在变得越来越复杂。而加入到程序员职责范围内的这些新东西,正是帮助程序员对抗越来越复杂的软件开发。迭代开发,让我们有机会把精力集中在最重要的功能特性上;开源项目,让我们可以减少不必要的代码编写。
测试同样如此,它可以让我们在越来越复杂的软件开发中能够稳步前行。一方面,在编写新功能时,测试可以让我们的代码正确性得到验证,让我们拥有一个个稳定的模块。另一方面,测试可以帮助我们在长期的过程中不断回归,让每一步走得更稳。
程序员圈子流传着一个关于测试的段子:每个程序员在修改代码时都希望有测试,而在写代码时,都不想写测试。
希望有测试,是因为测试可以给我们带来安全感。不想写测试,一方面,很多人会觉得麻烦,另一方面,也是更重要的,团队并没有要求。为什么很多团队不要求程序员都写测试呢?这里有一个很可悲的答案,因为大部分程序员都不会写测试。
大部分程序员不会写测试
看到这个结论,可能你会说,测试有什么难的吗?不就是用 xUnit 之类的单元测试框架写代码吗?程序员每天都在写代码,写代码的事能难倒程序员,这不是开玩笑吗?
如果你把这个问题抛给一个想写测试的程序员,他会告诉你,为了学着写测试,他了解过 xUnit 的框架,甚至看过人家演示 TDD 如何去做。看别人做起来,他觉得写测试挺容易啊。
可当他有了跃跃欲试的冲动,看到了自己的代码库,所有的兴奋都烟消云散了,他还是不知道怎么写出一个测试。可能是他的代码库太复杂了,他不知道该从哪里下手;也可能是跟着别人写测试很容易,而到自己写测试的时候,他都不知道第一个测试应该从哪里开始。
有很多反对自动化测试的程序员,他会给你很多他认为自动化测试不重要的理由。但如果有机会和他深入地聊进去,你会发现,本质的答案是他不会写测试。如果你非要问他测试如何写,他只能给你一些很宏观的角度,比如从接口上去测试、按照需求去测试云云。你会发现,这些原则上正确无误的说法,其实并不能很好地指导你的工作。讨论那么多,能力不足是原罪。
你的代码真的是高质量的吗?
刚刚我们聊了程序员写测试是大势所趋,以及大部分程序员并不会写测试。可能这还不能够完全说服你来写测试。那么现在我们不妨花一分钟的时间,来仔细想想这样一个问题:你对你编写的代码有信心吗?你能拍着胸脯说这是高质量的代码吗?
等等,这不是《程序员的测试课》吗?为什么这里要说编写高质量的代码?我是走错片场了吗?相信我,你没走错。程序员写测试就是为了编写高质量的代码。这里所说的高质量代码分成两个部分,一方面自然是我们常规理解的:经过测试的代码,质量会更高。另一方面,要想写好测试,代码本身的质量也要高。
对于今天的程序员来说,写测试就是程序员本职工作的一部分。毕竟,如果你连测试都做不好,那你对自己代码的信心从何而来呢?
给你讲个就发生在我身边的故事。有一次培训,我问了一个问题,作为一个程序员,在每次代码提交之前,你对自己编写的代码很有信心的请举手。有不少程序员骄傲地举起了手。我接着问,那你的信心是从哪来的呢?一个程序员回答说,我工作这么多年了,这点自信还是有的。嗯,不错。
你在提交之前,会验证一下吗?大部分程序员的手还是高高地举着。你是验证了这次编写的代码呢?还是验证所有的代码呢?很多人一脸茫然。一个程序员说,我能保证自己的代码没问题就行了,怎么能有时间验证所有的代码呢?那你怎么保证自己写的代码没有破坏已有的代码呢?不是还有测试同学吗?我顺便问了测试同学,你们会验证系统中所有的功能吗?一个测试同学说,我们也想,但功能太多了,验证不过来。
是的,这才是大多数团队在实际开发中的真相。大多数人对于编写代码只是有一种凭空的自信,我们并不知道每次提交的代码到底有多大的影响。所以,我们常常看到在生产环境中出了问题,定位半天居然是一个简单的错误。很多团队对于高质量代码的追求其实只是一种幻象。
这一次,我们就来一起打破幻象,学习编写真正的高质量代码。
学习写测试
怎么样才能学会写测试呢?最好的办法是跟着会写测试的人一起写一段时间,但整体行业的环境决定能提供这样机会的公司少之又少。大部分人学习测试,还是要通过阅读书籍。所以,经常有人让我推荐关于测试的书,遗憾的是,我确实没什么可以推荐的。
这些关于测试的书,要么是告诉你一些框架工具怎么用,这种东西通常看文档就能解决;要么是讲实践,比如 TDD但还是那个问题作者解决问题很爽但和你有什么关系呢归根结底缺少一根主线把所有这些东西连起来让测试的知识成为一个整体。
所以,这次我准备了《程序员的测试课》,尝试把“一个程序员在日常工作中如何编写自动化测试”的相关知识梳理一遍,从实战出发,解除你对测试的一些误解,教会你一些上手可用的方法。
我把这个专栏分成了三个部分。
基础篇,为你讲解关于测试的基础知识。不同的是,在讲解具体的内容前,我会带你先从一个实例入手,让你看看怎么样用带测试的方式编写一段代码,告诉你一个新项目如何去做测试。当我们有了对于编写测试一个直观的认识之后,再来了解具体的测试知识,就可以有更深刻的体验了。
应用篇,为你介绍在一个后端项目中可以怎样做测试。在这个部分,我们同样会以实战开始,主要讲解使用 Spring 框架如何做测试。之所以选择 Spring 框架,一方面,它的使用非常广泛;另一方面,它对测试提供了非常好的支持。
扩展篇,为你介绍 TDD 和 BDD 两项开发实践。这两项实践离很多人的实际工作是有距离的,之所以大多数人不采用这样的工作方式,思维习惯是一方面,还有一方面就是欠缺测试的基础。当我们经过这个专栏的前面部分铺垫了测试的基础之后,再来看这些实践,你会有不一样的感受。
写在最后
最后,还是要做一个自我介绍。我叫郑晔,一个写代码超过二十年的程序员,做过与软件开发相关的各种工作:编代码、带团队、做咨询、写开源……
我已经在《极客时间》上写了三个专栏,把自己对于软件开发方方面面的思考总结在其中。所以,在这个专栏中,你常常会看到其他三个专栏的影子:
开发代码之前要做任务分解这是《10x 程序员工作法》讲过的工作原则;
代码要可测,这是《软件设计之美》讲过的衡量设计优劣的一个重要标准;
代码要小巧,这是《代码之丑》讲过的代码追求的目标;
……
所以,如果你能把这几个专栏放在一起学习,一定会功力大增。另外,这个专栏中的实战部分,也算是给老同学们的一项福利,你们呼吁的实战环节,终于在这里成真了。
不过,即便你是新同学也无妨,从头到尾学习这个专栏,你就能收获到关于自动化测试的完整认知。为了不让有些同学失望,有一点我需要提前强调一下,这个课是给程序员的测试课,而非测试人员的测试课。所以,我们这个专栏的重点是如何做好自动化测试,而不是各种测试用例的设计方法。当然,如果有测试同学想深入到自动化测试,也欢迎你的加入。
准备好和我一起编写高质量的代码了吗?欢迎你加入我的专栏,让我们一起修炼,日益精进写代码的手艺!

View File

@ -0,0 +1,418 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 实战:实现一个 ToDo 的应用(上)
你好,我是郑晔。
这一讲是我们整个专栏的第一节课。我在开篇词里说过,很多程序员之所以不写测试,一个重要的原因是不会写测试。所以,我们不玩虚的,第一节课直接带你上手实战。
我们要实现的是一个 ToDo 的应用,选择一个新项目开始,我们没有历史负担,对于学习如何写测试来说,是最容易的。整个实战分为了上下两节课,在这节课里,我们先来实现业务的核心部分,下一节课,我们把完整的应用实现出来。
这个 ToDo 应用本身非常简单,实现功能并不是我们这两节课的重点。一方面,你会看到如何解决问题的过程,比如,如何分解任务、如何设计测试场景、如何把测试场景转换为一个测试用例等等;另一方面,你也会看到,我是如何运用这些解决问题的过程一点点把问题解决掉,在整个开发的过程中如何写测试、写代码。
当你在实际工作中面对更复杂的问题时,这里面的代码对你的帮助可能不大,但这些解决问题的思路却会在工作中实际帮助到你。如果你订阅过我的前几个专栏,这算是一个完整的实战练习。
项目前的准备
在正式开始之前,我们一块来看下这个 ToDo 应用都有哪些具体需求接下来的代码我会用Java来写如果你没有购买过我的其他课程也没有关系极客时间有免费试读额度欢迎你点击文章里的超链接进行学习
添加 Todo 项。
todo add <item>
1. <item>
Item <itemIndex> added
完成 Todo 项。
todo done <itemIndex>
Item <itemIndex> done.
查看 Todo 列表,缺省情况下,只列出未完成的 Todo 项。
todo list 1. <item1> 2. <item2>
Total: 2 items
使用 all 参数,查看所有的 Todo 项。
todo list --all
1. <item1>
2. <item2>
3. [Done] <item3>
Total: 3 items, 1 item done
如果你订阅过我的《代码之丑》,你会发现,它就是我在《代码之丑》中给你布置的课堂练习作业的第一部分。如果你想对今天的内容有更深刻的理解,不妨先停下来,自己实现一遍这个需求,然后,再回过头看我是怎样解决这个问题的,对比一下实现方式的差异,记得要写测试哦!
为了厘清主线,不受细节的干扰,我在正文中只罗列了最关键的部分代码。如果你想看完整的代码,我在 GitHub 上为此专门建了一个项目,你可以去参考。
具体的需求有了,我们接下来怎么动手实现这个应用呢?我们先来做一些基础的准备工作:
一个项目自动化;
对需求进行简单的设计。
为什么要先从这些东西做起呢我在《10x 程序员工作法》中曾经介绍过迭代 0 的概念,这是一个项目开始的基础准备,当然,因为我们这个是一个练习项目,所以,准备的内容相对来说,还比较少。
为什么要准备项目自动化呢简单来说就是防止自己犯一些低级错误。关于这个项目自动化中包含了哪些内容我在《10x 程序员工作法》中也专门用了一讲的篇幅介绍,你如果有兴趣不妨去了解一下。
接下来,我们就要进行一些简单的设计了。
设计先行
虽说这个部分的要求是一个命令行的应用,但我们要知道,一个系统的业务核心与它呈现的方式并不是耦合在一起的。也就是说,命令行只是这个 ToDo 应用的一种呈现形式。在专栏后面你会看到,我们也可以把它改造成一个 REST 服务。
所以,我们首先要做一个设计,把核心的业务部分和命令行呈现的部分分开。在我们的工程中,它们分别被放到了两个模块里,一个是 todo-core用来放置核心的业务部分一个是 todo-cli用来放置命令行相关的处理。这一讲我们主要先来解决核心的业务部分至于命令行相关的处理那会是我们下一讲的主题。
我们先来处理核心的业务部分。这里的核心业务是什么呢?根据前面的需求,就只有三个操作:
添加一个 Todo 项;
完成一个 Todo 项;
Todo 项列表。
接下来,我们可以用 DDD 战术设计的方法进行一下识别各个概念(如果你不了解战术设计的基本过程,可以去看看《软件设计之美》中的关于战术设计的过程)。
首先是名词,这里我们的核心对象只有一个,就是 Todo 项。Todo 项的核心字段就是它的内容,也就是我们在命令行里写下的内容。
有了名词,我们就要识别动作了。我们先来看领域服务,这里我们可以有一个 Todo 项服务,对应着我们的操作,它应该包含三个方法:
addTodoItem添加 Todo 项;
markTodoItemDone完成一个 Todo 项;
list列出所有的 Todo 项。
我们应用的需求比较简单,核心对象只有 Todo 项一个,也就不牵扯到多个对象的协同,所以我们这里就暂时不涉及到应用服务的设计。
服务只是操作,最终还要有一个地方把操作的结果存起来,在 DDD 中,这是 Repository 扮演的角色。所以,我们这里还需要一个 Todo 项的 Repository 用来处理与持久化相关的接口。
很多人一看到 Repository 这个概念,首先想到的是数据库,但正如你所见,这里并没有出现数据库。所以 Repository 并不是与数据库绑定在一起的,它只表示一种持久化的机制。在我们的这个版本实现里,这个 Repository 将会是一个文件的版本。
现在基本的设计有了,我们就要准备开始实现这个设计了。
任务分解
要从哪里开始实现呢?我们要从离我们需求最近的入口开始。通常来说,这个起点是应用服务,但是我们这里暂时没有应用服务,所以,我们可以从领域服务开始。
我们就按照需求的先后顺序,依次实现每个服务,首先是添加 Todo 项。
如果按照很多人通常的习惯,添加 Todo 项,就是创建一个 Todo 项,然后存在 Repository 里面。但这对我们的测试课来说是不够的,我们还得考虑一下这个行为要怎么测试。
要想测试一个函数,一个函数最好是可测的。什么是可测的?就是通过函数的接口设计,我们给出特定的输入,它能给我们相应的输出。所以,一个函数最好是有返回值的。我们可以这样来设计添加 Todo 项的函数接口。
TodoItem addTodoItem(final TodoParameter todoParameter);
在这个函数签名中TodoItem 表示一个 Todo 项,而 TodoParameter 表示创建一个 Todo 项所需的参数(很多人可能会选择字符串作为入口参数,我曾经在《代码之丑》中讲过,使用一个更有业务含义的名字,比直接使用基本类型会更清楚)。
有了这个函数签名,我知道你已经迫不及待地要开始写测试了。但请稍等一下,我们要先来考虑一下测试场景,因为很多人写代码只会考虑到正常的场景,趁着我们还没开始写代码,最好把能想到的各种场景都考虑一下。
首先想到的是添加一个正常的字符串,这是我们的正常情况,没有问题。但是,如果添加的字符串是一个空的,我们该怎么处理呢?
一般而言,处理空字符串的方式有两种。一种是返回一个空的 TodoItem一种是抛出一个异常。到底使用哪种做法我们要考虑一下二者语义的差别。返回一个空的 TodoItem表示这是一个可以接受的场景。而抛出一个异常表示这不是一个正常的场景它是一个“异常”。
就我们这里的场景而言,我们要从业务思考一下,确实有人可能在调用我们的命令时给出的参数是空,但考虑到 Fail Fast 原则,这种错误属于入口参数错误,应该在入口检测出来,不应该传到业务核心里面。
所以,我们可以将空传给业务核心部分视为“异常”。同时,我们也确立好了一条设计规范:对于输入参数的检测,由入口部分代码进行处理。
基于这条设计规范的考虑如果是一个空的字符串那么根本就不应该传到我们的领域服务中应该直接在入口参数检测中就应该消灭掉。换言之TodoParameter 就不会出现空字符串。所以空字符串这个事,我们就暂且不考虑了。
不过,这倒给我们提了一个醒,如果是 TodoParameter 为空呢?这种情况也不应该出现,所以我们可以把它当做异常来处理。
现在,我们这里就有了两个测试场景:
添加正常的参数对象,返回一个创建好的 Todo 项;
添加空的参数对象,抛出异常。
也许你还会想到几个场景,比如如果字符串重复了怎么办?答案是从目前的业务要求来说,字符串重复是可以接受的,只是添加了一个新的 Todo 项。所以,不需要为它做什么特殊的处理。
再有,如果存储到 Repository 的过程中出现了问题,比如磁盘满了,这样的问题属于不可恢复的异常,我们在业务处理中也做不了什么特殊的处理,只能把它抛出去。
一般来说,这种异常可以由 Repository 直接抛出一个 Runtime 异常我们在业务处理不需要做什么。所以我们这里可以确立另外一条设计规范Repository 的问题以运行时异常的形式抛出,业务层不需要做任何处理。
好,我们现在已经考虑了最主要的场景,下面就到了动手写代码环节了。
编写测试
我们从第一个测试场景开始,这个场景关注的是正常的参数对象。我们首先要做的是,把测试场景具象化成一个测试用例。
把测试场景具象成一个测试用例,也就是要把测试场景中空泛的描述变成一个个具体的参数。比如,添加正常的字符串。什么叫一个正常的字符串呢?在我们这个案例里面,它是相对于空字符串而言的,所以,我们这里需要给出一个非空的字符串。
如果有业务人员给我们一个具体的例子那是最好如果没有我会使用一些在测试中常用的词汇比如foo、bar 之类的。
到这里,我们就很容易写出一个测试的基本结构。
@Test
public void should_add_todo_item() {
TodoItemRepository repository = ...
TodoItemService service = new TodoItemService(repository);
TodoItem item = service.addTodoItem(new TodoParameter("foo"));
assertThat(item.getContent()).isEqualTo("foo");
}
你会发现这还是一段未完成的代码,原因就在于,我们还没有对 repository 这个变量进行处理。我们现在处理的重点是在领域服务上,而 TodoItemRepository 到底要怎么实现,我们还没有考虑。
我们现在对于 TodoItemRepository 的述求是它只要有一个 save 接口就好,至于它是数据库还是文件,根本不是我们现在关心的重点。
只有一个接口,我们该怎么用它呢?我们可以用 Mock 框架模拟出一个有这样行为的对象。Mock 框架就是根据预期的参数,给出相应的结果,这个结果可能是返回值,也可能是抛出异常。关于 Mock 框架更多的介绍,我们会在后面的部分专门讲解。
下面是增加了 repository 初始化的代码。
@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");
}
这里我们用到的 Mock 框架是 Mockito这里面有一句代码你或许会有点陌生。
when(repository.save(any())).then(returnsFirstArg());
这句代码表示当我用任意参数调用这个 repository 对象的 save 方法时,我预期它返回第一个参数作为返回值。对应到我们这里的语义,就是存进去什么对象,就返回什么对象。
另外,这里面用到的断言程序库是 AssertJ它的 API 是流畅风格的 APIFluent API也就是连着的点点点。
有了这个测试,实现相应的代码就很容易了,相信你也很容易做到。
public TodoItem addTodoItem(final TodoParameter todoParameter) {
final TodoItem item = new TodoItem(todoParameter.getContent());
return this.repository.save(item);
}
这里最核心的 TodoItem 目前只包括一个内容的字段。
@Getter
public class TodoItem {
private final String content;
public TodoItem(final String content) {
this.content = content;
}
}
接下来,我们再来实现下一个测试。有了第一个测试的基础,第二个测试的关注点是空对象,你也应该能够很容易得写出来。
@Test
public void should_throw_exception_for_null_todo_item() {
assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> service.addTodoItem(null));
}
根据第二个测试,我们的 addTodoItem 方法就需要增加一条对于空对象的处理。
public TodoItem addTodoItem(final TodoParameter todoParameter) {
if (todoParameter == null) {
throw new IllegalArgumentException("Null or empty content is not allowed");
}
final TodoItem item = new TodoItem(todoParameter.getContent());
return this.repository.save(item);
}
至此,添加 Todo 项的任务也算完成,我们可以运行一下命令做一下检查,看看我们是否有遗漏。
./gradlew check
这里的遗漏可能是由于编码风格,也可能是由于代码覆盖率导致,这也是我们为什么要把项目自动化放在最前面完成的原因。后面每完成一个任务,也应该运行一下这个命令,同样的事情,后面我就不再重复了。
到这里,关于如何添加测试编写代码的最基本思路,我们已经讲清楚了。接下来,我们来完成一个 Todo 项。完成 Todo 项的接口是这样的。
TodoItem markTodoItemDone(TodoIndexParameter index);
这里的入口参数是一个索引,只不过这里做了一次封装,封装出一个 TodoIndexParameter。
针对这个接口,我们考虑的测试场景包括:
对于一个已经存在的 Todo 项,将其标记已完成;
如果索引超出现有的索引范围,则返回空。
对于一个索引,你可能也会想到索引为负的场景。但同之前一样,这个问题应该是属于在入口就检验出来的问题,所以我们封装一个 TodoIndexParameter这样在业务层就不需要考虑索引为负的场景了。
对于最后一个场景,当索引超出索引范围,返回空。鉴于空指针总是一个容易引起问题的场景,所以,我们这里采用 Optional 替代直接返回对象(关于 Optional 使用的基本思路,我在《软件设计之美》中讲过,如果你感兴趣可以去回顾一下)。
Optional<TodoItem> markTodoItemDone(TodoIndexParameter index);
我们先来编写这个接口的第一个测试。
@BeforeEach
public void setUp() {
this.repository = mock(TodoItemRepository.class);
this.service = new TodoItemService(this.repository);
}
@Test
public void should_mark_todo_item_as_done() {
when(repository.findAll()).thenReturn(ImmutableList.of(new TodoItem("foo")));
when(repository.save(any())).then(returnsFirstArg());
final Optional<TodoItem> todoItem = service.markTodoItemDone(TodoIndexParameter.of(1));
assertThat(todoItem).isPresent();
final TodoItem actual = todoItem.get();
assertThat(actual.isDone()).isTrue();
}
因为 service 的初始化和 repository 这个模拟对象的初始化几乎所有正常路径都要用到,所以,我们把它挪到 setUp 方法中,以便每个测试之前都能够运行它。
这个版本的实现采用了最为粗暴的方案,把所有的 Todo 项都加载到内存中,然后根据索引进行筛选。所以,这里我们用到了 findAll 方法。
这个实现不难,重要的变化是 TodoItem 需要有一个字段标记它的完成,代码如下。
@Getter
public class TodoItem {
private final String content;
private boolean done;
public TodoItem(final String content) {
this.content = content;
this.done = false;
}
public void markDone() {
this.done = true;
}
}
后面两个测试场景以及相应的实现代码,你可以参考开源项目中的代码,这里就不一一罗列了。
最后是 Todo 项列表,它的接口相对比较简单。
List<TodoItem> list(final boolean all);
其中all 参数为 true 时,列出所有的 Todo 项false 的时候,列出未完成的 Todo 项。
在需求中,缺省情况罗列的是未完成的 Todo 项,这是过滤掉已完成的 Todo 项的结果。但是,如果我们简单的采用按照列表的顺序作为索引,这就产生一个问题,每当有一个 Todo 项完成之后,剩余 Todo 项的列表顺序就会发生改变,这其实是不合适的。所以,我们最好把索引放到 Todo 项的实体中。
@Getter
public class TodoItem {
private long index;
private final String content;
private boolean done;
public TodoItem(final String content) {
this.content = content;
this.done = false;
}
public void assignIndex(final long index) {
this.index = index;
}
public void markDone() {
this.done = true;
}
}
这里我们把索引的赋值可以在服务中完成,也可以在 Repository 保存的过程中完成。从目前的情况看,这个索引的值与 Repository 现有的 Todo 项个数紧密相关,所以,我们可以把它放在 保存到 Repository 的过程中完成。也就是说,保存一个 Todo 项时,如果这个 Todo 项没有索引,就为它赋一个索引,如果有索引,就更新相应的 Todo 项。
针对这个接口,我们考虑的测试场景包括:
如果有 Todo 项,罗列 Todo 项时,列出所有的 Todo 项;
如果没有 Todo 项,罗列 Todo 项时,列出 Todo 项为空;
如果有未完成的 Todo 项,罗列未完成 Todo 项,列出所有未完成的 Todo 项;
如果没有未完成的 Todo 项,罗列未完成 Todo 项,列出的 Todo 项为空。
具体的代码也不在这里罗列了,你可以参考开源项目中的代码。
有时你会发现,虽然我们列出了很多测试场景,但当我们有了一些基础的代码之后,一些测试刚写完就通过了。比如,如果我们先写了罗列 Todo 项和罗列未完成 Todo 项的代码,后面两个测试场景很可能自然地就通过了。
这种情况在写测试的时候是很常见的,这说明,我们前面的代码已经很好地处理了这些情况。这并不说明这些测试场景是无用的,因为不同的实现方式并不能保证这些测试都是通过的,所以,既然我们已经为它们写了测试,保留在那里就好了。
到这里,我们已经把最核心的业务代码写完了,当然,它还不能完整地运行,因为它没有命令行的输入,也没有实现 Repository 的存储。但有了一个稳定的核心,这些东西都好办。下一讲,我们就来把这些东西都连接起来。
总结时刻
在这一讲里,我们实现 ToDo 应用的核心业务部分,这里面的重点并不是把代码写出来,我相信你有能力去编写完成这段代码。我在这里的描述更多的是在一个项目启动的初期要关注哪些内容,以及如何去着手去编写测试。
项目刚开始时,我们要准备哪些内容:
项目的自动化;
针对需求进行初步的设计。
着手编写代码时,我们要怎么做呢?
对要实现的需求进行任务分解;
在一个具体的需求任务中,我们可以从需求入口开始入手;
设计一个可测试的函数;
针对具体的函数,考虑测试场景;
针对具体的测试场景,将场景具象化成测试用例。
在梳理的过程中,我们还会针对一些统一的情况作出一些约定,成为项目整体的设计规范,比如,在这里我们约定:
对于输入参数的检测,由入口部分代码进行处理;
Repository 的问题以运行时异常的形式抛出,业务层不需要做任何处理。
在编码的过程中,我们也看到了:
根据不断增加的需求,逐渐改动我们的设计,这就是演化式设计的基本做法;
我们对待测试也像对待代码一样,会消除代码中存在的一些坏味道。
如果今天的内容你只能记住一句话,那么请记住,细化测试场景,编写可测试的代码。
思考题
今天我分享了从一个需求入手,如何一步一步地写出测试。你在实际工作中是怎么做测试呢?如果你如果不做的话,原因又是什么呢?欢迎在留言区分享你的所见所闻。
参考资料
迭代0启动开发之前你应该准备什么
一个好的项目自动化应该是什么样子的?
战术设计:如何像写故事一样找出模型?

View File

@ -0,0 +1,354 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 实战:实现一个 ToDo 的应用(下)
你好,我是郑晔!
在上一讲里,我们实现了一个 ToDo 应用的核心业务部分。虽然测试都通过了,但我相信你可能还是会有一种不真实的感觉,因为它还不是一个完整的应用,既不能有命令行的输入,也不能把 Todo 项的内容真正地存储起来。
这一讲,我们就继续实现这个 ToDo 应用,把欠缺的部分都补上。不过,在开始今天的内容之前,我仍需要强调一下,之所以我要先做核心业务部分,因为它在一个系统中是最重要的。很多人写代码的时候会急着把各个部分连接起来,但却忽视了核心业务部分的构建,这样做造成的结果就是严重的耦合,这也是很多后续问题产生的根源。
在上一讲里我们已经有了一个业务内核现在还欠缺输入输出的部分也就是如何将Todo 项保存起来,以及如何接受命令行参数。
接下来,我们就分别来实现这两个部分。
文件存储
我们先来实现 Todo 项的存储。在上一讲中,我们已经预留好了存储的接口,也就是 Repository 这个接口。现在,我们只需要给这个接口提供一个相应的实现就好了。我们先来看看 Repository 接口现在是什么样子。
public interface TodoItemRepository {
TodoItem save(TodoItem item);
Iterable<TodoItem> findAll();
}
出于简单的考虑,我们要实现一个基于文件的存储。也就是说,给这个接口提供一个基于文件的实现版本。
首先,我们要决定一下把这个实现放到哪里。还记得我们一开始就分了两个模块吗?这两个模块一个是 todo-core用来存放核心业务的代码一个是 todo-cli用来存放与命令行相关的代码。
那么这个基于文件的实现应该算在哪里呢?
其实放在哪里都可以讲出一定的道理。放在 todo-core 中,它算核心业务提供的一个实现,供外围使用;放在 todo-cli 中,它就是一个与 CLI 实现相关的部分。
既然都可以,我更倾向于放在 todo-cli 这个模块里,原因是我们最好保持核心业务的小巧,等到以后有机会遇到它需要提供给其它模块使用时,我们再来考虑把它挪到 todo-core 中。
确定了它的模块归属之后,我们进入到具体的工作中,先来确定它的测试场景:
使用 findAll 查询空的 Repository ,返回一个空的列表;
保存了 Todo 项之后,查询 Repository 返回保存了 Todo 项的列表;
修改已保存的 Todo 项,保存之后,查询 Repository 得到的应该是修改过后的 Todo 项;
保存空的 Todo 项,会抛出异常。
临时文件
与之前的测试完全可以在内存中执行不同,这回的测试要用到文件。为了保证测试是可以重复执行的,我们要确保所有的资源在执行之后要恢复原样。内存资源恢复原样是没有问题的,那文件怎么办呢?
文件是一个外部资源,如果用到的是一个普通文件,我们需要确定这个文件要存放在哪里、需要在保证测试执行之后把测试写入的内容清理掉……总之,有不少细节要考虑。所幸,在测试中使用文件是一种特别常见的需求,像 JUnit 这样成熟的框架已经给了我们一个标准答案,那就是临时文件。
更准确地说JUnit 给出的方案是临时目录,在这个目录里,你怎么折腾都行。我们只要给一个变量标记上@TempDir,这个变量可以是作为一个测试函数的参数,也可以是一个测试类的字段。下面是我们的测试用例,在这里我们给类的一个字段标记上了@TempDir
class FileTodoItemRepositoryTest {
@TempDir
File tempDir;
private File tempFile;
private FileTodoItemRepository repository;
@BeforeEach
void setUp() throws IOException {
this.tempFile = File.createTempFile("file", "", tempDir);
this.repository = new FileTodoItemRepository(this.tempFile);
}
@Test
public void should_find_nothing_for_empty_repository() throws IOException {
final Iterable<TodoItem> items = repository.findAll();
assertThat(items).hasSize(0);
}
...
}
文件编解码
有了测试,我们还需要考虑实现的问题。存储到文件里,必须要考虑的一个问题就是编解码的问题,也就是用什么样的格式进行文件存储,这是我们要做的一个设计决策。出于简单的考虑,我准备采用 JSON 这种最常见的格式。因为 JSON 格式的编解码有很多现成的方式,我们就不需要专门的处理了。
处理 JSON 格式,我选择的程序库的是 Jackson这是行业中最主流的 JSON 处理程序库。就当前的情况来说,这个依赖只与 todo-cli 这个模块相关,所以,我们把 Jackson 的依赖添加到这个模块的构建脚本即可,也就是 todo-cli/build.gradle。
dependencies {
implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
}
这里的 jacksonVersion 是一个变量,我们把它配置在整个项目的 gradle.properties 文件里,方便对于依赖的管理。
jacksonVersion=2.12.3
添加了新的依赖之后,我们需要重新生成一下 IDEA 的工程,依赖就更新了,随后我们就可以继续工作了。
./gradlew idea
测试覆盖率
有了这个基础我们可以很容易地把代码实现出来比如findAll 的实现就是下面这样。
@Override
public Iterable<TodoItem> findAll() {
if (this.file.length() == 0) {
return ImmutableList.of();
}
try {
final CollectionType type = typeFactory.constructCollectionType(List.class, TodoItem.class);
return mapper.readValue(this.file, type);
} catch (IOException e) {
throw new TodoException("Fail to read todo items", e);
}
}
当通过了所有的测试,我们就要提交代码了。在此之前,我们需要运行提交脚本。
./gradlew check
当我们很快地解决大部分像代码风格之类的低级问题之后,有一个问题就会卡住我们:测试覆盖率。
测试覆盖率给了我们发现代码问题的机会。我在构建脚本设定的测试覆盖率是 100%,所以,只要有测试覆盖不到的地方就会被发现。打开测试覆盖率的报告(具体位置在 $buildDir/reports/jacoco/index.html它就会提醒我们哪里没有覆盖到就像下面这样。
对于一些简单的场景,我们可以通过增加或调整测试就可以提高测试覆盖率。但有些问题就不是简单调整能够解决的。比如这里的异常处理,就像上面覆盖率报告中的 IOException。遇到这种情况你会怎么办
最糟糕的做法是,有测试不好覆盖,就认为测试没有价值,然后彻底放弃测试。这显然不是我们的选项。如果我们坚持测试,要怎么通过这一关呢?
一种做法是不分青红皂白,统一降低对于测试覆盖率的要求,也就是修改构建脚本中的设置。虽然这种做法可以让我们临时通过这一关,但这却会留下后患:以后有其它本可以测试覆盖到的部分,由于测试覆盖率的降低也会被忽略。
再有一种做法,就是把这些异常造出来。如果你运气好,有些异常可以通过看接口来大概猜测是怎么产生出来的。像这里的这段代码,如果出现异常很可能就是 JSON 格式不合法造成的。但有时候,我们需要仔细研究这个程序库的源代码,才能知道这个异常是怎么产生的。
知道异常怎么产生的是第一步,接下来,还需要制造出这个异常。像不合法的 JSON 格式还好办有些异常则是你很难造出来的。比如如果我们用到反射API 会抛出 ClassNotFoundException但只要你这个类加载了就不会抛出 ClassNotFoundException。
我们需要知道的一点是,我们测试的目标是我们的代码,而不是这个难以测试的程序库。除非这个异常对我们来说至关重要,否则,为了写测试,去研究另外一个程序库,显然有点本末倒置了。
这也不行,那也不行,我们还有办法吗?通常来说,这种没法屏蔽掉的异常来自另外一个程序库,而使用这个程序库对我们来说,都是一些实现细节,那么我们可以将这些细节给封装起来。比如在前面代码里,抛出异常的主要是 readValue 这一句,它实现的就是一个文件中读取对象,我们可以把它封装到一个 JSON 处理的类中。
public final class Jsons {
private static final TypeFactory FACTORY = TypeFactory.defaultInstance();
private static final ObjectMapper MAPPER = new ObjectMapper();
public static Iterable<TodoItem> toObjects(final File file) {
final CollectionType type = FACTORY.constructCollectionType(List.class, TodoItem.class);
try {
return MAPPER.readValue(file, type);
} catch (IOException e) {
throw new TodoException("Fail to read objects", e);
}
}
...
}
我们在这里将异常封装成我们内部的运行时异常外面就可以不用捕获处理了。相应地findAll 的处理就可以调用这个封装出来的代码。
@Override
public Iterable<TodoItem> findAll() {
if (this.file.length() == 0) {
return ImmutableList.of();
}
return Jsons.toObjects(this.file);
}
经过这个改造FileTodoItemRepository 就可以由测试完全覆盖了。或许你还会担心那个新的 Jsons 类没有办法测试覆盖。对于这个类,我们的方案是忽略掉它,不去做覆盖。处理手法就是在构建脚本中将它排除在测试覆盖之外。
coverage {
excludeClasses = [
...
"com.github.dreamhead.todo.util.Jsons"
]
}
为什么我们可以忽略它?一方面,这段代码很简单,几乎没有逻辑,因为它只是一个调用的封装。另外一方面,这里面主要的代码不是我们编写的,正如前面所说,我们测试的主要目的是测试我们自己写的代码,而不是别人的程序库。
这里小结一下,由于其它程序库造成难以测试的问题,我们可以做一层层薄薄的封装,然后,在覆盖率检查中忽略它。封装和忽略,缺一不可。
至于其它部分更具体的代码,我就不在这里展示了,你可以到开源项目中去查看细节。到这里,我们已经有了可以存储 Todo 项的仓库。基础已经具备,接下来,我们就要把所有这些东西都连起来,给它一个入口。
命令行入口
编写命令行入口,我们要选择一个程序库,省得自己从头编写各种解析的细节。在这里,我选择的程序库是 Picocli。
这个程序库可能你对它不是那么熟悉。那么对于一个新程序库来说,你的关注点是什么呢?绝大多数人拿到一个新程序库,重点都是赶紧让它跑起来,只要程序能够运行,其它的就不在乎了,甚至用来测试程序库怎么用的代码,最终也成为了代码仓库的一部分。
请千万记住,用来试验的代码永远是用来试验的代码。一旦我们掌握了一个程序库的基本用法,接下来,我们应该抛弃掉试验代码,重新设计,按照它应有的样子来使用这个程序库。
接口的选择
面对新的程序库,还有一个问题我们可能会忽略。有些程序库对同样一件事可以有多种不同的处理方式。比如就 Picocli 而言,同样是处理一个命令的参数,可以把它当做一个类的字段,像下面这样。
class AddCommand ...
@Parameters(index = "0")
private String item;
...
}
也可以当做一个函数的参数。
class AddCommand ...
public int add(@CommandLine.Parameters(index = "0") final String item) {
...
}
}
你会选择哪种做法呢?我的答案是选择可测试性好的。
就上面两种做法而言,同样是要做单元测试,第一种字段的方式,我需要通过反射的方式设置这个字段的值;而第二种参数的方式,我只要传参就好了。显然,第二种方式更简单。
或许你会好奇,既然第二种方式更简单,那为什么还会有第一种方式呢?因为如果你不考虑测试而只考虑写代码的话,第一种方式用起来更容易。
一个是容易写,一个是容易测,这就是两种不同编码哲学的取舍。
当然,这个讨论是在我们有选择的情况下进行的,有些程序库并没有给我们提供这些选择。很多程序库只有一种做法,而且通常是容易写的做法,这个时候单元测试就比较麻烦。不过通常来说,这种情况都出现在边缘的部分,我们可以考虑这个部分的测试是用单元测试,还是用集成测试。
测试的选择
做好了基础的准备,现在我们准备开始测试了。同样,我们也要准备测试场景。在命令行接口我们要测的是什么呢?其实,主要的业务逻辑已经在前面的测试中覆盖到了,命令行接口主要就是完成与用户输入相关的一些处理。
还记得前面我在讨论业务处理时遗留的内容吗?没错,用户输入相关的一些校验要放在这里来做,剩下的就是转给我们领域服务的代码,也就是 TodoItemService。
有了这个理解,我们来罗列一下测试场景:
添加一个正常的 Todo 项,该 Todo 项可以查询到;
添加一个空的 Todo 项,提示参数错误;
标记一个已有的 Todo 项为完成,该 Todo 项的状态为已完成;
标记一个不存在的 Todo 项为完成,提示该项不存在;
标记一个索引小于 0 的 Todo 项为完成,提示参数错误;
列出所有 Todo 项,缺省为列出所有未完成的 Todo 项;
用“-a”参数列出所有的 Todo 项,列出所有的 Todo 项。
如果你是跟着我一路走到了现在,怎么把这些测试写出来对你来说应该已经不是太大的问题了。但在编写代码之前,还有一个问题要考虑,我们准备写什么样的测试呢?
我们前面编写的测试都是单元测试,也就是针对一个单元进行的测试。如果按照单元测试的编写逻辑来写这段代码,最简单的做法是 mock 一个 TodoItemService 作为参数传给我们的命令类,这种做法本身是没有问题的。
虽然我们能够保证所有的单元正常运作,但这些单元配合在一起是否依然能够正常运作呢?这可不一定。因为除了要保证单元的正确,我们还要保证单元之间的协作也是正确的。你或许已经知道我要说什么了,没错,除了单元测试,我们还需要集成测试。
之所以要在这里讨论集成测试,因为我们前面已经把主要的业务逻辑已经完成了,最后的这部分代码实际上只是对业务逻辑做一个简单的封装,这会是非常薄的一层。所以,这层如果做单元测试,除了参数校验的部分,剩下的主要工作都是转发,将处理逻辑转发给服务层。所以,出于实用的考虑,我们不妨在这里就用集成测试代替单元测试,简化测试的编写。
如果我们在这里准备编写的是集成测试,与编写单元测试不同的一个关键点就是,这里采用的服务对象是真实的对象,而不是模拟对象。这就需要我们按照业务对象的组装规则将真实的对象组装起来。在我们这个例子里面,因为涉及的对象都比较简单,所以,我们暂且采用直接对象组装的方式。在很多项目里面,对象组装的工作是由 DI 容器完成的。
为了保证组装过程的一致,我们可以把组装过程单独拿出来,让最终的代码和测试代码复用同样的逻辑。
public class ObjectFactory {
public CommandLine createCommandLine(final File repositoryFile) {
return new CommandLine(createTodoCommand(repositoryFile));
}
private TodoCommand createTodoCommand(final File repositoryFile) {
final TodoItemService service = createService(repositoryFile);
return new TodoCommand(service);
}
public TodoItemService createService(final File repositoryFile) {
final TodoItemRepository repository = new FileTodoItemRepository(repositoryFile);
return new TodoItemService(repository);
}
}
这个组装逻辑本身没有任何复杂的地方,不过,有一点是需要我们在写这段代码时要考虑清楚的,就是把组装的边界设置在哪里。换句话说就是把什么样的部分放在组装过程中,什么样的部分不放。因为放太多的话,测试可能会不方便;太少的话,会让集成本身变得意义不大。
在上面这段代码里,我们把边界设置在了文件接口,也就是 createService 这个函数的参数。这样处理的话,在产品的代码中,我们可以就用正式的文件;而在测试环境中,就可以采用临时文件。
class TodoCommandTest {
@TempDir
File tempDir;
private TodoItemService service;
private CommandLine cli;
@BeforeEach
void setUp() {
final ObjectFactory factory = new ObjectFactory();
final File repositoryFile = new File(tempDir, "repository.json");
this.service = factory.createService(repositoryFile);
cli = factory.createCommandLine(repositoryFile);
}
你会看到,在这里我们除了声明最外面的调用接口(也就是 cli )之外,还声明了一个变量 service它是做什么用的呢我们不妨看一下下面这个测试。
@Test
public void should_mark_as_done() {
service.addTodoItem(TodoParameter.of("foo"));
cli.execute("done", "1");
final List<TodoItem> items = service.list(true);
assertThat(items.get(0).isDone()).isTrue();
}
标记一个 Todo 项为已完成,但前提条件是要有一个 Todo 项供你去标记。那怎么把这个 Todo 项添加进去呢?一种做法是调用我们的命令行接口,但要知道,我们在这里测试的目标就是命令行接口,也就是 add而我们这里测试的主要接口是 done。
写测试要尽可能减少对于不稳定组件的依赖done 接口已经是一个不稳定的了,再加上 add测试出问题的概率就会进一步增大。
所以这里我们用了另外一种做法。service 是我们之前已经测试好的组件,我们可以把它看成一个稳定的组件,所以,这里我们使用了 service 添加 Todo 项。
具体的代码你可以参考我的开源项目,这里就不再进一步罗列了。
总结时刻
今天我们在核心业务的基础上,补齐了输入输出的部分。不同于之前所有的代码都是在内存中执行的情况,一旦牵扯到输入输出,我们就要考虑更多的问题。这一讲我们遇到的很多问题,可能也是你在实际的测试工作中会遇到的。
如果你的系统需要与文件打交道:
通过调整设计,将文件注入到模型中;
在测试中使用临时文件;
如果采用的是 JUnit 5可以使用@TempDir 在临时目录下创建临时文件。
如果通过测试覆盖发现了难以测试的第三方代码:
通过做一层薄薄的封装,将第三方代码与你的代码分离开,保证你的代码完全由测试覆盖;
在测试覆盖率中,忽略这层封装。
当我们使用第三方框架时:
与框架紧密结合的代码只是做最简单的接口校验工作,把业务逻辑放在自己的代码里;
如果有多种方式完成一个功能,选择可测试性较好的实现方式。
我们编写集成测试:
是为了保证组件之间协作的正确性;
需要利用与产品代码相同的组件组装过程;
可以把已经测试好的稳定组件当做基础。
如果今天的内容你只能记住一件事,那请记住:隔离变化,逐步编写稳定的代码。
思考题
一旦你开启了对测试的思考,我们就能发现更多的思考角度,比如:控制台输出应该怎么测试?这个问题就是今天留给你的思考题了。在这个现有的项目基础上,增加对于控台输出的测试,你会怎么做呢?欢迎在留言区分享你的做法。

View File

@ -0,0 +1,103 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 程序员的测试与测试人员的测试有什么不同?
你好,我是郑晔!
前面用了两讲的篇幅,我们一起一步一步地用带测试的方式完成了一个项目,现在相信你已经对如何在实际工作中编写测试有了一个初步的认识。有了实践的根基,我们还需要对如何编写测试有一个更全面地理解,以便日后能够更好地应对各种场景。
关于测试,许多程序员的第一个问题就是:测试不是测试人员的工作吗?如果我把测试写了,那是不是就抢了测试人员的工作呢?
不瞒你说,之所以我要把这个话题放在专栏前面讲,一个重要的原因就是我当年真的就这么想过。好,今天我们就来聊聊程序员的测试和测试人员的测试究竟有哪些不一样的地方。
程序员的测试能否替代测试人员的测试?
我给你讲一个我在职业生涯初期的故事。那时候,我刚刚踏上自己的程序员精进之路,我不断地寻找着各种能够更好地写程序的方式。当我意识到测试对于编程的重要性时,我就开始有意识地在写代码的时候编写测试,尽我所能把各种场景都考虑到。作为一个骄傲的程序员,我总是希望自己的代码是无懈可击的。
有一次,我写了一个协议的解析器,我把各种字段缺失或不正确的场景都处理了。结果交给测试同学后,他上来就发了一个空包,然后我的代码就崩溃了。我当时的第一反应是,你怎么能这么做?测试同学却反问,我为什么不能这么做?
是啊,为什么不能呢?测试同学只要能做到,他就可以这么做。而且,只要测试同学能做到,其他人也可能做到。
可以说,这件事彻底改变了我对测试人员的认识。相信很多人和从前的我一样有个偏见,认为测试同学不过是做一些简单的验证,或者只是因为自己时间不充足,有些细节没考虑到,让他们给抓住点小问题。作为程序员,只要自己认真了,其实就没测试什么事了。然而,这次的事告诉我,即便我全力以赴了,测试同学依然可以发现问题。
从此,我对测试人员的看法彻底转变了。在我随后的职业生涯中我发现,只要团队里有合格的测试人员,他总会以你想不到的角度,发现系统中意想不到的问题——即便团队已经写了很多的测试。
说到这,你就可以放心了,即便我们程序员把测试都写好了,测试人员也不会失业,他们总能找到问题。但下一个问题就随之而来了,测试人员的测试和程序员的测试到底有什么不一样,以至于即便是程序员已经很努力了,依然很难做到对测试场景全面的覆盖呢?
答案很简单,因为视角不同。
程序员的出发点是实现,而测试人员的出发点是业务。把这话翻译成你更熟悉的测试术语,那就是程序员的关注点是白盒测试,而测试人员则是黑盒测试。
程序员关注到的测试是站在实现的角度,即便我们能够先去设计测试场景,即便我们有测试覆盖率帮我们查缺补漏,但我们所做的一切都是建立在一个共同的前提下:我们要把代码写出来。
而测试人员则不同,他们并不关心代码是怎么实现的,他们只是站在业务的视角在想问题,他们考虑更多的是这个系统可以怎么用。只要二者的出发点不同,对于同样的事物,总会看到不同的东西。不然的话,人类社会哪有那么多的争论。
你或许会想,那我也从业务的角度去想,是不是就能获得测试人员的视角呢?
我要说,从业务角度思考,确实是我们向测试人员学习的一个重要方向。但同样不要指望你换个角度思考一下就能把测试人员代替了。人的注意力是有限的,作为一个程序员,我们会把更多的时间放在关于技术实现的思考上,我们在发现问题上的训练强度是远远不够的。所以,人们常说,别用你的业余爱好去挑战别人吃饭的本事。
程序员做测试,测试人员也做测试,那是不是测试人员的工作量就小多了?实际上,只要你稍微和测试同学交流一下你就会发现,在实际的工作中,大部分测试同学根本没有机会使出全力。
在测试的分类中,有一种测试叫探索性测试,也就是测试人员竭尽所能去对系统去做测试。不过,虽然有这么个分类,但大多数测试人员并没有机会去做这种测试。不是因为他们偷懒,而是由于大部分系统的基础质量不高,造成的结果就是,测试同学的大部分工作找到的都是极其简单的 bug。换言之如果程序员能够把自己的测试做好很多问题就应该被消灭在萌芽状态根本不应该到测试同学这里。
如果程序员能够提交一个经过自己测试的系统,测试同学才有机会让自己从日常琐碎的工作摆脱出来,去竭尽全力地测试一个系统。不是测试同学不努力,实在是系统太差劲。
在我工作过的测试做得比较好的团队,软件质量整体上来说,确实要好上很多。测试同学有机会进行各种探索性测试,因为基础的问题都会在程序员的测试中被覆盖了。
好,到这里,我们也就回答了一开头的那个问题:程序员的测试不能够替代测试人员的测试。我们也不用替测试人员担心他们的职业前景了。那反过来,既然在测试方面,测试人员还是有着自己的优势,那是不是我们可以从他们身上学到点什么呢?
向测试人员学习
首先,我要帮你纠正一个典型的误区。有一些人认为,测试做得好要依赖于工具。确实,今天的测试已经不像过去那么纯手工了,各种工具层出不穷,甚至很多项目为了测试要开发自己的测试工具。但无论是什么样的测试工具,都只是提升效率的一种手段。如果没有背后的测试思维支撑,再好的工具,也是没用的。
与其纠结于寻找更好的工具,更重要的是要向测试人员学习他们的思维方式。
前面我们已经提到了一点,测试人员拥有业务视角,这是最值得程序员去学习的。通常来说,测试人员对于业务的理解都会很深刻。在我之前的经历里,如果一个团队缺少业务分析师,有时候,我会让一个测试人员顶上去,而且效果往往还不错。
程序员对业务视角的忽略是一个普遍存在的问题,但这也是一个程序员必须要突破的关口。实际上,让程序员拥有业务视角不仅仅是测试的需求,同样也是写好代码的要求。现在流行的 DDD 设计方法的核心就是要让业务人员和开发团队使用一样的通用语言Ubiquitous Language。由此我们知道写代码要尽可能用业务语言写代码。
有了业务视角,再深入一步,我们要学会设计测试。
设计测试的一个关键点是找到更多的测试场景。这里面我们说的测试场景,你可以把它理解成一个大的分支条件。其实很多时候,程序员与测试人员的差距,从测试场景这一步已经开始显现出来了。
很多测试场景程序员压根就没想到,所以,就很难说进一步地去测试了。在前面我自己的经历里,我只想到了协议包不正确的各种情况。但空协议包这种场景在我的思考里压根就没有,所以,我发现不了其中的问题就再正常不过了。
针对测试场景,我们还需要考虑各种情况。在这里我们说的各种情况,你可以把它理解成在一个测试场景下的各种小分支。通常来说,正常情况大家都能想到怎么解决,程序员欠缺的往往是各种异常情况的处理。
举个例子,同样是注册的场景,程序员都会考虑到正常注册的情况。但如果注册的用户名里包含各种符号该怎么办,甚至有些测试人员会想到如果字符串超长的情况该怎么办(比如几 K 字节的字符串),这些都是对不同的异常情况的思考。
诚如前面所说,很难指望每个程序员都能把所有的情况考虑到,但程序员每多想到一点,软件质量就能多提高一点。
在《10x 程序员工作法》中我曾经讲过,每个需求都应该有验收条件。验收条件是很多测试人员设计测试的出发点,这也是我们可以向测试人员学习的最直接方式,当然,前提条件是你们的团队有验收条件。如果没有,那你需要赶紧去建立这项团队实践。
其实在实际工作中,程序员与测试人员如果能够工作在一个团队里,那就有一个更简单的做法来提升软件质量,就是测试人员设计好了测试用例之后在团队内部做一个分享,让相关的程序员能够有一个参考去编写自己的测试。
不过,请你放心,虽然他们已经把测试用例分享给你了,在测试过程中,他们还会发现更多新的测试用例。如果程序员有了编写测试的习惯,那测试人员在测试过程中发现的问题,也可以成为一个新的测试项,成为程序员编写测试的一部分,我们可以用代码把这个用例固化下来。
测试人员把用例分享给程序员,程序员用代码固化新的测试用例,这样,测试人员和开发人员之间就形成了一个良好的互动,我们也就有机会让软件的质量越做越好。
总结时刻
今天,我们谈到可能是不写测试的程序员关于测试问得最多的一个问题:程序员写测试,那测试人员怎么办?
程序员和测试人员拥有不同的视角,程序员更关注实现,而测试人员更关注业务,所以,即便程序员编写测试,也很难覆盖所有的情况。实际上,即便是测试人员也不敢说自己能够覆盖所有的情况。
目前大多数团队的情况是,测试人员并没有得到充分的发挥。只有程序员做好了自己的测试,测试人员才能从日常琐碎的验证工作中解脱出来,去做更有价值的测试。
在测试问题上,程序员应该向测试人员学习,与工具相比,更重要的是思维方式。我们可以像测试人员一样从测试场景入手,多考虑各种情况,尤其是异常情况。需求的验收条件是一个很好的测试起始点。
在团队中,测试人员可以把自己的测试用例分享给程序员,而程序员可以把新的测试用例用代码的方式固化下来,二者就此可以形成良好的互动。
其实,具体确定测试用例的方法有很多,比如边界值分析、等价类划分等等,这都是测试同学会更深入了解的内容,如果这一讲的内容让你对测试产生一些兴趣,你不妨去找本书读读,比如《软件测试的艺术》,或者找个测试同学深入请教一番。
如果今天的内容你只能记住一件事,那请记住:测试从测试场景入手,多考虑各种情况,尤其是异常情况。
思考题
今天我们讲了程序员和测试人员之间的关系,你在实际工作中,从测试同学身上学到了哪些东西呢?欢迎在留言区分享你的经历。

View File

@ -0,0 +1,148 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 自动化测试:为什么程序员做测试其实是有优势的?
你好,我是郑晔!
在上一讲里,我们讨论了程序员做测试和测试人员做测试之间有什么不同,你现在应该不会担心因为程序员做测试就抢了测试人员的饭碗了。这一讲,我们来谈谈程序员做测试的优势所在。估计你已经想到了,没错,就是自动化测试。
其实程序员的主要工作就是打造各种自动化工具无论是为了业务的支撑或者是对于开发过程本身的支持。自动化一方面是为了提高效率另一方面也是将低效繁琐的工作交由机器去完成。关于自动化的种种思考我在《10x 程序员工作法》中有了一个模块进行讲解,如果你有兴趣不妨去回顾一下。)
测试这种工作其实非常适合自动化,因为在整个软件的生命周期之内,新的需求总会不断出现,代码总会不断地调整。鉴于大部分软件常常都是牵一发动全身,所以,即便是只改动了一点代码,理论上来说也应该对软件的全部特性进行完整验证。如果只靠人工来做这个事情,这无疑是非常困难的。
很多团队只依赖于测试人员进行测试,而且测试以手工为主,结果就是大部分时间都是在进行低效地验证工作,而这些工作恰恰是最适合用自动化测试完成的。
从自测到自动化测试框架
你平时是怎么验证自己代码正确性的呢?最不负责任的做法是压根不验证,我曾见过最极端的做法是连编译都不通过的代码就直接提交了。不过,这是我职业生涯早期发生的事情。随着行业整体水平的提高,这种事情现在几乎看不到了。
现在很多人的做法是把整个系统启动起来,然后手工进行验证。当然,大多数人不会验证系统里面所有的内容,只会针对自己正在开发的部分进行验证。这种做法通常只能够保证自己刚刚编写的代码是正确的。结果常常是按下葫芦浮起瓢——这个功能是对了,但之前原本验证好的功能又不对了。
即便是一个再小的系统,其中的细节也多到没有人愿意每次去手工验证其中所有的细节。因为这样做既琐碎又重复,这显然是适合自动化发挥战斗力的地方。
最开始的自动化都是很简单的。通常来说,就是直接写一个 main 函数,直接调用代码中的模块。但每次要测试不同的代码时,程序员就要注释掉原来的测试代码,然后,再编写新的测试代码。
这种做法虽然可以去验证代码的正确性,但显然不适合反复验证。稍微优化点的做法就是把一个个测试用例放到不同的函数里。总的来说,这个阶段的自动化测试还处于草莽阶段。
真正让自动化测试这件事登堂入室的,就是自动化测试框架了。最早的测试框架起源是 Smalltalk 社区。Smalltalk 是一门早期的面向对象程序设计语言,它有很多拥趸,很多今天流行的编程概念都来自于 Smalltalk 社区,自动化测试框架便是其中之一。
不过,真正让测试框架广泛流行起来,要归功于则另外的自动化测试框架 JUnit它的作者是 Kent Beck 和 Erich Gamma。Kent Beck 是极限编程的创始人,在软件工程领域大名鼎鼎,而 Erich Gamma 则是著名的《设计模式》一书的作者,很多人熟悉的 Visual Studio Code 也有他的重大贡献。
有一次Kent Beck 和 Erich Gamma一起从苏黎世飞往亚特兰大参加 OOPLSAObject-Oriented Programming, Systems, Languages & Applications大会在航班上两个人结对编程写出了 JUnit。从这个名字你不难看出它的目标是打造一个单元测试框架。二人之所以能够在一路上就完成 JUnit 最初版本的开发,是因为他俩本身就在 Smalltalk 社区摸爬滚打了一段时间,对 Smalltalk 的单元测试框架有着很深刻的认识。
今天流行的自动化测试框架统称为 xUnit因为它们都有一个共同的根基也就是 JUnit。所以只要了解了 JUnit 中的基本概念,你再去看其它测试框架,几乎都是差不多的。
测试框架简介
接下来,我们就来一次快速的自动化测试框架简介,如果你已经对自动化测试框架非常熟悉的话,可以当做一次轻松的复习。
我们理解测试框架有两个关键点,一是要去理解测试组织的结构,一是要去理解断言。掌握了这两点,就足够应付日常的大多数情况了。
测试结构
我们先来看看组织测试的结构。首先最核心的概念就是怎么表示一个测试用例。JUnit 怎么表示测试用例,我们在前面讲实战的时候已经见识过了,代码如下所示。
@Test
public should_work() {
...
}
我们前面说过,草莽阶段稍微优化一点的做法就是把测试用例放到一个个不同的函数里面,而测试框架就是把这种做法做了一个延伸,同样是用一个一个的函数表示一个一个的测试用例。不同的是,在草莽阶段,你每写一个函数就要在执行的部分注册一下这个函数。
使用测试框架的话,需要对表示测试用例的函数进行统一的标识,以便框架能够在运行时识别出来。在我们上面这个例子里面,用来识别测试用例的就是@Test。如果你用过 4.0 之前版本的 JUnit它是约定以 test 开头的函数就是测试用例,所以,你会看到下面这样的写法。
public test_should_work() {
...
}
两种不同的写法本质上是程序设计语言层面的差别,因为 Java 5 引入了 Annotation 这个语法,才有了基于@Test 进行标注的做法。很多的语法层面的改进都是为了提升语言的表达能力,而这一点在程序库的设计上体现得最为明显。如果你去看不同程序语言的测试框架时就会发现,做得比较差就是直接照搬 test 开头的做法,而做得比较好的则是会结合自己的语言特点。
了解了最基本的测试用例结构,其实写测试就够了。但是,测试也是代码,好的测试代码要兼具好代码的属性,最基本的要求就是消除重复。
比如同样的初始化代码反复在写由于测试的特殊性这些初始化的代码需要在每个测试之前都去执行。为了解决这个问题JUnit 引入了 setUp 去做初始化的工作。在 JUnit 4 之后,这个由函数名称进行定义的做法,改成了使用 @BeforeEach 进行定义的方式。我们在前面的实战中也提到过。
@BeforeEach
void setUp() {
...
}
由于 @BeforeEach 的存在setUp 这个名字在这里已经没有意义,只不过因为这是一个函数,需要有一个名字。从习惯上,我们还是称呼它为 setUp 函数。如果 JUnit 进一步将语法升级到 Java 8 的语法,这里完全可以使用 lambda去掉对名字的依赖。
@BeforeEach 和 setUp 对应的是 @AfterEach 和 tearDown它们处理的是要在每个测试之后执行的清理工作。相对来说这一对用的就比较少了除非是你用到了一些需要释放的资源。
知道了测试用例的写法,知道了 setUp/tearDown你就基本上掌握了测试结构的核心了。如果你具体学习一个测试框架还会有人告诉你 TestSuite、TestRunner 等等的概念,但它们现在基本上可以归入到实现层面了(也就是执行测试所需要了解的概念),而在编写代码的层面上,有前面说到的这几个概念就够了。
断言
我们接下来看理解测试框架的第二个关键点,断言。测试结构保证了测试用例能够按照预期的方式执行,而断言则保证了我们的测试需要有一个目标,也就是我们到底要测什么。
断言,说白了就是拿执行的结果和预期的结果进行比较。如果执行一个测试连预期都没有,那它到底要测什么?所以,我们可以说,没有断言的测试不是好测试。
几乎每个测试框架都有自己内建的断言机制,比如下面这个。
assertEquals(2, calculator.add(1, 1));
这个 assertEquals 是最典型的一个断言,也几乎是使用最多的断言,很多其它语言的测试框架也把它原封不动地搬了过去。但这个断言有一个严重的问题,你如果不看 API根本记不住哪个应该是预期值哪个应该是你函数返回的实际值。这就是典型的 API 设计问题,让人很难用好。
所以社区中涌现了大量的第三方断言程序库比如Hamcrest、AssertJ、Truth。其中Hamcrest 是一个函数组合风格的断言库,一度被内建到 JUnit 4 里面但出于对社区竞争的鼓励JUnit 5 又把它挪了出来,下面是一段使用了 Harmcrest 的代码。
assertThat(calculator.subtract(4, 1), is(equalTo(3)));
AssertJ 是一种流畅风格的程序库,扩展性也非常不错,它也是我们在前面实战部分选择的程序库,下面是一段使用了 AssertJ 的代码。
assertThat(frodo.getName()).startsWith("Fro")
.endsWith("do")
.isEqualToIgnoringCase("frodo");
Truth 是 Google 开源的一个断言库,和 AssertJ 很类似,它对 Android 程序支持得比较好,我也放了一段代码,风格上和 AssertJ 如出一辙。
assertThat(projectsByTeam())
.valuesForKey("corelibs")
.containsExactly("guava", "dagger", "truth", "auto", "caliper");
断言,不仅仅包括有返回值的处理,还包括其它的特殊情况,比如,抛出异常也可进行断言,这是 JUnit 5 内建的异常断言,你可以参考一下。
Assertions.assertThrows(IllegalArgumentException.class, () -> {
Integer.parseInt("One");
});
具体有哪些情况可以进行断言,你可以查阅所使用断言库的 API 文档。
最后,我还要讲一个不在这些断言库里的断言,那就是 Mock 框架提供的一种断言verify。
关于 Mock 框架后面我们还会讲到这里只是简单地提一下verify 的作用就是验证一个函数有没有得到调用。在某些测试里面,函数既没有返回值,也不会抛出异常。比如拿保存一个对象来说,我们唯一能够判断保存动作是否正确执行的办法,就是利用 verify 去验证保存的函数是否得到调用,就像下面这样。
verify(repository).save(obj);
虽然它不在断言库中,但它确确实实是一种断言,它判断的是一个动作是否得到正确的执行。所以,当我们说一个测试应该包含断言时,有 verify 的情况也算是有断言了。至于怎么用好 verify我们后面讲到 Mock 框架时再说。
讲过测试结构和断言,我们已经把测试框架的核心内容说完了。但这些只是写测试的基础,要想写好测试,我们还需要对什么样的测试是好的测试有个基本的认识,这就是我们下一讲要讲的内容了。
总结时刻
这一讲,我们讲了程序员在测试上的优势所在,也就是自动化。软件开发本身就是一个不断迭代的过程,对每一次代码的改动来说,理论上就应该把整个系统从头到尾地测一遍。这种工作手工做是非常琐碎的,所以非常适合使用自动化。
验证程序的正确性是程序员的基本工作,不过,很多人的做法还是手工验证。为别人打造自动化工具的人,自己的开发过程还不够自动化,这是很多程序员面对的尴尬。实际上,还有一些人在探索自动化的做法,从最早的 main 函数,到后来的自动化测试框架,就是在这方面一点一点的进步。自动化测试框架的出现,让自动化测试从业余走向了专业。
理解自动化测试框架,主要包含两个部分:组织测试的结构以及断言。组织测试的结构最核心的就是测试用例如何写,以及 setUp 和 tearDown 函数。而断言则是保证了我们测试的目标。断言程序库有很多你可以根据自己的喜好进行选择。除了断言程序库Mock 框架的 verify 也是一种断言。
如果今天的内容你只能记住一件事,那请记住:没有断言的测试不是好测试。
思考题
今天我们讲了自动化测试框架最核心的部分,但现在的测试框架都已经有了更多丰富的功能,希望你找一个你喜欢的测试框架,深入地了解一下它们新特性,挑一个让你印象深刻的特性和我们分享。期待在留言区看到你的想法。