实现todo应用 内核
项目前准备
先来看下 Todo 应用有哪些具体需求
- 添加 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://github.com/himcs/todo
基础的准备工作:
- 项目自动化
- 对需求进行简单设计
为什么要准备项目自动化?
简单来说,防止自己犯低级错误。项目自动化可以参考 项目自动化
简单的设计
需求是一个命令行应用,但项目的业务核心与呈现不是耦合在一起的。命令行只是Todo应用的一种呈现方式,后面会添加REST的方式。
首先做一个的设计,将和新的业务部分和命令行的程序部分分开。在工程中分成两个模块,todo-core 存放业务核心,todo-cli 放置命令行相关的处理。
我们主要先来解决核心的业务部分。
核心业务有什么?根据前面的需求,只有三个操作。
- 添加todo
- 完成todo
- 查看所有todo
这里核心对象只有一个 就是 Todo项。Todo项的核心字段就是它的内容,就是我们在命令行写下的内容。
有了对象我们就要识别动作了,这里我们会有一个Todo服务,对应我们的操作。应该包含三个方法:
- addTodoItem,添加 Todo 项;
- markTodoItemDone,完成一个 Todo 项;
- list,列出所有的 Todo 项。
服务只是操作,最终要有一个地方存储结果。所以我们还需要一个 Todo 项的 Repositoy 处理持久化相关的接口。
Repository并不是与数据库绑定的,他只是一种持久化机制。命令行版本,我们采用文件存储。
任务分解
从哪里开始实现? 从离我们最近的入口开始。通常来说,这个起点是应用服务,但是我们这里暂时没有应用服务,所以,我们可以从领域服务开始。
我们就按照需求的先后顺序,依次实现每个服务,首先是添加Todo项。
通常添加Todo项,就是创建一个 Todo 项,然后存在 Repository里,但我们要考虑一下这个行为如何测试。
测试一个函数,这个函数最好是可测试的。
什么是可测的?就是通过函数的接口设计,我们给出特定的输入,它能给我们相应的输出。所以,一个函数最好是有返回值的。
所以我们来设计Todo项的添加接口
TodoItem addTodoItem(final TodoParameter todoParameter);
TodoItem 表示一个 Todo 项, TodoParameter 表示创建 Todo 项所需的参数(使用更有业务含义的名字,比直接使用基本类型更清楚)
我们下哪里考虑一下测试场景。
首先想到的是添加正常字符串。这是正常情况,没有问题。
如果添加的是空字符串,我们如何处理?
一般而言,处理空字符串的方式有两种。
- 返回空的 TodoItem
- 抛出异常
就这里场景而言,命令行可能输入空字符串,这种错误输入入口参数异常,应该在入口检测出来,不应该传到业务里。
所以我们可以将空输入视为异常。
同时,我们确立好一条设计规范
- 对于输入参数的处理,应该入口处进行检测
如果 TodoParameter 为空呢?这种情况也不应该出现,我们也当作异常处理。
现在,我们有了两个异常场景
- 正常输入,返回一个 TodoItem
- TodoParameter 为 null,抛出异常
如果存储到 Repositoy 过程中出现了问题, 比如磁盘满了,我们改如何处理?这种属于不可恢复异常,我们在业务处理也做不了什么,只能把它抛出去。
一般来说,这种异常可以由 Repositoy 会抛出 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 进行处理。我们测试的重点时 TodoItemService,TodoItemRepository 如何实现我们暂时还未考虑。
只有一个接口,我们改如何用它呢?
我们可以根据Mock框架模拟一个具有行为的对象。
下面添加了 模拟 TodoItemRepository 的代码。
@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。
断言库 用的是 AssertJ,它的 API 风格是 Fluent API(...);
有了测试,实现也很简单
public TodoItem addTodoItem(final TodoParameter todoParameter) {
final TodoItem item = new TodoItem(todoParameter.getContent());
return this.repository.save(item);
}
@Getter
public class TodoItem {
private final String content;
public TodoItem(final String content) {
this.content = content;
}
}
下一个测试是 null 输入,预期一个异常。
@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
遗漏可能是代码风格,也可能是代码覆盖率,这也是我们要把项目自动化排在前面的原因。后面每完成一个任务,都要运行一下 check。
接下来我们来完成 Todo 项,完成 Todo 项的接口是这样的。
Optional<TodoItem> markTodoItemDone(TodoIndexParameter todoIndexParameter);
入口参数是一个索引,只不过我们添加了一层封装。
针对这个场景,我们考虑的测试场景包括:
- 对于已存在的 Todo 项, 标记完成
- 不存在的 Todo 项,返回空
对于一个索引为负数的场景,这应该在入口处就应该被检测出来,所以我们封装一个 TodoIndexParameter,业务层就不用考虑索引为负的场景了。
编写测试
@Test
public void should_mark_todo_item_done() {
TodoItem todoItem1 = new TodoItem("123");
todoItem1.setId(1);
when(todoItemRepository.findAll()).thenReturn(ImmutableList.of(todoItem1));
when(todoItemRepository.save(any())).then(returnsFirstArg());
Optional<TodoItem> todoItem = todoService.markTodoItemDone(new TodoIndexParameter(1));
assertThat(todoItem.get().isDone()).isEqualTo(true);
}
这里我们采用简单实现,先 findAll 所有数据到内存,再根据索引进行筛选。
然后考虑不存在的情况,编写测试
@Test
public void should_not_mark_todo_item_for_out_of_scope_index() {
when(todoItemRepository.findAll()).thenReturn(ImmutableList.of(new TodoItem("123")));
final Optional<TodoItem> todoItem = todoService.markTodoItemDone(new TodoIndexParameter(1));
assertThat(todoItem).isEmpty();
}
最关键的是,我们的数据模型要添加一个是否完成的字段,为了实现索引的定位,我们还要加上 id 字段
@Getter
public class TodoItem {
@Setter
private int id;
private String content;
@Setter
private boolean done;
public TodoItem(final String content) {
this.content = content;
done = false;
}
}
最后我们要实现 列表接口,有一个参数标记是否过滤未完成的 TodoItem
List<TodoItem> listItem(final boolean all);
然后考虑一下测试场景:
- 如果有 Todo 项,罗列 Todo 项时,列出所有的 Todo 项;
- 如果没有 Todo 项,罗列 Todo 项时,列出 Todo 项为空;
- 如果有未完成的 Todo 项,罗列未完成 Todo 项,列出所有未完成的 Todo 项;
- 如果没有未完成的 Todo 项,罗列未完成 Todo 项,列出的 Todo 项为空。
有时你会发现,虽然我们列出了很多测试场景,但当我们有了一些基础的代码之后,一些测试刚写完就通过了。比如,如果我们先写了罗列 Todo 项和罗列未完成 Todo 项的代码,后面两个测试场景很可能自然地就通过了。
到这里,我们已经把最核心的业务代码写完了,当然,它还不能完整地运行,因为它没有命令行的输入,也没有实现 Repository 的存储。但有了一个稳定的核心,这些东西都好办。
总结
重点,实现需求,重要的不是如何代码写出来,而是思考项目初期要准备的内容,和如何编写测试
- 项目自动化
- 需求初步设计
- 需求任务分解
- 设计可测试函数
- 针对函数,设计测试
- 针对测试场景,具象化为测试用例
梳理两条约定
- 对于输入参数的检测,由入口部分代码进行处理;
- Repository 的问题以运行时异常的形式抛出,业务层不需要做任何处理。
编码过程
- 根据需求,渐进式改动设计
- 对待测试也像对待代码一样
如果今天的内容你只能记住一句话,那么请记住,细化测试场景,编写可测试的代码。