導入
これまでの章のテストは、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を見る
addはrepository.add(factory.create(title, priority));、completeはrepository.findById(id)が返るOptionalの中身があればcomplete()を呼びます
まとめ
- 単体テスト(Fakeやモックで隔離)と統合テスト(本物同士を組み合わせる)は目的が違い、どちらか一方で十分ということはない
- 統合テストは「層のつなぎ目」で前提がズレていないかを確認する
InMemoryTaskRepositoryはメモリ上で完結するため、DBが無くても本物同士の組み合わせという統合テストの利点を得られる
次回: 章の締めくくり、テストしにくいコードを見分ける目を養います。