第12章:CQSとテスト②(Commandの副作用を確かめる🎭🧪)
この章のゴール🎯✨
Commandのテストは「返り値を見る」よりも、次の2つをちゃんと確認できるようになるのがゴールだよ〜😊
- 状態がどう変わったか(DB/メモリ上のデータ、ドメイン状態)✅
- 副作用が起きたか(メール送信、外部API、イベント発行、ログ、ファイル etc)📨🌐
そして、そのために 外部I/Oを差し替えてテストする感覚🔌 を身につけるよ!
1) まず結論:Commandテストの見取り図🗺️🧠

Commandはだいたいこう👇
- 入力を受け取る(Command)📥
- ルールチェック(バリデーション)🧾
- 状態変更(Repository/DBに反映)🧱
- 副作用(メール・イベント・外部API)🎇
- 成功/失敗を返す(Resultなど)🎁
だからテストはこうなる👇
- 「正しい状態変更」できた?
- 「必要な副作用」だけ起きた?(余計なことしてない?)
- 「失敗時は何もしない」になってる?(ここ超大事!)🛑✨
2) “モック/スタブ”って何?(最小だけ)🎭🧸
難しい言葉は最小でOK〜😊 よく使うのはこの4つ!
- Stub(スタブ):呼ばれたら“決まった値”を返す係🔁
- Fake(フェイク):簡易実装(インメモリDBみたいな)🍱
- Spy(スパイ):呼ばれた記録を取って後で確認する係👀📝 慣れたら Moq みたいなライブラリでMockするのもOK🙆♀️(MoqはNuGetで広く使われてるよ〜) (NuGet)
3) お題:ToDoの「完了Command」をテストしてみよ📝✅
3-1. 依存を“差し替え可能”にする(テストの入口🔑)
Command側が DBやメール送信を直接newしてたら テストは地獄になるよね😇💥 なので、インターフェースにして外から注入するよ🔌
ここが“依存関係ルール”の入り口っぽいところ: 中(ユースケース)は外(DBやメール)を知らない 👉 だから差し替えできる✨
3-2. 最小の実装例(Resultで返すパターン🎁)
// ドメイン
public sealed class TodoItem
{
public Guid Id { get; }
public string Title { get; }
public bool IsDone { get; private set; }
public DateTimeOffset? DoneAt { get; private set; }
public TodoItem(Guid id, string title)
{
Id = id;
Title = title;
}
public void Complete(DateTimeOffset now)
{
if (IsDone) return; // 二重完了は無視する方針(例)
IsDone = true;
DoneAt = now;
}
}
// 外部I/Oの境界(差し替えポイント)
public interface ITodoRepository
{
Task<TodoItem?> FindByIdAsync(Guid id, CancellationToken ct);
Task SaveAsync(TodoItem item, CancellationToken ct);
}
public interface IEmailSender
{
Task SendCompletedAsync(string title, CancellationToken ct);
}
// Command + Handler
public readonly record struct CompleteTodoCommand(Guid TodoId);
public readonly record struct Result(bool IsSuccess, string? Error)
{
public static Result Ok() => new(true, null);
public static Result Fail(string error) => new(false, error);
}
public sealed class CompleteTodoHandler
{
private readonly ITodoRepository _repo;
private readonly IEmailSender _email;
private readonly TimeProvider _time;
public CompleteTodoHandler(ITodoRepository repo, IEmailSender email, TimeProvider time)
=> (_repo, _email, _time) = (repo, email, time);
public async Task<Result> HandleAsync(CompleteTodoCommand cmd, CancellationToken ct)
{
var todo = await _repo.FindByIdAsync(cmd.TodoId, ct);
if (todo is null) return Result.Fail("NotFound");
var now = _time.GetUtcNow();
todo.Complete(now);
await _repo.SaveAsync(todo, ct);
await _email.SendCompletedAsync(todo.Title, ct);
return Result.Ok();
}
}
ここでの見どころ👀✨
- TimeProviderを注入してるから、テストで“時間”を固定できるよ⏰
- .NETではテスト用の FakeTimeProvider も用意されてる(MicrosoftのTestingパッケージ) (NuGet)
4) テスト①:手書きFake/Spyでいく(初心者に最強✍️💪)
4-1. Fake/Spyを作る🧸🕵️♀️
public sealed class FakeTodoRepository : ITodoRepository
{
private readonly Dictionary<Guid, TodoItem> _store = new();
public int SaveCallCount { get; private set; }
public void Seed(TodoItem item) => _store[item.Id] = item;
public Task<TodoItem?> FindByIdAsync(Guid id, CancellationToken ct)
=> Task.FromResult(_store.TryGetValue(id, out var item) ? item : null);
public Task SaveAsync(TodoItem item, CancellationToken ct)
{
SaveCallCount++;
_store[item.Id] = item;
return Task.CompletedTask;
}
}
public sealed class SpyEmailSender : IEmailSender
{
public int SendCallCount { get; private set; }
public string? LastTitle { get; private set; }
public Task SendCompletedAsync(string title, CancellationToken ct)
{
SendCallCount++;
LastTitle = title;
return Task.CompletedTask;
}
}
4-2. テストを書く(AAA:Arrange-Act-Assert)🧪✨
(ここではテストフレームワークは例としてMSTestっぽい書き方にするね。MSTestは最近も更新が続いてて、v4の移行ガイドも公式にあるよ📘) (Microsoft Learn)
[TestClass]
public sealed class CompleteTodoHandlerTests
{
[TestMethod]
public async Task 完了すると_状態が変わり_SaveとEmailが1回ずつ呼ばれる()
{
// Arrange
var repo = new FakeTodoRepository();
var email = new SpyEmailSender();
var id = Guid.NewGuid();
repo.Seed(new TodoItem(id, "レポート提出"));
var time = new Microsoft.Extensions.Time.Testing.FakeTimeProvider();
time.SetUtcNow(new DateTimeOffset(2026, 1, 20, 0, 0, 0, TimeSpan.Zero));
var sut = new CompleteTodoHandler(repo, email, time);
// Act
var result = await sut.HandleAsync(new CompleteTodoCommand(id), CancellationToken.None);
// Assert
Assert.IsTrue(result.IsSuccess);
var saved = await repo.FindByIdAsync(id, CancellationToken.None);
Assert.IsNotNull(saved);
Assert.IsTrue(saved!.IsDone);
Assert.AreEqual(new DateTimeOffset(2026, 1, 20, 0, 0, 0, TimeSpan.Zero), saved.DoneAt);
Assert.AreEqual(1, repo.SaveCallCount);
Assert.AreEqual(1, email.SendCallCount);
Assert.AreEqual("レポート提出", email.LastTitle);
}
[TestMethod]
public async Task 存在しないIDなら_NotFoundで_SaveもEmailもしない()
{
// Arrange
var repo = new FakeTodoRepository();
var email = new SpyEmailSender();
var time = new Microsoft.Extensions.Time.Testing.FakeTimeProvider();
var sut = new CompleteTodoHandler(repo, email, time);
// Act
var result = await sut.HandleAsync(new CompleteTodoCommand(Guid.NewGuid()), CancellationToken.None);
// Assert
Assert.IsFalse(result.IsSuccess);
Assert.AreEqual("NotFound", result.Error);
Assert.AreEqual(0, repo.SaveCallCount);
Assert.AreEqual(0, email.SendCallCount);
}
}
ポイント😊💡
- 成功時:状態変化 + 副作用を両方チェック✅
- 失敗時:副作用ゼロをチェック🛑(これが事故防止に効く!)
5) テスト②:Moqで“呼ばれ方”を検証する(便利だけど使いすぎ注意🎭⚠️)
MoqはNuGetで配布されてて、今もメンテされてるよ〜 (NuGet) ただし初心者さんは 「全部Verify地獄」 になりがちなので、大事な副作用だけに絞ろうね🥹
雰囲気だけ例👇
var repo = new Moq.Mock<ITodoRepository>();
var email = new Moq.Mock<IEmailSender>();
repo.Setup(r => r.FindByIdAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync(new TodoItem(id, "レポート提出"));
await sut.HandleAsync(new CompleteTodoCommand(id), CancellationToken.None);
repo.Verify(r => r.SaveAsync(It.IsAny<TodoItem>(), It.IsAny<CancellationToken>()), Times.Once);
email.Verify(e => e.SendCompletedAsync("レポート提出", It.IsAny<CancellationToken>()), Times.Once);
注意⚠️
- 内部の実装手順まで縛ると、リファクタでテストが壊れやすい😵💫
- なるべく 「観測できる結果(状態)」を優先して、 “本当に大事な外部副作用”だけVerifyするのがおすすめ😊✨
6) 外部I/Oごとの“差し替え”アイデア集🔌📦
DB(Repository)
- ユニットテスト:FakeRepository(今回のやつ)でOK👌
- もう一歩:EFならSQLiteインメモリ等(※ここは次の段階でOK)🧀
HTTP(外部API)
- HttpClientは
HttpMessageHandlerを差し替える設計にするとテストしやすい🌐✨
時刻(地味に重要!)
TimeProvider+FakeTimeProviderでテストが安定する⏰🧪 (NuGet)
“実物に近い”統合テスト(発展🔥)
- Dockerが使えるなら Testcontainers for .NET で「使い捨てDB」を立ててテストもできるよ🧪🐳 (dotnet.testcontainers.org) (ただし今章は“入門”なので、まずはユニットテストで十分!)
7) AI(Copilot/Codex)に手伝ってもらうコツ🤖🧷
7-1. テスト案を出させるプロンプト例💬
- 「このCommandの成功/失敗パターンを洗い出して、AAAでテスト名も付けて」
- 「副作用(Save/Email)が呼ばれる条件・呼ばれない条件を表にして」
- 「NotFoundのときに何も起きないことを保証するテストを書いて」
7-2. 事故りやすいAIあるある⚠️😂
- Verifyしまくって“実装依存テスト”になる
- 時刻が固定されてなくてテストがたまに落ちる
- Arrangeがでかすぎて読めない
👉 AIが出したテストは、最後にこの質問でセルフレビューすると強いよ😊
- 「このテスト、仕様を守ってる?それとも実装手順を縛ってる?」🪞
8) 仕上げチェックリスト✅✨(ここだけ覚えて帰ってOK!)
- 成功時:状態変化をAssertしてる?✅
- 成功時:重要な副作用を確認してる?📨
- 失敗時:副作用ゼロを確認してる?🛑
- 時刻/乱数/IDなど、不安定要素を固定できてる?⏰
- テスト名が「何が起きるか」を日本語で言えてる?📝
次の章(第13章)では、引数が増えてきたときにキレイに保つために **Command/Queryを“オブジェクト化”**していくよ〜📦✨ その前に、いまのToDo題材で「メール送信は成功時だけ」「二重完了はどうする?」みたいな仕様を1個だけ足して、テストも増やしてみよっか?😊🎀