導入
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ならFindByIdがnullを返し、?.で何もしない
次回: このテストで本物のRepositoryをFakeに差し替えます。