本文へスキップ
BecomeCoder

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

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

ブラウザで完結

導入

前回のテストInMemoryTaskRepositoryを使いましたが、実務でRepositoryの実体がDB接続を必要とする場合、テストのたびにDBを用意するのは大変です。レッスン14FakeTaskRepositoryTaskServiceに注入し、DBなしで業務ロジックだけを検証しましょう。

図解

sequenceDiagram
    participant Test
    participant TaskService
    participant FakeTaskRepository
    Test->>TaskService: new TaskService(fake)
    Test->>TaskService: AddTask("牛乳を買う", ...)
    TaskService->>FakeTaskRepository: Add(task)
    Test->>FakeTaskRepository: AddCallCount を確認

サンプル

TaskServiceのコードは1行も変えず、渡すRepositoryだけを差し替えます。

var fake = new FakeTaskRepository();
var service = new TaskService(fake);

service.AddTask("牛乳を買う", Priority.Medium);

Console.WriteLine(fake.AddCallCount);            // 1(本当にRepositoryへ渡されたか検証できる)
Console.WriteLine(service.GetActiveTasks().Count); // 1(業務ロジックの結果も検証できる)
  • TaskServiceITaskRepositoryにしか依存していないため、InMemoryTaskRepositoryFakeTaskRepositoryのどちらを渡しても動く(DIP/レッスン15
  • Fakeを使えば「結果(GetActiveTasks)」だけでなく「呼び出し(AddCallCount)」まで検証できる
  • DBや外部APIに依存するRepositoryほど、Fakeでのテストの価値が大きくなる

やってみよう

AddTaskで重複タイトルを渡し、fake.AddCallCountが増えていない(=Repositoryまで到達していない)ことを確認してみましょう。

演習

var fake = new FakeTaskRepository();
var service = new TaskService(fake);

service.AddTask("牛乳を買う", Priority.Medium);
service.AddTask("卵を買う", Priority.Low);

Assert(fake.AddCallCount == 2, "Addが2回呼ばれた");
Assert(service.GetActiveTasks().Count == 2, "未完了が2件");
Console.WriteLine("全テスト成功");

// TODO: ITaskRepository を実装し、Add が呼ばれた回数を AddCallCount で数える
//       FakeTaskRepository を実装してください
___

void Assert(bool c, string n) { if (!c) throw new Exception($"FAIL: {n}"); }

// --- 型のコピー ---
public readonly struct TaskTitle : IEquatable<TaskTitle>
{
    public string Value { get; }
    public TaskTitle(string value)
    {
        if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("タイトルは空にできません");
        if (value.Length > 50) throw new ArgumentException("タイトルは50文字以内です");
        Value = value.Trim();
    }
    public bool Equals(TaskTitle other) => Value == other.Value;
    public override string ToString() => Value;
}
public enum Priority { Low, Medium, High }
public 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;
    public void Rename(TaskTitle newTitle) => Title = newTitle;
}
public static class TaskFactory
{
    public static TaskItem Create(string title, Priority priority = Priority.Medium)
        => new TaskItem(Guid.NewGuid(), new TaskTitle(title), priority);
}
public interface ITaskRepository
{
    void Add(TaskItem task);
    IReadOnlyList<TaskItem> All();
    TaskItem? FindById(Guid id);
}
public interface ITaskService
{
    TaskItem AddTask(string title, Priority priority);
    void CompleteTask(Guid id);
    IReadOnlyList<TaskItem> GetActiveTasks();
}
public class TaskService : ITaskService
{
    private readonly ITaskRepository _repository;
    public TaskService(ITaskRepository repository) => _repository = repository;
    public TaskItem AddTask(string title, Priority priority)
    {
        if (_repository.All().Any(t => t.Title.Value == title))
            throw new InvalidOperationException("同じタイトルのタスクが既にあります");
        var task = TaskFactory.Create(title, priority);
        _repository.Add(task);
        return task;
    }
    public void CompleteTask(Guid id) => _repository.FindById(id)?.Complete();
    public IReadOnlyList<TaskItem> GetActiveTasks() => _repository.All().Where(t => !t.IsCompleted).ToList();
}
  • 期待される出力: 全テスト成功
ヒント1を見る

public class FakeTaskRepository : ITaskRepository { private readonly List<TaskItem> _tasks = new(); public int AddCallCount { get; private set; } public void Add(TaskItem task) { _tasks.Add(task); AddCallCount++; } public IReadOnlyList<TaskItem> All() => _tasks; public TaskItem? FindById(Guid id) => _tasks.FirstOrDefault(t => t.Id == id); }

ヒント2を見る

TaskService側のコードは変更不要です。渡すRepositoryをFakeTaskRepositoryにするだけで動きます

まとめ

  • テスト対象(TaskService)のコードを変えずに、依存だけをFakeに差し替えられるのがDIの効能
  • Fakeなら結果の検証(GetActiveTasks)と呼び出しの検証(AddCallCount)の両方ができる
  • DB・外部APIに依存するほど、Fakeによるテストの価値は大きくなる

次回: 画面に渡すデータの形を分けるTaskDtoTaskMapperです。

実際に動かしてみよう

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

C# — ブラウザ内で実行

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