本文へスキップ
BecomeCoder

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

Service+Repositoryを通しでテストする ― 統合的な検証

ブラウザで完結

導入

これまでの章のテストは、TaskService単体を、Fakeや作りたてのRepositoryと組み合わせてテストするものが中心でした。これは単体テスト――1つのクラスだけを隔離してテストする方法です。今回は逆に、本物のInMemoryTaskRepositoryと本物のTaskFactoryを実際に組み合わせて、層をまたいだ動きを検証する統合テストを書きます。

図解

flowchart TB
    subgraph UNIT["単体テスト(前の章まで)"]
        S1["TaskService"] --> F["Fake/Mockitoのモック"]
    end
    subgraph INTEG["統合テスト(このレッスン)"]
        S2["TaskService"] --> R["InMemoryTaskRepository(本物)"]
    end
    style F fill:#e8f5e9
    style R fill:#fff3e0

説明

  • 単体テスト: 速い・失敗の原因を特定しやすい・数を多く書ける。ただし「Fakeやモックが本物と同じように振る舞う」という前提の上に成り立つ
  • 統合テスト: 本物同士の組み合わせなので、その前提のズレに気づける。単体テストよりわずかに遅く、数は少なめでよい(重要な流れだけを確認する)
  • 目安: 細かい業務ルールの分岐は単体テストで数多く層をまたいだ主要な流れは統合テストで少数確認する、という役割分担
  • InMemoryTaskRepositoryは名前に「Memory」と付くだけで、これ自体が本物の実装。DBを用意しなくても本物同士を組み合わせた統合テストが書める、というのがこのアプリの構成上の利点

やってみよう

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

演習

今回はTaskService.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; }
}

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());
    }
}
// TODO: 契約どおりの TaskService(add / complete / list / remainingCount)を実装してください。
//       Fake は一切使わず、InMemoryTaskRepository と TaskFactory を組み合わせて動くことを確認します
public class TaskService {
}
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class TaskServiceIntegrationTest {
    @Test
    void tsuikaToKanryouGaHonmonoNoRepositoryToTsuujiteHanieisareru() {
        TaskRepository repository = new InMemoryTaskRepository();
        int[] counter = {0};
        TaskFactory factory = new TaskFactory(() -> "id-" + (counter[0]++));
        TaskService service = new TaskService(repository, factory);

        service.add("牛乳を買う", Priority.LOW);
        service.add("レポート提出", Priority.HIGH);
        String secondId = repository.findAll().get(1).getId();
        service.complete(secondId);

        assertEquals(2, repository.findAll().size(), "Repositoryに2件実際に保存されている");
        assertEquals(1, service.remainingCount(), "未完了はServiceを通しても1件");
        assertEquals(2, service.list().size(), "一覧はServiceを通して2件取得できる");
    }
}
  • 期待される結果: 1件のテストが成功
ヒント1を見る

コンストラクタはpublic TaskService(TaskRepository repository, TaskFactory factory) { this.repository = repository; this.factory = factory; }

ヒント2を見る

addrepository.add(factory.create(title, priority));completerepository.findById(id)が返るOptionalの中身があればcomplete()を呼びます

まとめ

  • 単体テスト(Fakeやモックで隔離)と統合テスト(本物同士を組み合わせる)は目的が違い、どちらか一方で十分ということはない
  • 統合テストは「層のつなぎ目」で前提がズレていないかを確認する
  • InMemoryTaskRepositoryメモリ上で完結するため、DBが無くても本物同士の組み合わせという統合テストの利点を得られる

次回: 章の締めくくり、テストしにくいコードを見分ける目を養います。

実際に動かしてみよう

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

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

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