本文へスキップ
BecomeCoder

Java MVVMコース · 第9章 テストをやり込む ― TDDの総仕上げ · レッスン35

仕様変更にTDDで対応する ― 並び替え機能を追加する

ブラウザで完結

導入

「タスク一覧を優先度の高い順に並べたい」――リリース後によくある仕様追加です。既存のaddcompletelistremainingCountには一切手を触れず、新しい仕様を新しいメソッドとして付け加えられるかを確認しましょう。既存のテストを1つも壊さずに機能を追加できることが、ここまで積み上げてきたTDDの実践力の見せ所です。

図解

flowchart LR
    R["repository.findAll()<br/>(TaskItemの一覧)"] --> S["並び替え<br/>(Priorityのordinalで比較)"]
    S --> M["TaskMapperで変換"]
    M --> OUT["優先度High→Medium→Lowの順に<br/>並んだTaskDtoの一覧"]
    style S fill:#e1f5fe

説明

00-index.mdで決めた約束のとおり、並び替えは既存のlist()を変えず、listSortedByPriority()という用途ごとの新しいメソッドとして追加します。

  • PriorityLOWMEDIUMHIGHの順で宣言されたenumなので、.ordinal()012を返します。これを比較に使えば、優先度の大小をそのまま数値の大小として扱えます
  • List#sort(Comparator)にラムダを渡すと、そのラムダが2つの要素を受け取ってintを返す比較関数として使われます。マイナス・ゼロ・プラスの意味は、あなたが書いた比較用のメソッド(例えばcompareTo)と同じです
  • repository.findAll()が返す一覧をそのまま並び替えると保存されているデータの順序まで変わってしまうため、必ずnew ArrayList<>(...)コピーしてから並び替えます
Priority p = Priority.HIGH;
System.out.println(p.ordinal());     // 2 (LOW=0, MEDIUM=1, HIGH=2)

やってみよう

まず何も書かずに「▶ 実行」を押し、listSortedByPriorityが無くてテストが失敗する(Red)ことを確認してから演習に進みましょう。

演習

TaskService.javaの担当は新しい1メソッドだけです。他のファイルは前の章までに作ったものをそのままコピーしてあります。

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; }
}

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 record TaskDto(String id, String title, String priorityLabel, boolean completed) { }
public class TaskMapper {
    public static TaskDto toDto(TaskItem task) {
        return new TaskDto(task.getId(), task.getTitle().getValue(), task.getPriority().getLabel(), task.isCompleted());
    }
}
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 List<TaskDto> list() {
        List<TaskDto> result = new ArrayList<>();
        for (TaskItem task : repository.findAll()) {
            result.add(TaskMapper.toDto(task));
        }
        return result;
    }

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

    // TODO: 優先度が高い順(High → Medium → Low)に並んだ一覧を返す
    //       listSortedByPriority を実装してください。add/complete/list/remainingCount は変更禁止です
}
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class TaskServiceTest {
    private TaskService newService() {
        TaskRepository repository = new InMemoryTaskRepository();
        int[] counter = {0};
        IdGenerator idGenerator = () -> "id-" + (counter[0]++);
        TaskFactory factory = new TaskFactory(idGenerator);
        return new TaskService(repository, factory);
    }

    @Test
    void yuusendoNoTakaiJunNiNarabu() {
        TaskService service = newService();
        service.add("牛乳を買う", Priority.LOW);
        service.add("レポート提出", Priority.HIGH);
        service.add("掃除する", Priority.MEDIUM);

        List<TaskDto> sorted = service.listSortedByPriority();
        assertEquals("レポート提出", sorted.get(0).title(), "Highが先頭");
        assertEquals("掃除する", sorted.get(1).title(), "次にMedium");
        assertEquals("牛乳を買う", sorted.get(2).title(), "Lowが最後");
    }
}
  • 期待される結果: 1件のテストが成功
ヒント1を見る

List<TaskItem> items = new ArrayList<>(repository.findAll()); items.sort((a, b) -> b.getPriority().ordinal() - a.getPriority().ordinal());

ヒント2を見る

並び替えたあとはforで1件ずつTaskMapper.toDto(...)して新しいList<TaskDto>に詰め替えます(list()と同じ形)

まとめ

  • 新しい仕様がどの層に属するかを見極め、既存メソッドは変えずに新しいメソッドとして追加する
  • enum.ordinal()を使えば、順序のある選択肢を数値として比較できる
  • 既存のaddcompletelistremainingCountのテストは1つも壊れていない――これが「安全な仕様追加」の証拠

次回: 動くけど読みにくいコードを、テストに守られながらリファクタリングします。

実際に動かしてみよう

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

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

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