本文へスキップ
BecomeCoder

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

TaskService ― タスク追加・完了をTDDで実装

ブラウザで完結

導入

TaskTitleは空文字や51文字以上を拒否しますが(レッスン6)、それは1つのタイトル単体の検証です。「既存のタスクと同じタイトルは登録できない」という判断は、他のタスクと見比べる必要があるためRepositoryを持つServiceの仕事です。TDDでこのルールを積み上げましょう。

図解

flowchart TB
    ADD["AddTask(title, priority)"] --> CHK{"同じタイトルが<br/>既にある?"}
    CHK -->|はい| ERR["InvalidOperationException"]
    CHK -->|いいえ| NEW["TaskFactory.Create → repository.Add"]
    style ERR fill:#ffebee
    style NEW fill:#e8f5e9

サンプル

Red(失敗するテスト)から書き始めます。

var repo = new InMemoryTaskRepository();
var service = new TaskService(repo);
service.AddTask("牛乳を買う", Priority.Medium);

// 同じタイトルはもう一度追加できない、というルールをテストで先に書く
bool duplicateRejected = false;
try { service.AddTask("牛乳を買う", Priority.Low); }
catch (InvalidOperationException) { duplicateRejected = true; }

Console.WriteLine(duplicateRejected);   // True
  • 「Redを書く」とは、まだ実装していないルールを先にテストとして書くこと
  • ここでは「重複したらInvalidOperationExceptionが飛ぶ」という仕様を先に固定した
  • 次にこれを通す最小実装(Green)を書く

やってみよう

CompleteTaskにも「存在しないIdを渡したら何も起きない(例外にしない)」というルールを足し、その仕様をテストとして書いてみましょう。

演習

var repo = new InMemoryTaskRepository();
var service = new TaskService(repo);
service.AddTask("牛乳を買う", Priority.Medium);

Assert(Throws(() => service.AddTask("牛乳を買う", Priority.Low)), "重複タイトルは追加できない");
Assert(repo.All().Count == 1, "重複は登録されない");

var t2 = service.AddTask("レポートを書く", Priority.High);
service.CompleteTask(t2.Id);
Assert(t2.IsCompleted, "完了フラグが立つ");
Console.WriteLine("全テスト成功");

// TODO: 同じタイトルのタスクが既にあれば InvalidOperationException を投げる AddTask と、
//       Id から探して Complete() する CompleteTask を実装してください
public class TaskService : ITaskService
{
    private readonly ITaskRepository _repository;
    public TaskService(ITaskRepository repository) => _repository = repository;
    ___
    public IReadOnlyList<TaskItem> GetActiveTasks() => _repository.All().Where(t => !t.IsCompleted).ToList();
}

bool Throws(Action action) { try { action(); return false; } catch { return true; } }
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を見る

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();

ヒント2を見る

追加前に_repository.All().Any(t => t.Title.Value == title)で重複を確認します

まとめ

  • 「他のデータと見比べる判断」はServiceの仕事(Repositoryは1件ずつの出し入れしか知らない)
  • 仕様を先にテスト(Red)として書き、それを通す実装(Green)を書くリズムはこの章でも変わらない
  • CompleteTaskは存在しないIdならFindByIdnullを返し、?.で何もしない

次回: このテストで本物のRepositoryをFakeに差し替えます。

実際に動かしてみよう

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

C# — ブラウザ内で実行

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