【C#/Blazor】実務Webアプリ開発編(24)Domain層のテストを書く ~値オブジェクト・エンティティ・集約・ドメインサービスを外部依存なしで検証する~
実務Webアプリ開発編です。Part IV(Domain層の実装)として、これまで値オブジェクト・集約・ドメインサービスなどDomain層を構成する部品について学んできました。
今回はその続きとして、Domain層のコードが正しく動作することをどのように自動テストで確かめるかについて扱います。
値オブジェクト・集約・ドメインサービスを、外部依存なしで小さく速く検証できることを、MentorAppの実コードで確認していきましょう。
以下のような方に役立つ内容となっています。
- Domain層のコードは何をどうテストすればよいか整理したい
- 単体テスト・統合テスト・E2Eテストの違いがまだ曖昧
- DDDやクリーンアーキテクチャにするとテストしやすいと言われる理由を実感したい
- xUnit の
FactとTheoryの使い分けを実コードで見たい
以下のようなMentorAppを題材として進めます。

GitHubにドキュメント・コードの一式があります。
Part IVで扱ってきた各要素の復習も兼ねるので、前回までの記事(特に値オブジェクト、集約、ドメインサービス)とつなげて読むと理解しやすいです。
Part IVで作ってきたDomain層は、外部技術から切り離されているぶん、ビジネスルールだけをまっすぐ確かめやすいです。
今回は、設計の良さがテストしやすさにも表れることを一緒に見ていきましょう。
テスト自動化の考え方
自動テストの価値は、壊れたことを自動で検知できることです。
コードを修正するたびに手作業で確認していると、確認漏れや見落としが起きやすくなります。そこで、期待する振る舞いをテストコードとして残しておくわけです。
自動テストの考え方やxUnitの使い方の基本については以下の記事をぜひ参考にしてください。
自動テストっていうと、単体テスト・統合テスト・E2E(EndToEnd)テストとかで分類するんだっけ?
自動テストの設計では、そのような分類名自体よりも、どの範囲を動かして、何を確認したいかを先に考える方が重要です。
一例として、クリーンアーキテクチャ・DDDで設計したアプリの自動テストは以下のように設計します。
| 確認する範囲 | 主に確認すること | 外部依存 | MentorAppの例 | 一般的な呼び方 (よくある呼び方) |
|---|---|---|---|---|
| Domain層だけ | 値オブジェクト・集約・ドメインサービスのルール | なし | EmailTests | 単体テスト |
| Application層からDBまで | ユースケースの流れと永続化を含む接続 | DB | TopicServiceTests | 統合テスト |
| Webアプリとブラウザ操作 | ユーザー視点の主要シナリオ | Webサーバー・ブラウザ・DB | MentorshipE2ETests | E2Eテスト |
「狭い範囲のテスト」では各部品における間違いを素早く見つけやすく、「広い範囲のテスト」では実際のアプリの使い方に近い流れを確認できます。
段階を分けることで、素早く各部品の動作を確認したいことと、アプリ全体の動きをしっかり確認することの両方を扱いやすくなります。
本記事では、このうちDomain層だけを直接動かすテストに絞って見ていきます。以後は便宜上「Domain層テスト」と呼びます。
いちばん「広い範囲」のE2Eテストだけ、たくさんのバリエーションをテストするんじゃだめなの?
E2Eテストは最も実際のアプリの動作に近いため、価値があります。
ただ、テスト失敗時の原因箇所の特定にコストがかかりがちなうえ、1つのテストケースの実行にも時間がかかります。
だからこそ、まずは軽いDomain層テストでビジネスルールを細かく押さえるのが大事なのです。
本記事シリーズでは、この一般論に沿って、MentorAppのテストを次の3種類に分けて扱い、1つのテストプロジェクトにまとめてもたせる構成になっています。
| 本記事シリーズでの呼び方 | MentorAppでの配置 | 確認すること |
|---|---|---|
| Domain層テスト (本記事で扱う) | tests/MentorApp.Tests/Domain/ | 値オブジェクト・集約・ドメインサービスのルール |
| Application層テスト | tests/MentorApp.Tests/Application/ | ApplicationサービスからDomain層・Repository・DBまでのつながり |
| E2Eテスト | tests/MentorApp.Tests/E2E/ | ブラウザ操作を含むユーザー視点の主要シナリオ |
Domain層テストの書き方
Domain層のテストが書きやすい理由は、DB接続、HTTP、DI(依存性注入)、UI(画面)といった外部技術が入ってこないからです。
つまり、オブジェクトを生成して、メソッドを呼んで、結果や例外を確認するだけで、業務ルールそのものを直接検証できます。
テストコードは、Arrange(準備)・Act(実行)・Assert(検証)に分けて考えると整理しやすくなります。
Arrange:テスト対象と入力値を用意する
↓
Act:コンストラクタやメソッドを実行する
↓
Assert:期待した結果・例外・状態になっているか確認するクリーンアーキテクチャで、Domain層が「実装技術に依存せず純粋なビジネスロジック」だけっていう利点が活かされているんだね!
その通りです!だから、テストコードも簡潔に書けるのです。
次に、Domain層の値オブジェクト・集約・ドメインサービスといった部品を、どのような観点でテストするかをみてみましょう。
値オブジェクトをテストする
値オブジェクトでは、正しい値を受け入れ、不正な値を拒否し、値としてのルールを守ることを見ます。
今回のMentorAppなら、Email が分かりやすい例です。形式チェック、前後空白のトリム、値としての一貫性などが対象になります。
集約をテストする
集約では、正しい状態で生成できること、状態遷移が期待通りに行われること、不正な入力や操作を拒否できることを見ます。
集約の内側に子エンティティがある場合も、基本的には集約ルートのメソッドを通して確認します。
子エンティティを直接テストするというより、外部から見える集約の振る舞いとして、集約内のルールが守られているかを確かめるイメージです。
Topic集約なら、「作成直後はOpenであること、CloseするとClosedになること、Closedの状態では投稿を拒否すること」というように、状態と操作結果の関係が中心です。
ドメインサービスをテストする
ドメインサービスでは、複数集約をまたぐ業務判断について、与えた入力材料に対して期待した判定結果になるかを見ます。
ここで大切なのは、リポジトリやDBの動作ではなく、ドメインサービスが担当している判断そのものを確かめることです。
MentorAppにおけるDomain層のテストをみる
ここからは、実際に tests/MentorApp.Tests/Domain/ 配下にあるテストを見ていきます。
値オブジェクト・集約・ドメインサービスを対象としたテストケースの具体例をそれぞれみながら、Domain層テストの雰囲気をつかみましょう。
値オブジェクトのテスト例
まずはEmailTestsを見てみましょう。扱うケースは、前後に空白があるメールアドレスを渡す場合と、不正な文字列を渡す場合です。
それぞれのケースを小さなテストに分けています。以下は要点部分の抜粋です。
[Fact]
public void CreateEmail_WithWhitespace_Then_TrimsValue()
{
// Arrange
const string input = " taro.yamada@example.com ";
const string expected = "taro.yamada@example.com";
// Act
var email = new Email(input);
// Assert
Assert.Equal(expected, email.Value);
Assert.Equal(expected, email.ToString());
}
[Theory]
[InlineData(null)]
[InlineData("abc")]
public void CreateEmail_WithInvalidValue_Then_ThrowsArgumentException(string? input)
{
// Act & Assert
Assert.Throws<ArgumentException>(() => new Email(input));
}ここでは、Fact を単一ケースに、Theory を複数の異常系パターンに使っています。
1つ目のテストの期待値は、保持される値と ToString() の結果がどちらもトリム後の文字列になることです。
2つ目のテストの期待値は、null やメールアドレス形式ではない文字列を渡したときに、ArgumentException になることです。
Theory だとデータバリエーションを表みたいに並べられるから、こういう異常系パターンをテストするときとかに便利だね!
User集約のテスト例
次は、UserTestsです。User は子エンティティを持たないシンプルな集約ルートなので、生成時の状態と、表示名更新に失敗したときの状態を扱っています。
表示名更新のケースでは、空白だけの表示名を渡したときに例外になることと、そのあとも元の表示名が残っていることを期待値にしています。
[Fact]
public void CreateUser_Then_PropertiesAreInitialized()
{
// Arrange
var createdAt = new DateTimeOffset(2026, 1, 15, 10, 0, 0, TimeSpan.Zero);
const string externalId = "mentor-001";
const string displayName = "山田太郎";
const string email = "taro.yamada@example.com";
// Act
var user = new User(externalId, displayName, createdAt, email, Role.Mentor);
// Assert
Assert.NotEqual(Guid.Empty, user.Id);
Assert.Equal(externalId, user.ExternalId);
Assert.Equal(displayName, user.DisplayName);
Assert.Equal(email, user.Email.Value);
Assert.Equal(Role.Mentor, user.Role);
Assert.Equal(createdAt, user.CreatedAt);
}
[Fact]
public void UpdateDisplayName_WithBlankName_Then_ThrowsArgumentException()
{
// Arrange
const string externalId = "mentee-001";
const string currentDisplayName = "佐藤花子";
const string email = "hanako.sato@example.com";
var user = new User(
externalId: externalId,
displayName: currentDisplayName,
createdAt: new DateTimeOffset(2026, 1, 15, 10, 0, 0, TimeSpan.Zero),
email: email,
role: Role.Mentee);
// Act & Assert
Assert.Throws<ArgumentException>(() => user.UpdateDisplayName(" "));
// Assert
Assert.Equal(currentDisplayName, user.DisplayName);
}1つ目のテストでは、User を生成したあと、外部ID、表示名、メールアドレス、ロール、作成日時が期待通りに保持されていることをAssertしています。
2つ目のテストで大事なのは、例外が起きることと、失敗後も元の表示名が維持されることを両方見ている点です。
不正入力を拒否できても、途中で状態が壊れていたら困ります。
集約のテストでは、この「拒否」と「状態維持」を分けて意識すると整理しやすいです。
Topic集約のテスト例
次は TopicTests です。扱うケースは、Topic 作成直後の状態と、Close後に投稿しようとしたときの動作の検証です。
以下は TopicTests の要点部分の抜粋です。
[Fact]
public void CreateTopic_Then_InitialStateIsOpen()
{
// Arrange
var mentorshipId = Guid.NewGuid();
var createdAt = new DateTimeOffset(2026, 1, 15, 10, 0, 0, TimeSpan.Zero);
const string title = "相談したいこと";
// Act
var topic = new Topic(mentorshipId, title, createdAt);
// Assert
Assert.NotEqual(Guid.Empty, topic.Id);
Assert.Equal(mentorshipId, topic.MentorshipId);
Assert.Equal(title, topic.Title);
Assert.Equal(TopicStatus.Open, topic.Status);
Assert.Equal(createdAt, topic.CreatedAt);
Assert.Empty(topic.Messages);
}
[Fact]
public void CloseTopic_Then_CannotPostMessage()
{
// Arrange
var topic = new Topic(Guid.NewGuid(), "クローズ確認", new DateTimeOffset(2026, 1, 15, 10, 0, 0, TimeSpan.Zero));
var senderUserId = Guid.NewGuid();
const string messageContent = "クローズ後の投稿";
topic.Close();
// Act & Assert
Assert.Throws<InvalidOperationException>(() =>
topic.PostMessage(senderUserId, messageContent, new DateTimeOffset(2026, 1, 15, 11, 0, 0, TimeSpan.Zero)));
// Assert
Assert.Equal(TopicStatus.Closed, topic.Status);
Assert.Empty(topic.Messages);
}1つ目のテストでは、Topic を生成したあと、ID、メンタリングID、タイトル、作成日時が保持され、初期状態が Open で、メッセージがまだ空であることをAssertしています。
2つ目のテストでは、Closed なら投稿できない、失敗後もメッセージが増えていないというルールが正しく動作しているかをチェックしています。
Message を直接操作するのではなく、利用側から見える Topic の振る舞いとして、投稿拒否やメッセージ数を検証している点がポイントです。
ドメインサービスのテスト例
最後に MentorshipDuplicationCheckServiceTests です。メンターとメンティーの組み合わせを渡したときに、作成可否の判定が期待通りになるかを扱っています。
ここでは、作成できる組み合わせでは例外が起きないこと、すでに有効なメンタリングがある組み合わせでは例外になることを期待値にしています。
以下は MentorshipDuplicationCheckServiceTests の要点部分の抜粋です。
[Fact]
public void ValidateMentorshipCreation_WithoutActiveMentorship_Then_DoesNotThrow()
{
// Arrange
var service = new MentorshipDuplicationCheckService();
var mentor = CreateUser("mentor-001", "テストメンター", "mentor@example.com", Role.Mentor);
var mentee = CreateUser("mentee-001", "テストメンティー", "mentee@example.com", Role.Mentee);
// Act
var exception = Record.Exception(() =>
service.ValidateMentorshipCreation(mentor, mentee, hasActiveMentorshipForPair: false));
// Assert
Assert.Null(exception);
}
[Fact]
public void ValidateMentorshipCreation_WithActiveMentorship_Then_ThrowsInvalidOperationException()
{
// Arrange
var service = new MentorshipDuplicationCheckService();
var mentor = CreateUser("mentor-001", "テストメンター", "mentor@example.com", Role.Mentor);
var mentee = CreateUser("mentee-001", "テストメンティー", "mentee@example.com", Role.Mentee);
// Act & Assert
Assert.Throws<InvalidOperationException>(() =>
service.ValidateMentorshipCreation(mentor, mentee, hasActiveMentorshipForPair: true));
}ドメインサービスは「取得済みの材料を使って最終判断を行う」ように設計されているので、Domain層テストでもそのまま扱いやすくなっています。
もしここでドメインサービスがDBへの問い合わせまで抱えていたら、テストも重くなります。
判定材料の取得はApplication層、最終判断はDomain層と分けたことが、テストの軽さにも効いています。
ここで紹介しているMentorAppのDomain層テストは、すべての業務ルールを網羅的に作り込んだ完成形ではありません。
値オブジェクト・集約・ドメインサービスの役割やテストしやすさを確認しやすい部分を中心に実装しています。
Application層テスト・E2Eテストとは何が違うか
Domain層テストの軽さを実感するには、より「広い範囲」の動作を確認するテストと比べると分かりやすいです。
ここでは、Application層テスト・E2Eテストとの違いを大まかに整理しておきましょう。
これらの自動テストやテスト全体の総論については、シリーズ後半のテストPARTで詳しく扱います。
Application層テストでは、Applicationサービスを起点にして、Domain層、リポジトリ、DBまで含めたユースケース全体の流れを確認します。
たとえば「トピックを作成する」という操作なら、Application層テストでは、サービスを呼び出した結果がDBに保存され、あとから取得できるところまで確認します。
Domain層テストより確認範囲が広いぶん、DIやテスト用DBなどの準備も増えます。
E2Eテストでは、ブラウザ操作を通して、ユーザー視点の主要シナリオが最後まで通るかを確認します。
例えば「プロフィールを更新する」という操作なら、サインインして、プロフィール画面を開き、入力欄を書き換え、更新ボタンを押し、画面上の結果を確認します。
実際のアプリの使い方に近いので価値は高い一方、Webサーバー、ブラウザ、DBなど準備するものが多く、実行にも時間がかかります。
それぞれのテストをあらためて整理すると以下のようになります。ここでいう「実行に必要なもの」は、テストを動かすときに必要になる外部要素や起動対象のことです。
| テスト | 何を見るか | 実行に必要なもの | 実行時間 | 失敗時の切り分け |
|---|---|---|---|---|
| Domain層テスト | ビジネスルールそのもの | ほぼコードだけ | 短い | しやすい |
| Application層テスト | ユースケースと永続化の接続 | DI、リポジトリ、テスト用DBなど | 中くらい | やや重い |
| E2Eテスト | ユーザー操作のシナリオ | Webサーバー、ブラウザ、DBなど | 長くなりやすい | 重い |
Domain層で部品ごとの個別の動作を確認し、そのうえでApplication層で部品を組み合わせた動作を確認し、最後にE2Eテストで完成品としての動きを確認します。
このように、品質を積み上げていく役割分担が有効です。
Domain層の小さな部品レベルで確認できることは、個別に素早く確認しておくってことだね。
そうですね。部品ごとに動作検証し、検証済みの部品を組み合わせた動作を確認し…としていき、最後にシステム全体の動作を確認するわけですね。
まとめ
今回は、MentorAppの実コードを使って、Domain層のテストがどのように書けるかを見てきました。
ポイントを整理すると次の通りです。
- Domain層は外部依存がないため、ビジネスルールそのものを小さく速く検証しやすい
- 値オブジェクト・集約・ドメインサービスでは、見るべきルールの観点が少しずつ違う
- クリーンアーキテクチャでは、Domain / Application / E2E と分けて考えると確認範囲を整理しやすい
次回からはPart V(永続化とCQRS)に入り、Infrastructure層におけるリポジトリの具体的な実装やEF Coreとの接続を見ていきます。
Domain層の内側だけで完結していたルールが、永続化とどうつながるかに注目していきましょう。
Domain層は、外部技術から切り離されているからこそ、ルールを小さく確かめやすいです。
引き続き、リポジトリとEF Coreによる永続化の実装について一緒に学んでいきましょう!






