メインコンテンツまでスキップ

第29章 テスト① Commandは単体テストが主役🧪✨

今回は「Command(書き込み側)」を 単体テストで守る 章だよ〜!🎀 CQRSって「読む(Query)」と「書く(Command)」を分けるけど、テストの向き不向きも分かれるのがポイント💡


1) この章のゴール🎯✨

この章を終えると、こんな感じになれるよ👇😊

  • ✅ Command の 業務ルール を単体テストで守れる
  • ✅ DBやWebの都合に左右されない 速いテスト が書ける
  • ✅ 「何をテストすべき?」「何は統合テストでいい?」が判断できる

2) 2026の「.NETテスト事情」ちょいメモ🗒️⚡

  • C# 14 は 2025年11月リリースとして案内されてるよ(=いま最新)🧡 (Microsoft Learn)
  • .NET 10 の dotnet testVSTestMicrosoft Testing Platform (MTP) で実行できるよ(テストランナー選択が .NET 10 SDK から可能)🚀 (Microsoft Learn)

ただし!最初は難しく考えなくてOK🙆‍♀️ ふつうに dotnet test で回せればまず勝ち!🎉 (Microsoft Learn)


3) なんで Command は単体テスト向きなの?🤔💖

Examining a bug under a microscope.

Command = 状態を変える側だよね✍️ そして状態を変えるときって、だいたいこうなる👇

  • 「タイトル必須」みたいな入力ルール🧾
  • 「期限切れは禁止」みたいな業務ルール📅
  • 「同じ名前はNG」みたいな一意性ルール🚫

つまり ルールの塊 になりやすい!🧱✨ ルールって テストの主役 なんだよね😊

逆に Query は、SQL/LINQ/JOIN/集計…みたいな「外の世界」の影響が強くて、単体で頑張りすぎると地獄になりがち😵(これは次章でやるね)


4) Command単体テストで「何を守る?」チェックリスト✅🧠

最低限、ここだけ守れたらかなり強い💪✨

  • ✅ 入力チェック(null/空/文字数/範囲)
  • ✅ 業務ルール(状態・期限・上限・重複など)
  • ✅ 失敗時の戻り値(エラーコード/メッセージ)
  • ✅ 成功時に「保存されるべきものが保存されたか」

逆に、単体テストで 無理しなくていい もの👇😇

  • ❌ EF Core のマッピングが正しいか
  • ❌ SQLが速いか
  • ❌ インデックスが効いてるか

それらは 統合テスト/計測 の世界(後の章)📌


5) テストしやすい CommandHandler の形🍱✨

単体テストしやすくするコツはシンプル!

コツA:依存を「注入」して、差し替えられるようにする🔁

  • Repository(保存先)
  • 時刻(現在時刻)
  • ユーザー情報(必要なら)

コツB:戻り値を「結果型」にして、例外まみれにしない🧯

例外は「落ちる」から、テストも読みづらくなりやすい🥲 業務エラーは 戻り値で表現できると超ラク✨


6) ミニ題材:ToDoの「作成Command」📝🐣

要件はこんな感じにするよ👇

  • タイトルは必須(空はダメ)
  • タイトルは100文字まで
  • 期限(DueDate)が過去ならダメ
  • 同じタイトルが既にあるならダメ(重複禁止)

7) 実装(アプリ側)✍️🧩

7-1) Command結果(成功/失敗)🎁

namespace TodoApp;

public sealed record CommandResult<T>(
bool IsSuccess,
T? Value,
string? ErrorCode,
string? ErrorMessage)
{
public static CommandResult<T> Success(T value)
=> new(true, value, null, null);

public static CommandResult<T> Fail(string code, string message)
=> new(false, default, code, message);
}

7-2) Entity / Repositoryインターフェイス📦

namespace TodoApp;

public sealed record TodoItem(
Guid Id,
string Title,
DateOnly? DueDate,
DateTimeOffset CreatedAtUtc);

public interface ITodoRepository
{
Task<bool> ExistsTitleAsync(string title, CancellationToken ct);
Task AddAsync(TodoItem item, CancellationToken ct);
}

7-3) Command / Handler 🧑‍🍳

namespace TodoApp;

public sealed record CreateTodoCommand(string Title, DateOnly? DueDate);

public sealed class CreateTodoHandler
{
private readonly ITodoRepository _repo;
private readonly TimeProvider _time;

public CreateTodoHandler(ITodoRepository repo, TimeProvider time)
{
_repo = repo;
_time = time;
}

public async Task<CommandResult<Guid>> HandleAsync(
CreateTodoCommand cmd,
CancellationToken ct = default)
{
// ①入力チェック
if (string.IsNullOrWhiteSpace(cmd.Title))
return CommandResult<Guid>.Fail("TITLE_REQUIRED", "タイトルは必須だよ🥺");

if (cmd.Title.Length > 100)
return CommandResult<Guid>.Fail("TITLE_TOO_LONG", "タイトルは100文字までだよ✂️");

// ②業務ルール(期限)
var today = DateOnly.FromDateTime(_time.GetUtcNow().UtcDateTime);
if (cmd.DueDate is not null && cmd.DueDate.Value < today)
return CommandResult<Guid>.Fail("DUE_IN_PAST", "期限が過去になってるよ📅💦");

// ③業務ルール(重複)
if (await _repo.ExistsTitleAsync(cmd.Title, ct))
return CommandResult<Guid>.Fail("DUPLICATE_TITLE", "同じタイトルがもうあるよ😵");

// ④作成して保存
var id = Guid.NewGuid();
var item = new TodoItem(
id,
cmd.Title.Trim(),
cmd.DueDate,
_time.GetUtcNow());

await _repo.AddAsync(item, ct);

return CommandResult<Guid>.Success(id);
}
}

8) テスト準備(テスト側)🧰🧪

xUnitでいくよ!🐾

xUnit は .NET 8 以降をサポートするよ(v3)✨ (xUnit.net) Microsoft Learn にも xUnit での単体テスト手順があるよ📚 (Microsoft Learn)

「時間」を固定するために FakeTimeProvider を使う⏰✨

FakeTimeProvider を使うと、テストで時間を確定できるよ! これは Microsoft が提供するテスト向け実装🧡 (Microsoft Learn)


9) ミニ演習:Commandテストを3本だけ書く🧪✅

9-1) テスト用の InMemoryRepository を作る🧺

using TodoApp;

public sealed class InMemoryTodoRepository : ITodoRepository
{
private readonly List<TodoItem> _items = new();

public Task<bool> ExistsTitleAsync(string title, CancellationToken ct)
=> Task.FromResult(_items.Any(x => x.Title == title));

public Task AddAsync(TodoItem item, CancellationToken ct)
{
_items.Add(item);
return Task.CompletedTask;
}

// テストで確認しやすいように追加(本番には不要でもOK)
public IReadOnlyList<TodoItem> Items => _items;
}

9-2) テスト本体(3本)🎉

using Microsoft.Extensions.Time.Testing;
using TodoApp;
using Xunit;

public sealed class CreateTodoHandlerTests
{
[Fact]
public async Task Succeeds_when_valid()
{
// Arrange 🧁
var repo = new InMemoryTodoRepository();

var time = new FakeTimeProvider();
time.SetUtcNow(new DateTimeOffset(2026, 1, 24, 0, 0, 0, TimeSpan.Zero));

var handler = new CreateTodoHandler(repo, time);

// Act 🏃‍♀️
var result = await handler.HandleAsync(new CreateTodoCommand(
Title: "レポート提出",
DueDate: new DateOnly(2026, 1, 25)));

// Assert ✅
Assert.True(result.IsSuccess);
Assert.NotEqual(Guid.Empty, result.Value);
Assert.Single(repo.Items);
Assert.Equal("レポート提出", repo.Items[0].Title);
}

[Fact]
public async Task Fails_when_title_is_empty()
{
// Arrange 🍬
var repo = new InMemoryTodoRepository();
var time = new FakeTimeProvider();
time.SetUtcNow(new DateTimeOffset(2026, 1, 24, 0, 0, 0, TimeSpan.Zero));

var handler = new CreateTodoHandler(repo, time);

// Act 🏃‍♀️
var result = await handler.HandleAsync(new CreateTodoCommand(
Title: " ",
DueDate: null));

// Assert ✅
Assert.False(result.IsSuccess);
Assert.Equal("TITLE_REQUIRED", result.ErrorCode);
Assert.Empty(repo.Items);
}

[Fact]
public async Task Fails_when_due_date_is_in_the_past()
{
// Arrange 🍓
var repo = new InMemoryTodoRepository();
var time = new FakeTimeProvider();
time.SetUtcNow(new DateTimeOffset(2026, 1, 24, 0, 0, 0, TimeSpan.Zero));

var handler = new CreateTodoHandler(repo, time);

// Act 🏃‍♀️
var result = await handler.HandleAsync(new CreateTodoCommand(
Title: "過去期限のタスク",
DueDate: new DateOnly(2026, 1, 23)));

// Assert ✅
Assert.False(result.IsSuccess);
Assert.Equal("DUE_IN_PAST", result.ErrorCode);
Assert.Empty(repo.Items);
}
}

🎊 できた!これで「Commandのルール」が単体テストで守れるようになったよ!


10) テストの実行方法🏁🖱️

そのまま dotnet test でOK✨

dotnet test はテストをビルドして実行する標準コマンドだよ🧪 (Microsoft Learn)

dotnet test

(Visual Studio なら Test Explorer で ▶ 実行できるよ〜😊)


11) よくあるつまずきポイント集😵‍💫🩹

つまずき①:DateTime.Now を直に使ってテストが不安定⏰💥

TimeProvider + FakeTimeProvider で固定しよう✨ (Microsoft Learn)

つまずき②:HandlerがDB直結でテストが重い🐢

→ Repository をインターフェイスにして、単体テストは InMemory に差し替え👍

つまずき③:テストが「実装の写経」になる📋

→ “どう実装したか” じゃなくて、ルール(仕様) を Assert しよ✨ 例:

  • 「期限が過去なら必ず失敗」
  • 「成功したら保存される」

12) AI(Copilot / Codex)活用プロンプト例🤖💞

①テストケースを出させる(取捨選択する)✅

  • 「CreateTodoCommand の仕様はこれ。境界値を含むテストケース案を10個出して。重要度も付けて」

②エラーコードの粒度チェック🧯

  • 「このエラーコード設計、UI/ログ/保守の観点で過不足ない?改善案ちょうだい」

③テストを読みやすくするリファクタ🧼

  • 「このテスト、読みやすくするためにArrangeの共通化を提案して。ただし抽象化しすぎないで」

コツ:AIの案は“採用する前に” 自分のチェックリスト(何のルールを守りたい?)でふるいにかけるのが最強だよ😺✨


13) まとめ🎀(ここだけ覚えてたら勝ち🏆)

  • Command は ルールの塊 → 単体テストがいちばん効く🧪✨
  • 単体テストは 速い・安定・怖くない を目指す🏃‍♀️💨
  • 時刻みたいな “ブレるもの” は注入して固定(TimeProvider)⏰
  • まずは テスト3本 でOK!少数精鋭で守る✅

次の第30章は「Queryは統合寄り(現実路線)」だよ〜🧫✨ 「単体で頑張りすぎない判断」を一緒に身につけよっ😊💖