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

第43章:Entitiesのテスト(速い・堅い・気持ちいい)🍰🧪✨

Entities(Entity / Value Object)は、アプリの“心臓”だよね🫀 だからこそ 最優先でテストして守る と、あとあと超ラクになります💖 しかもEntitiesはフレームワークに依存しない「純C#」だから、テストが爆速で気持ちいい〜😆⚡

(ちなみに今どきのC#は C# 14 + .NET 10(LTS) が軸だよ〜🧡 (Microsoft Learn))


この章のゴール🎯✨

次ができるようになろう〜😊💪

  • Value Object の 不変条件(Invariants) をテストで守れる🚧💎
  • Entity の ふるまい(メソッド) をテストで守れる🧠🪪
  • 「外部なし」=DBなし/HTTPなし/DIなしで、超高速に回せる🏎️💨
  • テストが「仕様書」になって、設計がブレにくくなる📘✨

Entitiesテストが“いちばんコスパ良い”理由💰💖

爆速テスト (Entity Testing)

1) 速い⚡(だから毎回回せる)

  • 依存がない=起動も準備もいらない😆
  • テスト実行が早いほど、気軽に何回も回せる🔁✨

2) 壊れやすいのは“ルール”🧨(だから守る)

  • 変更で壊れがちなのって「タイトル空OKになっちゃった」とか「重複タグが入る」とか…地味だけど致命傷💀
  • Entitiesテストは、そこをガチガチに守る🛡️✨

3) 設計がキレイになりやすい🧼

  • テストしやすいEntitiesは、だいたい責務が整理されてる😊
  • テスト書きながら「ここ変だな?」が見える👀✨

テスト基盤は何を使う?🧰✨(おすすめ:xUnit)

Visual Studioで気持ちよく回すなら xUnit が定番だよ〜😊 今は xUnit v3 が .NET 8+ をサポートしてるので、.NET 10でもOK🧡 (xunit.net) さらにVisual Studio 2026まわりでは Microsoft Testing Platform との話も進んでるよ〜🧪✨ (xunit.net)


まずは題材:MemoのEntitiesを“最小”で用意しよう📝✨

ここでは「メモ管理」のEntitiesだけ作って、テストするよ〜😊 (UseCaseとかAPIとかは一切いらない🙅‍♀️✨)

例:ドメインルール(テストで守りたいこと)📌

  • タイトルは 空/空白だけNG
  • タイトルは 最大100文字(例)✍️
  • タグ名は 空NG ❌、重複追加NG
  • Archiveしたら IsArchived=true

実装例:Entities(C#)🧩💎

ここは「説明用にシンプル」だけど、テスト観点はそのまま実戦で使えるよ〜😊✨

using System;
using System.Collections.Generic;

namespace MyApp.Core.Entities;

// ドメインのバリデーションエラー(“失敗=仕様”)
public sealed class DomainValidationException : Exception
{
public string Code { get; }

public DomainValidationException(string code, string message) : base(message)
=> Code = code;
}

// Value Object:MemoTitle(不変条件をここで守る)
public sealed record MemoTitle
{
public string Value { get; }

private MemoTitle(string value) => Value = value;

public static MemoTitle Create(string? value)
{
if (string.IsNullOrWhiteSpace(value))
throw new DomainValidationException("MemoTitle.Empty", "タイトルは必須です");

var v = value.Trim();

if (v.Length > 100)
throw new DomainValidationException("MemoTitle.TooLong", "タイトルが長すぎます");

return new MemoTitle(v);
}

public override string ToString() => Value;
}

// Value Object:TagName
public sealed record TagName
{
public string Value { get; }

private TagName(string value) => Value = value;

public static TagName Create(string? value)
{
if (string.IsNullOrWhiteSpace(value))
throw new DomainValidationException("TagName.Empty", "タグ名は必須です");

var v = value.Trim();

if (v.Length > 30)
throw new DomainValidationException("TagName.TooLong", "タグ名が長すぎます");

return new TagName(v);
}

public override string ToString() => Value;
}

// Entity:Memo(同一性+ふるまい)
public sealed class Memo
{
public Guid Id { get; }
public MemoTitle Title { get; private set; }
public bool IsArchived { get; private set; }

private readonly HashSet<TagName> _tags = new();
public IReadOnlyCollection<TagName> Tags => _tags;

public Memo(Guid id, MemoTitle title)
{
if (id == Guid.Empty)
throw new DomainValidationException("Memo.IdEmpty", "IDが不正です");

Id = id;
Title = title;
}

public void Rename(MemoTitle newTitle)
{
// 例:Archivedでも改名できる仕様にする(必要なら禁止してOK)
Title = newTitle;
}

public void Archive() => IsArchived = true;
public void Unarchive() => IsArchived = false;

public void AddTag(TagName tag)
{
if (!_tags.Add(tag))
throw new DomainValidationException("Memo.TagDuplicate", "タグが重複しています");
}
}

テスト例:Value Object のテスト💎🧪✨(一番おいしい所)

✅ 何をテストする?

  • “作れない状態”がちゃんと作れないか🚧
  • 境界値(100文字OK / 101文字NG)📏
  • Trimされるか(仕様なら)✂️
using System;
using Xunit;
using MyApp.Core.Entities;

namespace MyApp.Core.Entities.Tests;

public sealed class MemoTitleTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Create_EmptyOrWhiteSpace_Throws(string? input)
{
var ex = Assert.Throws<DomainValidationException>(() => MemoTitle.Create(input));
Assert.Equal("MemoTitle.Empty", ex.Code);
}

[Fact]
public void Create_TooLong_Throws()
{
var longTitle = new string('a', 101);

var ex = Assert.Throws<DomainValidationException>(() => MemoTitle.Create(longTitle));
Assert.Equal("MemoTitle.TooLong", ex.Code);
}

[Fact]
public void Create_Trim_Works()
{
var title = MemoTitle.Create(" hello ");
Assert.Equal("hello", title.Value);
}

[Fact]
public void Create_MaxLength100_IsOk()
{
var ok = new string('a', 100);
var title = MemoTitle.Create(ok);
Assert.Equal(100, title.Value.Length);
}
}

💡ポイント😍

  • 例外の message より Code をテストするのがおすすめ✨(文言変更でテストが壊れにくい👍)

テスト例:Entity のふるまいテスト🪪🧪✨

✅ 何をテストする?

  • 状態が正しく変わるか(Rename / Archive)🔁
  • ルール違反が止まるか(重複タグ)🛑
using System;
using Xunit;
using MyApp.Core.Entities;

namespace MyApp.Core.Entities.Tests;

public sealed class MemoTests
{
[Fact]
public void NewMemo_HasTitle_And_NotArchived()
{
var memo = new Memo(Guid.NewGuid(), MemoTitle.Create("first"));

Assert.Equal("first", memo.Title.Value);
Assert.False(memo.IsArchived);
}

[Fact]
public void Rename_ChangesTitle()
{
var memo = new Memo(Guid.NewGuid(), MemoTitle.Create("old"));

memo.Rename(MemoTitle.Create("new"));

Assert.Equal("new", memo.Title.Value);
}

[Fact]
public void Archive_SetsArchivedTrue()
{
var memo = new Memo(Guid.NewGuid(), MemoTitle.Create("x"));

memo.Archive();

Assert.True(memo.IsArchived);
}

[Fact]
public void AddTag_Duplicate_Throws()
{
var memo = new Memo(Guid.NewGuid(), MemoTitle.Create("x"));
var tag = TagName.Create("work");

memo.AddTag(tag);

var ex = Assert.Throws<DomainValidationException>(() => memo.AddTag(tag));
Assert.Equal("Memo.TagDuplicate", ex.Code);
}

[Fact]
public void Constructor_EmptyGuid_Throws()
{
var ex = Assert.Throws<DomainValidationException>(() =>
new Memo(Guid.Empty, MemoTitle.Create("x"))
);

Assert.Equal("Memo.IdEmpty", ex.Code);
}
}

テストの書き方テンプレ📐✨(迷子防止)

Arrange / Act / Assert(AAA)🧁

  • Arrange:準備🍳
  • Act:実行🧨
  • Assert:確認✅

テスト名もテンプレ化しちゃうと超ラク😆✨

  • Create_Empty_Throws
  • AddTag_Duplicate_Throws
  • Archive_SetsTrue

“やっちゃダメ”集🙅‍♀️💥(でもみんな通る😂)

  • ❌ DBやHTTPを混ぜる → Entitiesテストの強み(速さ)が死ぬ💀
  • ❌ privateメソッドを直接テストしたくなる → 公開されたふるまいで検証しよ😊
  • ❌ テストが「実装に依存」しすぎ → “仕様”だけを見よう📘✨
  • ❌ 例外メッセージ文字列を固定でAssert → 文言変更で折れる🥲(Code推し💖)

AIの使いどころ🤖✨(Entitiesテストは相性よすぎ)

1) 境界値の洗い出し📏

AIにこう聞くと強いよ〜😊

  • 「このVOの制約から境界値テストケースを列挙して」
  • 「null/空/空白/最大/最大+1/絵文字/全角…も含めて」

2) テスト名の統一🧠

  • 「命名規則をこの形に揃えて」
  • 「Given-When-Thenに変換して」

3) 失敗ケースを“仕様”として固める⚠️

  • 「この失敗はドメインの仕様?アダプタ側の形式エラー?」って相談相手にするの最高😆✨

ミニ課題🎮💖(15〜30分)

課題A:VOをもう1つ増やす💎

  • 例:MemoBody(本文)

    • 空はOK/NG?
    • 最大長は?
    • Trimする? → 仕様を決めて、テストを書いて守ってね🧪✨

課題B:Entityにルールを1つ足す🪪

  • 例:Archived中は AddTag できない仕様にする

    • できないなら例外Codeは?
    • テストはまず失敗させてから直す(TDDっぽく)😆🔁

章末チェックリスト✅🧡

  • VOの不変条件がテストで守られてる💎🧪
  • Entityの主要なふるまいがテストで守られてる🪪✨
  • テストが速い(外部依存ゼロ)⚡
  • 例外は Code をAssertしてる(壊れにくい)🛡️
  • 境界値(最大/最大+1)が入ってる📏

次の第44章では、この「Port差し替え」の考え方で UseCaseもDBなしでテストしていくよ〜🎭🧪✨