本文へスキップ
BecomeCoder

C# MVVMコース · 第6章 ViewModel ― 画面の状態と操作 · レッスン24

ViewModelのテストでServiceをFakeに差し替える

ブラウザで完結

導入

ここまでに作ったViewModelBaseObservableCollectionRelayCommandを組み合わせ、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を画面に表示します。

実際に動かしてみよう

本文のサンプルや演習のコードは、コードブロック右上の「コピー」ボタンでコピーして、下のエディタに貼り付ければそのまま実行できます。

C# — ブラウザ内で実行

ブラウザ内でC#を動かす環境を読み込みます(初回のみ数秒)。
スクロールして表示された時点でも自動で読み込まれます。