实现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,我们选择 Jackson, 这是业界最主流的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',
]
}
为什么可以忽略它?
- 这段代码很简单,几乎没有逻辑
- 这里面主要代码不是我们写的,我们测试的主要目的是我们自己写的代码,而不是别人的程序库
小结一下,由于其它程序库造成难以测试的问题,我们可以做一层层薄薄的封装,然后,在覆盖率检查中忽略它。封装和忽略,缺一不可。
基础已经打好,我们来吧所有的东西连接起来,给它一个入口。
命令行入口
编写命令行入口,我们要选择一个程序库,省的从头编写各种解析的细节,这里我们选择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);
}
}
我们测试中,除了声明最外面的调用接口(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
测试覆盖发现了难以测试的第三方代码
- 做一层薄薄的封装,将三方代码与你的代码分离,保证你的代码完全由测试覆盖
- 测试覆盖率中,忽略这层封装
使用三方框架时
- 与框架紧密结合的代码制作最简单的接口校验,业务逻辑放到自己的代码中
- 如果由多种方式完成功能,选择可测试性好的实现
集成测试
- 保证组件之间协作的正确性
- 利用与产品代码相同的组件组织过程
- 把测试好的稳定组件当作基础
如果今天的内容你只能记住一件事,那请记住:隔离变化,逐步编写稳定的代码。