導入
ここまでに作ったViewModelBase・状態(newTitle/getTasks)・Command/RelayCommand/canExecuteを組み合わせ、TaskServiceに依存する完成形のTaskListViewModelをTDDで作ります。ここでは本物のTaskServiceではなく、Mockitoのmock(TaskService.class)を注入してテストします。ViewModelのテストに、DBやファイル、JavaFXは一切登場しません。
図解
classDiagram
class TaskService {
+add(title, priority)
+complete(id)
+list() List~TaskDto~
+remainingCount() int
}
class TaskListViewModel {
+getNewTitle() String
+setNewTitle(value)
+getTasks() List~TaskDto~
+getAddCommand() Command
+complete(id)
}
TaskListViewModel --> TaskService : コンストラクタ注入(テストではmock)
やってみよう
下の実行ウィジェットで、mock(TaskService.class)を差し替えるだけでViewModelの振る舞い(addCommandの実行やcompleteの委譲)を検証できることを確認しましょう。
演習
いよいよ実務に近い構成です。エディタは7つのタブに分かれています――TaskListViewModel.java(今回あなたが実装するファイル)・Domain.java(第2〜4章の土台)・Service.java(第5章のTaskDto/TaskMapper/TaskService)・ViewModelBase.java(第21レッスンの土台)・Command.java・RelayCommand.java(第23レッスンの土台)・TaskListViewModelTest.java(テスト)。責務ごとにファイルが分かれていることを意識しながら進めましょう。
import java.util.List;
// TODO: TaskListViewModel を完成させてください
// ・コンストラクタで TaskService を受け取る(DI)
// ・getNewTitle() / setNewTitle(value): 変わったら "newTitle" と "canAdd" を通知する
// ・getTasks(): service.list() をそのまま返す
// ・getRemainingCount(): service.remainingCount() をそのまま返す
// ・getAddCommand(): 実行すると service.add(newTitle, Priority.MEDIUM) を呼び、
// newTitle を "" に戻す RelayCommand。canExecute は newTitle が空でないこと
// ・complete(id): service.complete(id) を呼ぶ
public class TaskListViewModel extends ViewModelBase {
}
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.List;
import java.util.ArrayList;
import java.util.Optional;
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) {
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 List<TaskDto> list() {
List<TaskDto> result = new ArrayList<>();
for (TaskItem t : repository.findAll()) {
result.add(TaskMapper.toDto(t));
}
return result;
}
public int remainingCount() {
int count = 0;
for (TaskItem t : repository.findAll()) {
if (!t.isCompleted()) count++;
}
return count;
}
}
import java.util.ArrayList;
import java.util.List;
public interface PropertyChangedListener {
void onPropertyChanged(String propertyName);
}
public abstract class ViewModelBase {
private final List<PropertyChangedListener> listeners = new ArrayList<>();
public void addPropertyChangedListener(PropertyChangedListener listener) {
listeners.add(listener);
}
protected void raisePropertyChanged(String propertyName) {
for (PropertyChangedListener listener : listeners) {
listener.onPropertyChanged(propertyName);
}
}
}
public interface Command {
void execute();
default boolean canExecute() { return true; }
}
import java.util.function.BooleanSupplier;
public class RelayCommand implements Command {
private final Runnable action;
private final BooleanSupplier canExecuteCheck;
public RelayCommand(Runnable action) {
this.action = action;
this.canExecuteCheck = null;
}
public RelayCommand(Runnable action, BooleanSupplier canExecute) {
this.action = action;
this.canExecuteCheck = canExecute;
}
public void execute() {
action.run();
}
public boolean canExecute() {
return canExecuteCheck == null || canExecuteCheck.getAsBoolean();
}
}
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
public class TaskListViewModelTest {
@Test
void titleWoNyuryokuSuruto_AddCommandGaJikkoKaniNaru() {
TaskService service = mock(TaskService.class);
TaskListViewModel vm = new TaskListViewModel(service);
vm.setNewTitle("牛乳を買う");
assertTrue(vm.getAddCommand().canExecute(), "タイトルがあれば追加可能");
}
@Test
void addCommandWoJikkoSuruto_ServiceNiTsuikaGaIraiSareNyuryokuranGaClearSareru() {
TaskService service = mock(TaskService.class);
TaskListViewModel vm = new TaskListViewModel(service);
vm.setNewTitle("牛乳を買う");
vm.getAddCommand().execute();
verify(service).add(eq("牛乳を買う"), eq(Priority.MEDIUM));
assertEquals("", vm.getNewTitle(), "追加後は入力欄がクリアされる");
}
@Test
void completeWoYobuto_ServiceNoCompleteNiIjoSareru() {
TaskService service = mock(TaskService.class);
TaskListViewModel vm = new TaskListViewModel(service);
vm.complete("t1");
verify(service).complete("t1");
}
}
- 期待される結果: 3件のテストがすべて成功
ヒント1を見る
コンストラクタはthis.service = service; addCommand = new RelayCommand(() -> add(), () -> !newTitle.isBlank());。addはprivate void add() { service.add(newTitle, Priority.MEDIUM); setNewTitle(""); }
ヒント2を見る
completeはpublic void complete(String id) { service.complete(id); }のように、ただServiceへ委譲するだけです
まとめ
- ViewModelのテストは「本物のTaskService」ではなく
mock(TaskService.class)を注入して行う addCommandの実行結果(Serviceへの委譲・入力欄クリア)やcompleteの委譲まで、verifyで検証できる- DB・ファイル・JavaFXが一切登場しない、高速で壊れにくいテストになる。これで第7章、JavaFXへの接続に進む準備が整った
次章: 実際にJavaFXのViewとつなぎ、ここまでのViewModelを画面に表示します。