因收到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 在临时目录下创建临时文件。

如果通过测试覆盖发现了难以测试的第三方代码:

  • 通过做一层薄薄的封装,将第三方代码与你的代码分离开,保证你的代码完全由测试覆盖;
  • 在测试覆盖率中,忽略这层封装。

当我们使用第三方框架时:

  • 与框架紧密结合的代码只是做最简单的接口校验工作,把业务逻辑放在自己的代码里;
  • 如果有多种方式完成一个功能,选择可测试性较好的实现方式。

我们编写集成测试:

  • 是为了保证组件之间协作的正确性;
  • 需要利用与产品代码相同的组件组装过程;
  • 可以把已经测试好的稳定组件当做基础。

如果今天的内容你只能记住一件事,那请记住:隔离变化,逐步编写稳定的代码

思考题

一旦你开启了对测试的思考,我们就能发现更多的思考角度,比如:控制台输出应该怎么测试?这个问题就是今天留给你的思考题了。在这个现有的项目基础上,增加对于控台输出的测试,你会怎么做呢?欢迎在留言区分享你的做法。