実務Webアプリ開発編です。Part IV(Domain層の実装)として、これまで値オブジェクト・集約・ドメインサービスなどDomain層を構成する部品について学んできました。

今回はその続きとして、Domain層のコードが正しく動作することをどのように自動テストで確かめるかについて扱います。

値オブジェクト・集約・ドメインサービスを、外部依存なしで小さく速く検証できることを、MentorAppの実コードで確認していきましょう。

以下のような方に役立つ内容となっています。

  • Domain層のコードは何をどうテストすればよいか整理したい
  • 単体テスト・統合テスト・E2Eテストの違いがまだ曖昧
  • DDDやクリーンアーキテクチャにするとテストしやすいと言われる理由を実感したい
  • xUnit の FactTheory の使い分けを実コードで見たい

以下のようなMentorAppを題材として進めます。

GitHubにドキュメント・コードの一式があります。

Part IVで扱ってきた各要素の復習も兼ねるので、前回までの記事(特に値オブジェクト、集約、ドメインサービス)とつなげて読むと理解しやすいです。

【C#/Blazor】実務Webアプリ開発編 (19)DDDの値オブジェクトをC#のrecordで実装する ~業務ルールを型に閉じ込める~ 実務Webアプリ開発編です。前回までPart III(アーキテクチャ概論)として、クリーンアーキテクチャやDDDの考え方を見てきました...
【C#/Blazor】実務Webアプリ開発編 (20)DDDのエンティティ・集約・集約ルートを実コードで理解する ~不変条件の守り方~ 実務Webアプリ開発編です。前回は、Emailを題材に値オブジェクトの実装を見てきました。 今回はPart IV(ドメイン層の実...
【C#/Blazor】実務Webアプリ開発編 (21)DDDの集約境界の決め方と守り方 ~TopicとMessageの親子関係を学ぶ~ 実務Webアプリ開発編です。Part IV(ドメイン層の実装)として、前回はUserやMentorshipを題材に、集約ルート自身の状...
【C#/Blazor】実務Webアプリ開発編 (23)DDDのドメインサービスとは?~集約をまたぐビジネスルール~ 実務Webアプリ開発編です。Part IV(ドメイン層の実装)として、前回はリポジトリインターフェースをDomain層に置く理由を見て...
プロ太

Part IVで作ってきたDomain層は、外部技術から切り離されているぶん、ビジネスルールだけをまっすぐ確かめやすいです。

今回は、設計の良さがテストしやすさにも表れることを一緒に見ていきましょう。

テスト自動化の考え方

自動テストの価値は、壊れたことを自動で検知できることです。

コードを修正するたびに手作業で確認していると、確認漏れや見落としが起きやすくなります。そこで、期待する振る舞いをテストコードとして残しておくわけです。

自動テストの考え方やxUnitの使い方の基本については以下の記事をぜひ参考にしてください。

C#入門編(27)自動テスト入門 ~xUnitで「壊れたら気づける」仕組みを作る~ C#入門編です。今回はC#における自動テストについて解説します。 「コードを変更するたびに、手動で動作確認するのが大変…」「いつ...
プロ美

自動テストっていうと、単体テスト・統合テスト・E2E(EndToEnd)テストとかで分類するんだっけ?

プロ太

自動テストの設計では、そのような分類名自体よりも、どの範囲を動かして、何を確認したいかを先に考える方が重要です。

一例として、クリーンアーキテクチャ・DDDで設計したアプリの自動テストは以下のように設計します。

確認する範囲主に確認すること外部依存MentorAppの例一般的な呼び方
(よくある呼び方)
Domain層だけ値オブジェクト・集約・ドメインサービスのルールなしEmailTests単体テスト
Application層からDBまでユースケースの流れと永続化を含む接続DBTopicServiceTests統合テスト
Webアプリとブラウザ操作ユーザー視点の主要シナリオWebサーバー・ブラウザ・DBMentorshipE2ETestsE2Eテスト

「狭い範囲のテスト」では各部品における間違いを素早く見つけやすく、「広い範囲のテスト」では実際のアプリの使い方に近い流れを確認できます。

段階を分けることで、素早く各部品の動作を確認したいことと、アプリ全体の動きをしっかり確認することの両方を扱いやすくなります。

本記事では、このうち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による永続化の実装について一緒に学んでいきましょう!

ABOUT ME
プロ太
ソフトウェア開発を楽しく、効率的に行う方法を追求しています。 開発者の視点から技術的課題に向き合い、「純粋な技術的興味に基づく探求」と「実践的な課題解決」という二つの柱を両輪として活動しています。