实现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/todoopen in new window

基础的准备工作:

  • 项目自动化
  • 对需求进行简单设计

为什么要准备项目自动化?

简单来说,防止自己犯低级错误。项目自动化可以参考 项目自动化

简单的设计

需求是一个命令行应用,但项目的业务核心与呈现不是耦合在一起的。命令行只是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 的问题以运行时异常的形式抛出,业务层不需要做任何处理。

编码过程

  • 根据需求,渐进式改动设计
  • 对待测试也像对待代码一样

如果今天的内容你只能记住一句话,那么请记住,细化测试场景,编写可测试的代码

Last Updated:
Contributors: mcs