gitbook/程序员的测试课/docs/404301.md
2022-09-03 22:05:03 +08:00

421 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
```
如果你订阅过我的[《代码之丑》](https://time.geekbang.org/column/intro/100068401),你会发现,它就是我在《代码之丑》中给你布置的课堂练习作业的第一部分。如果你想对今天的内容有更深刻的理解,不妨先停下来,自己实现一遍这个需求,然后,再回过头看我是怎样解决这个问题的,对比一下实现方式的差异,记得要写测试哦!
为了厘清主线,不受细节的干扰,我在正文中只罗列了最关键的部分代码。如果你想看完整的代码,我在 GitHub 上为此专门建了[一个项目](https://github.com/dreamhead/geektime-todo),你可以去参考。
具体的需求有了,我们接下来怎么动手实现这个应用呢?我们先来做一些基础的准备工作:
* 一个项目自动化;
* 对需求进行简单的设计。
为什么要先从这些东西做起呢我在《10x 程序员工作法》中曾经介绍过[迭代 0](https://time.geekbang.org/column/article/77294) 的概念,这是一个项目开始的基础准备,当然,因为我们这个是一个练习项目,所以,准备的内容相对来说,还比较少。
为什么要准备项目自动化呢简单来说就是防止自己犯一些低级错误。关于这个项目自动化中包含了哪些内容我在《10x 程序员工作法》中也专门用了[一讲](https://time.geekbang.org/column/article/86561)的篇幅介绍,你如果有兴趣不妨去了解一下。
接下来,我们就要进行一些简单的设计了。
## 设计先行
虽说这个部分的要求是一个命令行的应用,但我们要知道,一个系统的业务核心与它呈现的方式并不是耦合在一起的。也就是说,命令行只是这个 ToDo 应用的一种呈现形式。在专栏后面你会看到,我们也可以把它改造成一个 REST 服务。
所以,我们首先要做一个设计,**把核心的业务部分和命令行呈现的部分分开**。在我们的工程中,它们分别被放到了两个模块里,一个是 todo-core用来放置核心的业务部分一个是 todo-cli用来放置命令行相关的处理。这一讲我们主要先来解决核心的业务部分至于命令行相关的处理那会是我们下一讲的主题。
我们先来处理核心的业务部分。这里的核心业务是什么呢?根据前面的需求,就只有三个操作:
* 添加一个 Todo 项;
* 完成一个 Todo 项;
* Todo 项列表。
接下来,我们可以用 DDD 战术设计的方法进行一下识别各个概念(如果你不了解战术设计的基本过程,可以去看看《软件设计之美》中的关于[战术设计](https://time.geekbang.org/column/article/267843)的过程)。
首先是名词,这里我们的核心对象只有一个,就是 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 原则](https://time.geekbang.org/column/article/84374),这种错误属于入口参数错误,应该在入口检测出来,不应该传到业务核心里面。
所以,我们可以将空传给业务核心部分视为“异常”。同时,我们也确立好了**一条设计规范:对于输入参数的检测,由入口部分代码进行处理**。
基于这条设计规范的考虑如果是一个空的字符串那么根本就不应该传到我们的领域服务中应该直接在入口参数检测中就应该消灭掉。换言之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](https://joel-costigliola.github.io/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 使用的基本思路,我在《软件设计之美》中[讲过](https://time.geekbang.org/column/article/258954),如果你感兴趣可以去回顾一下)。
```
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 的问题以运行时异常的形式抛出,业务层不需要做任何处理。
在编码的过程中,我们也看到了:
* 根据不断增加的需求,逐渐改动我们的设计,这就是演化式设计的基本做法;
* 我们对待测试也像对待代码一样,会消除代码中存在的一些坏味道。
如果今天的内容你只能记住一句话,那么请记住,**细化测试场景,编写可测试的代码。**
![](https://static001.geekbang.org/resource/image/d8/23/d8yy713c1c76376c9e17a42917637523.jpg?wh=2000x1073)
## 思考题
今天我分享了从一个需求入手,如何一步一步地写出测试。你在实际工作中是怎么做测试呢?如果你如果不做的话,原因又是什么呢?欢迎在留言区分享你的所见所闻。
## 参考资料
[迭代0启动开发之前你应该准备什么](https://time.geekbang.org/column/article/77294)
[一个好的项目自动化应该是什么样子的?](https://time.geekbang.org/column/article/86561)
[战术设计:如何像写故事一样找出模型?](https://time.geekbang.org/column/article/267843)