本文へスキップ
BecomeCoder

Java MVVMコース · 第10章 仕上げ ― アプリを完成させる · レッスン41

機能追加をTDDで ― カテゴリ分け

ブラウザで完結

導入

もう1つ、よくある要望「タスクを仕事/プライベートのようなカテゴリで分けたい」を追加してみましょう。これも進め方は同じです。「あるカテゴリのタスクだけを取り出す」という判断は第5章で学んだ通りServiceの仕事、ViewModelとViewは、その結果を表示するだけにとどめます。

図解

flowchart LR
    C["TaskItem.category"] --> SVC["TaskService.listByCategory(category)<br/>(Serviceが絞り込む)"]
    SVC --> VM["ViewModel<br/>(結果件数を表示するだけ)"]

説明

00-index.mdの拡張仕様どおり、TaskItemString categoryを足し、絞り込みはTaskService用途ごとの新しいメソッドlistByCategory(String category)として追加します(レッスン35のlistSortedByPriorityと同じ考え方です)。

  • categoryも期限切れのときと同じく、既存の3引数コンストラクタは変えず、categoryを受け取る新しいコンストラクタを追加します
  • listByCategory(String category)は、repository.findAll()を1件ずつ見て、categoryが一致するものだけを新しいList<TaskItem>に集める、というremainingCount()と同じ形の「1件ずつ見て条件に合うものだけ拾う」ループで書けます
  • 「あるカテゴリだけを取り出す」という判断がTaskServiceの役割になっているのは、第4章で学んだ「Repositoryは出し入れだけ、Serviceが判断する」という役割分担にそのまま従っています

やってみよう

まず何も書かずに「▶ 実行」を押し、categorylistByCategoryが無くてコンパイルエラーになる(Red)ことを確認してから演習に進みましょう。

演習

Support.javaは完成済みとしてそのまま使い、今回の担当はDomain.javaTaskItemTaskService.javaです。

public interface IdGenerator {
    String next();
}
// --- 前の章までに作ったクラスのコピー ---
final class TaskTitle {
    private final String value;

    TaskTitle(String value) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("タイトルは空にできません");
        }
        if (value.length() > 50) {
            throw new IllegalArgumentException("タイトルは50文字以内です");
        }
        this.value = value;
    }

    String getValue() { return value; }
    public String toString() { return value; }
}

enum Priority {
    LOW("低"), MEDIUM("中"), HIGH("高");

    private final String label;
    Priority(String label) { this.label = label; }
    String getLabel() { return label; }
}

// TODO: category を受け取るコンストラクタと getCategory を実装してください。
//       既存の3引数コンストラクタは変更禁止(categoryは新しいコンストラクタに委譲してください)
class TaskItem {
    private final String id;
    private TaskTitle title;
    private final Priority priority;
    private boolean completed;

    TaskItem(String id, TaskTitle title, Priority priority) {
        this.id = id;
        this.title = title;
        this.priority = priority;
    }

    String getId() { return id; }
    TaskTitle getTitle() { return title; }
    Priority getPriority() { return priority; }
    boolean isCompleted() { return completed; }
    void complete() { completed = true; }
}

class TaskFactory {
    private final IdGenerator idGenerator;
    TaskFactory(IdGenerator idGenerator) { this.idGenerator = idGenerator; }

    TaskItem create(String title, Priority priority) {
        return new TaskItem(idGenerator.next(), new TaskTitle(title), priority);
    }
    TaskItem create(String title) { return create(title, Priority.MEDIUM); }
}

interface TaskRepository {
    void add(TaskItem task);
    List<TaskItem> findAll();
    Optional<TaskItem> findById(String id);
}

class InMemoryTaskRepository implements TaskRepository {
    private final List<TaskItem> tasks = new ArrayList<>();
    public void add(TaskItem task) { tasks.add(task); }
    public List<TaskItem> findAll() { return tasks; }
    public Optional<TaskItem> findById(String id) {
        for (TaskItem t : tasks) {
            if (t.getId().equals(id)) return Optional.of(t);
        }
        return Optional.empty();
    }
}
public class TaskService {
    private final TaskRepository repository;
    private final TaskFactory factory;

    public TaskService(TaskRepository repository, TaskFactory factory) {
        this.repository = repository;
        this.factory = factory;
    }

    public void add(String title, Priority priority) {
        repository.add(factory.create(title, priority));
    }

    public void complete(String id) {
        Optional<TaskItem> found = repository.findById(id);
        if (found.isPresent()) {
            found.get().complete();
        }
    }

    public int remainingCount() {
        int count = 0;
        for (TaskItem task : repository.findAll()) {
            if (!task.isCompleted()) count++;
        }
        return count;
    }

    // TODO: 指定したカテゴリのタスクだけを集めた一覧を返す listByCategory を実装してください。
    //       add / complete / remainingCount は変更禁止です
}
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class TaskServiceCategoryTest {
    @Test
    void shiteiShitaCategoryDakeGaKaesareru() {
        TaskRepository repository = new InMemoryTaskRepository();
        repository.add(new TaskItem("1", new TaskTitle("企画書を書く"), Priority.HIGH, "仕事"));
        repository.add(new TaskItem("2", new TaskTitle("牛乳を買う"), Priority.LOW, "プライベート"));
        repository.add(new TaskItem("3", new TaskTitle("見積もりを送る"), Priority.MEDIUM, "仕事"));

        int[] counter = {0};
        TaskService service = new TaskService(repository, new TaskFactory(() -> "id-" + (counter[0]++)));

        List<TaskItem> workTasks = service.listByCategory("仕事");
        assertEquals(2, workTasks.size(), "指定カテゴリだけが2件返る");
        for (TaskItem task : workTasks) {
            assertEquals("仕事", task.getCategory(), "返るのは指定カテゴリだけ");
        }
    }
}
  • 期待される結果: 1件のテストが成功
ヒント1を見る

TaskItem(String id, TaskTitle title, Priority priority) { this(id, title, priority, "未分類"); }のように、既存のコンストラクタからcategory付きの新しいコンストラクタへ委譲します

ヒント2を見る

public List<TaskItem> listByCategory(String category) { List<TaskItem> result = new ArrayList<>(); for (TaskItem task : repository.findAll()) { if (task.getCategory().equals(category)) result.add(task); } return result; }

まとめ

  • 「特定の条件で絞り込む」機能追加も、絞り込みロジックはService/Entity側、ViewModel/Viewは結果の表示だけという役割分担で進める
  • 既存のコンストラクタを変えずに項目を追加する手順(this(...)への委譲)は、期限切れのときと全く同じ形で再利用できた
  • 期限切れ・カテゴリ分けのどちらも、コースを通して学んだ「依存の向き」「TDD」「役割分担」を崩さずに追加できた

次回: コース全体のまとめと、次に学ぶとよいコースを紹介します。

実際に動かしてみよう

このレッスンのサンプルは、実務と同じように役割ごとの .java ファイルへ分けてあります。下のエディタは最初からその複数ファイルが入った状態で、上のタブでファイルを切り替えられます。そのまま「▶ 実行」を押せば全ファイルをまとめて解釈して動かせます。@Test の付いたテストがあるレッスンでは、テストメソッドごとに ✅/❌ の一覧(Red/Green)が出るので、まずテストを赤くしてから実装で緑にする、というTDDの回し方をその場で体験できます(本物のJVMではなく、JUnit・Mockitoの主要な書き方まで再現した学習用シミュレータです)。

Java — ブラウザ内で実行(学習用シミュレータ)

Javaの教材サブセットを動かす学習用シミュレータを読み込みます(本物のJVMではなく、動きを再現した軽量な自作エンジンです)。
スクロールして表示された時点でも自動で読み込まれます。