本文へスキップ
BecomeCoder

C# MVVMコース · 第9章 テストをやり込む ― TDDの総仕上げ · レッスン36

Service+Repositoryを通しでテストする ― 統合的な検証

ブラウザで完結

導入

第5〜6章のテストは、TaskService単体をFakeTaskRepositoryと組み合わせてテストするものが中心でした(レッスン18)。これは単体テスト――1つのクラスだけを、依存をFakeにして隔離するテストです。今回は逆に、本物のInMemoryTaskRepositoryと本物のTaskServiceを実際に組み合わせて、層をまたいだ動きを検証する統合テストを書きます。

説明

flowchart TB
    subgraph UNIT["単体テスト(第5-6章)"]
        S1["TaskService"] --> F["FakeTaskRepository"]
    end
    subgraph INTEG["統合テスト(このレッスン)"]
        S2["TaskService"] --> R["InMemoryTaskRepository(本物)"]
    end
    style F fill:#e8f5e9
    style R fill:#fff3e0
  • 単体テスト: 速い・失敗の原因を特定しやすい・数を多く書ける。ただし「Fakeが本物と同じように振る舞う」という前提の上に成り立つ
  • 統合テスト: 本物同士の組み合わせなので、その前提のズレに気づける。単体テストよりわずかに遅く、数は少なめでよい(重要な流れだけを確認する)
  • 目安: 細かい業務ルールの分岐は単体テストで数多く層をまたいだ主要な流れは統合テストで少数確認する、という役割分担
  • InMemoryTaskRepositoryは名前に「Memory」と付くだけで、これ自体が本物の実装。DBを用意しなくても本物同士を組み合わせた統合テストが書める、というのがこのアプリの構成上の利点

やってみよう

第6章のFakeTaskRepositoryを使ったテストと、このレッスンのInMemoryTaskRepositoryを使ったテストを見比べ、Assertの立て方自体は変わらないことを確認しましょう。

演習

var repository = new InMemoryTaskRepository();
var service = new TaskService(repository);

var t1 = service.AddTask("牛乳を買う", Priority.Low);
var t2 = service.AddTask("レポート提出", Priority.High);
service.CompleteTask(t1.Id);

Assert(repository.All().Count == 2, "Repositoryに2件実際に保存されている");
Assert(service.GetActiveTasks().Count == 1, "未完了はServiceを通しても1件");
Assert(service.GetActiveTasks()[0].Id == t2.Id, "残っているのはt2");

bool duplicateRejected = false;
try { service.AddTask("レポート提出", Priority.Medium); }
catch (InvalidOperationException) { duplicateRejected = true; }
Assert(duplicateRejected, "重複タイトルはRepositoryが本物でも拒否される");
Assert(repository.All().Count == 2, "拒否された分はRepositoryに増えていない");

Console.WriteLine("全テスト成功");

// TODO: 契約どおりの TaskService(重複タイトル拒否・CompleteTask・GetActiveTasks)を
//       実装してください。Fakeは一切使わず、InMemoryTaskRepository と組み合わせて
//       動くことを確認します
public class TaskService : ITaskService
{
    ___
}

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 class InMemoryTaskRepository : ITaskRepository
{
    private readonly List<TaskItem> _tasks = new();
    public void Add(TaskItem task) => _tasks.Add(task);
    public IReadOnlyList<TaskItem> All() => _tasks;
    public TaskItem? FindById(Guid id) => _tasks.FirstOrDefault(t => t.Id == id);
}
public interface ITaskService
{
    TaskItem AddTask(string title, Priority priority);
    void CompleteTask(Guid id);
    IReadOnlyList<TaskItem> GetActiveTasks();
}
  • 期待される出力: 全テスト成功
ヒント1を見る

実装はレッスン17と同じです: 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; }

ヒント2を見る

public void CompleteTask(Guid id) => _repository.FindById(id)?.Complete(); public IReadOnlyList<TaskItem> GetActiveTasks() => _repository.All().Where(t => !t.IsCompleted).ToList(); コンストラクタはpublic TaskService(ITaskRepository repository) => _repository = repository;のままです

まとめ

  • 単体テスト(Fakeで隔離)と統合テスト(本物同士を組み合わせる)は目的が違い、どちらか一方で十分ということはない
  • 統合テストは「層のつなぎ目」で前提がズレていないかを確認する
  • InMemoryTaskRepositoryメモリ上で完結するため、DBが無くても本物同士の組み合わせという統合テストの利点を得られる

次回: 章の締めくくり、テストしにくいコードを見分ける目を養います。

実際に動かしてみよう

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

C# — ブラウザ内で実行

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