实现todo应用CLI

上一篇里,我们实现了 ToDo 应用的核心业务部分。但它还不是一个完整的应用,既不能有命令行的输入,也不能把 ToDo 项内容真正的存储起来。

这一篇,我们把欠缺的部分补上。不过,仍需强调一下,之所以先做核心业务部分,因为它在一个系统中是最重要的。

文件存储

我们先来实现 Todo 项的存储。

先来看一下预留的 Repository 接口

public interface TodoItemRepository {
    TodoItem save(TodoItem todoItem);

    Iterable<TodoItem> findAll();
}

处于简单考虑,我们是一个基于文件的存储。

首先我们要考虑这个实现放在哪里。放在 core 模块或 cli 模块都有一定的道理。

作者更倾向与放到 todo-cli 这个模块里,原因是最好保持核心业务的小巧,等有机会给其他模块使用时,在考虑挪到 todo-core 中。

确定了模块归属,我们来确定一下测试场景.

  • findAll 查询空的 Repository, 返回空列表
  • 新增 Todo 项后,findAll 返回新增的列表
  • 修改 Todo 项后,findAll 返回修改后列表
  • 保存空的 Todo 项, 抛出异常

临时文件

内存中的测试是可以重复执行的。那文件怎么办?

文件是外部资源,我们要考虑文件放到哪里,如何清理等等。所幸,JUnit给了我们标准答案,就是临时文件。

Junit给出的方案是 临时目录,在这个目录里怎么折腾都行。给一个变量标记上 @TempDir,这个变量可以是作为一个测试函数的参数,也可以是一个测试类的字段。

class FileTodoItemRepositoryTest {

    @TempDir
    File tempDir;
    private File tempFile;

    FileTodoItemRepository repository;

    @BeforeEach
    public void before() throws IOException {
        this.tempFile = File.createTempFile("file", "", tempDir);
        this.repository = new FileTodoItemRepository(this.tempFile);
    }
}

文件编解码

存储到文件,我们必须要考虑编解码的问题。处于简单考虑,我们采用JSON这种格式。

处理JSON,我们选择 Jacksonopen in new window, 这是业界最主流的JSON库。我们把依赖加入到构建脚本。也就是 todo-cli/build.gradle。


dependencies {
    implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
}

添加了依赖,我们重新生成一下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);
    }
}

当通过了所有测试,我们要check一下

./gradlew check

当我们解决了大部分像代码风格之类的低级问题后,有一个问题会卡住我们:测试覆盖率。

我们的构建脚本设定的测试覆盖率是100%,所以只要有未覆盖的地方就通不过check。打开报告(具体位置在 $buildDir/reports/jacoco/index.html),可以看到具体哪里没有覆盖到。

对于简单场景,我们可以增加或调整测试提高覆盖率,有些问题不是简单调整就能解决的。例如上面的 IOException,我们该怎么办?

最烂的做法是,不好覆盖,认为测试没有价值,就彻底放弃测试。

我们坚持测试,如何通过这一关呢?

一种做法是不分青红皂白,统一降低对于测试覆盖率的要求,也就是修改构建脚本中的设置。虽然这种做法可以让我们临时通过这一关,但这却会留下后患:以后有其它本可以测试覆盖到的部分,由于测试覆盖率的降低也会被忽略。

另一种做法,是把这些异常造出来。运气好,可以看接口,大概猜出来,有时候,需要仔细研究程序库的源代码,才能知道异常如何产生的。

知道异常如何产生是第一步,接下来是如何构建异常。像不合法的 JSON 格式还好办,有些异常很难制造。比如 使用反射 的 ClassNotFoundException, 只要类加载了就不会抛出 ClassNotFoundException。

我们要弄清楚一点,我们测试的目标是我们的代码,而不是这个难以测试的程序库. 除非这个异常对我们至关重要,否则为了写测试去研究另一个程序库,就是本末倒置了。

我们还有其他的办法吗?通常来说,无法屏蔽的异常来自另一个程序库,对于我们来说,都是一些实现细节,我们可以将细节封装起来。例如前面的代码,实现的是从文件读取对象,我们把它封装到一个 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 {
    excludePackages = [
    ]
    excludeClasses = [
            'io.github.himcs.todo.cli.util.Jsons',
    ]
}

为什么可以忽略它?

  • 这段代码很简单,几乎没有逻辑
  • 这里面主要代码不是我们写的,我们测试的主要目的是我们自己写的代码,而不是别人的程序库

小结一下,由于其它程序库造成难以测试的问题,我们可以做一层层薄薄的封装,然后,在覆盖率检查中忽略它。封装和忽略,缺一不可。

基础已经打好,我们来吧所有的东西连接起来,给它一个入口。

命令行入口

编写命令行入口,我们要选择一个程序库,省的从头编写各种解析的细节,这里我们选择Picocliopen in new window.

对于一个新程序库,首先要让它跑起来,一但我们掌握了一个程序库的基本用法,我们要抛弃掉实验代码,重新设计,按照它应有的样子使用程序库。

接口的选择

有些程序库对于一件事有多种不同的处理方式。对于 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);
    }
}

我们测试中,除了声明最外面的调用接口(cli)外,还声明了一个变量 service,它有什么用呢?我们来看看下面测试实现

@Test
public void should_mark_todo_item_done() {
    TodoItem todoItem = service.addTodoItem(new TodoParameter("miao"));
    int result = cli.execute("done", String.valueOf(todoItem.getId()));
    assertThat(result).isEqualTo(0);
    List<TodoItem> todoItems = service.listItem(true);
    assertThat(todoItems.get(0).getContent()).isEqualTo("miao");
    assertThat(todoItems.get(0).isDone()).isEqualTo(true);
}

标记一个 Todo 项完成, 但前提是要有一个 Todo 项供你去标记。呢么如何把这个 Todo 项加进去呢?

  • 一种做法是 调用命令行接口,但是我们在这里测试的目标就是命令行皆苦,就是 add,这里测试的接口是 done。测试要尽可能减少对不稳定组件的依赖,done 已经是不稳定的了 ,再加上一个 add ,测试出问题的概率进步增大

  • 另一种做法,service 是我们之前测试好的组件,我们可以把它看成稳定组件。所以我们使用 service 来添加 Todo 项。

测试控制台输出

代码如下,用我们的新对象来接受控制台的输出,测试方法执行完毕再执行清理操作。


class TodoCommandTest {

    @TempDir
    File tempDir;
    private TodoService service;
    private CommandLine cli;

    private final ByteArrayOutputStream out = new ByteArrayOutputStream();
    private final ByteArrayOutputStream err = new ByteArrayOutputStream();
    private final PrintStream originalOut = System.out;
    private final PrintStream originalErr = System.err;

    @BeforeEach
    public void setUp() {
        final ObjectFactory factory = new ObjectFactory();
        final File repositoryFile = new File(tempDir, "repository.json");
        this.service = factory.createService(repositoryFile);
        this.cli = factory.createCommandLine(repositoryFile);
        System.setOut(new PrintStream(out));
        System.setErr(new PrintStream(err));
    }

    @AfterEach
    public void clean(){
        System.setOut(originalOut);
        System.setErr(originalErr);
    }

    @Test
    public void should_add_todo_item() {
        int result = cli.execute("add", "test");
        assertThat(result).isEqualTo(0);
        List<TodoItem> todoItems = service.listItem(true);
        assertThat(todoItems.get(0).getContent()).isEqualTo("test");
        assertThat(out.toString()).contains("Item 1 added");
    }
}

总结

系统与文件交互时

  • 调整设计,将文件注入到模型
  • 测试中使用临时文件
  • 如果是 JUnit5, 可使用 @TempDir

测试覆盖发现了难以测试的第三方代码

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

使用三方框架时

  • 与框架紧密结合的代码制作最简单的接口校验,业务逻辑放到自己的代码中
  • 如果由多种方式完成功能,选择可测试性好的实现

集成测试

  • 保证组件之间协作的正确性
  • 利用与产品代码相同的组件组织过程
  • 把测试好的稳定组件当作基础

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

Last Updated:
Contributors: mcs