導入
ここまでに作ったViewModelBase・ObservableCollection・RelayCommandを組み合わせ、ITaskServiceに依存する完成形のTaskListViewModelをTDDで作ります。前章までと同じく、本物のTaskServiceではなく手作りのFakeサービスを注入してテストします。ViewModelのテストに、DBやファイルは一切登場しません。
図解
classDiagram
class ITaskService {
<<interface>>
+AddTask(title, priority) TaskItem
+CompleteTask(id)
+GetActiveTasks() IReadOnlyList
}
class FakeTaskService {
テスト用の手作りFake
}
class TaskListViewModel {
+Tasks : ObservableCollection~TaskDto~
+NewTaskTitle : string
+AddCommand : ICommand
}
ITaskService <|.. FakeTaskService
TaskListViewModel --> ITaskService : コンストラクタ注入
やってみよう
下の実行ウィジェットで、FakeTaskServiceを差し替えるだけでViewModelの振る舞いを検証できることを確認しましょう。
演習
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
var fakeService = new FakeTaskService();
var vm = new TaskListViewModel(fakeService);
vm.NewTaskTitle = "牛乳を買う";
Assert(vm.AddCommand.CanExecute(null), "タイトルがあれば追加可能");
vm.AddCommand.Execute(null);
Assert(vm.Tasks.Count == 1, "追加した1件が画面用のTasksに反映される");
Assert(vm.Tasks[0].Title == "牛乳を買う", "タイトルが反映される");
Assert(vm.NewTaskTitle == "", "追加後は入力欄がクリアされる");
Assert(fakeService.AddedTitles.Count == 1, "Serviceにも追加が委譲される");
Console.WriteLine("全テスト成功");
// テスト専用の手作りFake(本物のTaskServiceは使わない)
class FakeTaskService : ITaskService
{
public List<string> AddedTitles { get; } = new();
private readonly List<TaskItem> _tasks = new();
public TaskItem AddTask(string title, Priority priority)
{
var task = TaskFactory.Create(title, priority);
_tasks.Add(task);
AddedTitles.Add(title);
return task;
}
public void CompleteTask(Guid id) => _tasks.First(t => t.Id == id).Complete();
public IReadOnlyList<TaskItem> GetActiveTasks() => _tasks.Where(t => !t.IsCompleted).ToList();
}
// TODO: TaskListViewModel を完成させてください
// ・コンストラクタで ITaskService を受け取る(DI)
// ・Tasks: ObservableCollection<TaskDto>
// ・NewTaskTitle: string(SetPropertyで通知)
// ・AddCommand: 実行すると service.AddTask(NewTaskTitle, Priority.Medium) を呼び、
// 結果を TaskMapper.ToDto で変換して Tasks に追加し、NewTaskTitle を "" に戻す
// ・AddCommand の CanExecute は NewTaskTitle が空でないことを条件にする
class TaskListViewModel : ViewModelBase
{
___
}
// --- ここから下は前章までに作った契約(シグネチャは変えず簡潔にコピー) ---
interface ITaskService
{
TaskItem AddTask(string title, Priority priority);
void CompleteTask(Guid id);
IReadOnlyList<TaskItem> GetActiveTasks();
}
record TaskDto(Guid Id, string Title, string Priority, bool IsCompleted);
static class TaskMapper
{
public static TaskDto ToDto(TaskItem task) => new(task.Id, task.Title.Value, task.Priority.ToString(), task.IsCompleted);
}
static class TaskFactory
{
public static TaskItem Create(string title, Priority priority = Priority.Medium)
=> new TaskItem(Guid.NewGuid(), new TaskTitle(title), priority);
}
enum Priority { Low, Medium, High }
class TaskItem
{
public Guid Id { get; }
public TaskTitle Title { get; private set; }
public Priority Priority { get; private set; }
public bool IsCompleted { get; private set; }
public TaskItem(Guid id, TaskTitle title, Priority priority) { Id = id; Title = title; Priority = priority; }
public void Complete() => IsCompleted = true;
}
readonly struct TaskTitle
{
public string Value { get; }
public TaskTitle(string value)
{
if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("タイトルは空にできません");
Value = value.Trim();
}
public override string ToString() => Value;
}
abstract class ViewModelBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
OnPropertyChanged(name);
return true;
}
}
class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool>? _canExecute;
public RelayCommand(Action execute, Func<bool>? canExecute = null) { _execute = execute; _canExecute = canExecute; }
public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true;
public void Execute(object? parameter) => _execute();
public event EventHandler? CanExecuteChanged;
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
void Assert(bool condition, string name)
{
if (!condition) throw new Exception($"FAIL: {name}");
}
- 期待される出力:
全テスト成功
ヒント1を見る
コンストラクタは_service = service; AddCommand = new RelayCommand(Add, () => !string.IsNullOrWhiteSpace(NewTaskTitle));
ヒント2を見る
private void Add() { var task = _service.AddTask(NewTaskTitle, Priority.Medium); Tasks.Add(TaskMapper.ToDto(task)); NewTaskTitle = ""; }
まとめ
- ViewModelのテストは「本物のService」ではなく「テスト用のFakeService」を注入して行う
AddCommandの実行結果(Tasksへの反映・入力欄クリア)まで含めて検証できる- DB・ファイル・UIフレームワークが一切登場しない、高速で壊れにくいテストになる
次章(別担当が執筆): 実際にWPFのViewとつなぎ、ここまでのViewModelを画面に表示します。