本文へスキップ
BecomeCoder

Java MVVMコース · 第5章 Service ― 業務ロジックの層 · レッスン19

Serviceのテストで依存をモックに差し替える

ブラウザで完結

導入

前回のテストInMemoryTaskRepositoryを使いましたが、実務でRepositoryの実体がDB接続を必要とする場合、テストのたびに本物のDBを用意するのは大変です。Mockitoのmock(...)を使い、TaskRepositoryの偽物を差し込んでTaskServiceだけを検証しましょう。

図解

sequenceDiagram
    participant Test
    participant TaskService
    participant Mock as mock(TaskRepository.class)
    Test->>TaskService: new TaskService(mock, factory)
    Test->>TaskService: add("牛乳を買う", ...)
    TaskService->>Mock: findAll()
    TaskService->>Mock: add(task)
    Test->>Mock: verify(mock).add(any())

説明

mock(Foo.class)は、Fooというインターフェース(またはクラス)を実装した「呼び出しを記録するだけの偽物」を作ります。

TaskRepository repo = mock(TaskRepository.class);
TaskFactory factory = new TaskFactory(() -> "t1");
TaskService service = new TaskService(repo, factory);

service.add("牛乳を買う", Priority.MEDIUM);

verify(repo).add(any());   // repo.add(...) が1回呼ばれたことを確認
  • TaskServiceのコードは1行も変えず、渡すTaskRepositoryだけを差し替える
  • findAll()をスタブしていなければ、モックは戻り値の型(List)に合わせて自動的に空のリストを返す。だから「まだ何もタスクがない」状態を毎回自分で作らなくてよい
  • when(repo.findAll()).thenReturn(list)とスタブすれば、「既にこのタスクがある」という状況も自由に作れる
  • verify(repo).add(any())で「本当にRepositoryへ渡されたか」まで検証できる。verify(repo, never()).add(any())なら「1回も呼ばれていない」ことを確認できる

やってみよう

when(repo.findAll()).thenReturn(...)で「既に同じタイトルのタスクがある」状態を作り、addを呼んだときにrepo.add(...)が1回も呼ばれない(never())ことを確認する書き方を、下のテストで練習します。

演習

Domain.java(土台)・TaskService.java(前回完成させたもの、そのまま使います)・TaskServiceMockTest.java(今日のTODO)の3ファイルです。1つ目のテストは完成していますが、2つ目のテストは自分で書きます。

import java.util.List;
import java.util.ArrayList;
import java.util.Optional;

public interface IdGenerator {
    String next();
}

public class TaskTitle {
    private final String value;

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

    public String getValue() { return value; }

    @Override
    public String toString() { return value; }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof TaskTitle)) return false;
        return value.equals(((TaskTitle) o).value);
    }

    @Override
    public int hashCode() { return value.hashCode(); }
}

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

    private final String label;

    Priority(String label) { this.label = label; }

    public String getLabel() { return label; }
}

public class TaskItem {
    private final String id;
    private TaskTitle title;
    private final Priority priority;
    private boolean completed;

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

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

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof TaskItem)) return false;
        return id.equals(((TaskItem) o).id);
    }

    @Override
    public int hashCode() { return id.hashCode(); }
}

public class TaskFactory {
    private final IdGenerator idGenerator;

    public TaskFactory(IdGenerator idGenerator) {
        this.idGenerator = idGenerator;
    }

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

    public TaskItem create(String title) {
        return create(title, Priority.MEDIUM);
    }
}

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

public 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();
    }
}
import java.util.Optional;

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) {
        for (TaskItem t : repository.findAll()) {
            if (t.getTitle().getValue().equals(title)) {
                throw new IllegalStateException("同じタイトルのタスクが既にあります");
            }
        }
        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 t : repository.findAll()) {
            if (!t.isCompleted()) count++;
        }
        return count;
    }
}
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.ArrayList;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

public class TaskServiceMockTest {
    @Test
    void juufukuTitleGaSudeniAreba_RepositoryNiTsuikaSarezuNiReigaOkoru() {
        TaskRepository repo = mock(TaskRepository.class);
        TaskFactory factory = new TaskFactory(() -> "t1");
        List<TaskItem> found = new ArrayList<>();
        found.add(factory.create("牛乳を買う", Priority.MEDIUM));
        when(repo.findAll()).thenReturn(found);
        TaskService service = new TaskService(repo, factory);

        assertThrows(IllegalStateException.class, () -> service.add("牛乳を買う", Priority.LOW));
        verify(repo, never()).add(any());
    }

    @Test
    void juufukuShinaiTaskHaRepositoryNiTsuikaSareru() {
        // TODO: mock(TaskRepository.class) を使い、
        //       service.add("洗濯", Priority.LOW) を呼んだあと、
        //       repo.add(...) が1回呼ばれたことを verify で確認してください
        //       (findAll() は何もスタブしなくても、既定で空のリストを返します)
    }
}
  • 期待される結果: 2件のテストがすべて成功
ヒント1を見る

TaskRepository repo = mock(TaskRepository.class); TaskFactory factory = new TaskFactory(() -> "t9"); TaskService service = new TaskService(repo, factory);

ヒント2を見る

service.add("洗濯", Priority.LOW); verify(repo).add(any());

まとめ

  • mock(Foo.class)は「呼び出しを記録するだけの偽物」を作る。テスト対象(TaskService)のコードは1行も変えなくてよい
  • スタブしていないメソッドは戻り値の型に合わせた既定値(Listなら空リストなど)を返す
  • verify(mock).method(...)で「本当に呼ばれたか」、verify(mock, never()).method(...)で「呼ばれなかったこと」まで検証できる

次回: 画面に渡すデータの形、TaskDtoTaskMapperを作ります。

実際に動かしてみよう

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

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

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