iT邦幫忙

2026 iThome 鐵人賽

DAY 16
1

Day15_用 Moq 與 Bogus 產生模擬物件與資料

前言

Day14 的完成率計算器只要收到工作清單,就能算出答案。它不必問資料庫,也不用寄 Email,所以測試時直接建立物件就夠了。

實際的 Service 沒這麼安靜。它可能要先請 Repository 找資料,再請通知服務寄信。若單元測試真的連上 SQL Server、真的把 Email 寄出去,測試就會受到網路、帳號與資料狀態影響,甚至在你按下 dotnet test 後寄出一排測試信。這不是我們想要的結果。

這一篇會重新建立一個獨立範例,不沿用前一篇的程式碼。完整專案放在 MockUnitTestSample,內容是「找出即將到期的工作項目,逐筆寄出提醒」。它剛好能接到 Day16:今天先確認提醒邏輯,下一篇再把它交給 Hangfire 定時執行。

先把測試想成一場排練

假設 WorkItemReminderService 是舞台上的主角。它需要兩位搭檔:

  • IWorkItemRepository 負責找出即將到期的工作。
  • INotificationService 負責寄出提醒。

單元測試只想觀察主角怎麼做事,不需要把真正的資料庫與 Email 服務請進排練場。Moq 可以替兩位搭檔找臨時演員;我們先交代台詞,再確認演出時有沒有照劇本走。

Bogus 做的是另一件事。它像道具組,按照規則準備工作標題、Email 與日期,省下每個測試重複填資料的時間。

工具 在測試裡負責什麼
Moq 建立相依服務的替身,設定回傳內容並檢查呼叫
Bogus 依規則產生測試物件
NUnit 找到並執行測試
Fluent Assertions 用容易閱讀的語句核對結果

Moq 處理「對方會怎麼回應」,Bogus 處理「這次拿什麼資料來測」。兩者不能互相取代。

下載並執行完整專案

電腦需要先安裝 .NET 10 SDK。開啟終端機後執行:

git clone https://github.com/JJDing-Louis/MockUnitTestSample.git
cd MockUnitTestSample
dotnet test MockUnitTestSample.slnx --configuration Release

專案分成正式程式與測試程式:

MockUnitTestSample/
├── src/MockUnitTestSample/
│   ├── INotificationService.cs
│   ├── IWorkItemRepository.cs
│   ├── WorkItem.cs
│   ├── WorkItemReminderService.cs
│   └── WorkItemStatus.cs
└── tests/MockUnitTestSample.Tests/
    ├── WorkItemFakerFactory.cs
    └── WorkItemReminderServiceTests.cs

本文實測使用的套件版本如下:

套件 版本
NUnit 4.6.1
NUnit3TestAdapter 6.3.0
NUnit.Analyzers 4.14.0
Microsoft.NET.Test.Sdk 18.10.0
FluentAssertions 7.2.2
Moq 4.20.72
Bogus 35.6.5

Fluent Assertions 沿用 Day14 的 7.2.2,避免把授權差異混進這次的重點。若公司專案準備升到 8.x 以上,請先確認新版授權是否符合實際用途。

先寫下這次要驗證的規則

範例只保留一條容易觀察的流程:

1. 收到今天日期與「往後幾天」的範圍。
2. 請 Repository 找出這段期間內即將到期的工作。
3. 每一筆工作各寄一封提醒。
4. 回傳總共寄了幾封。

daysAhead 只接受 0 到 30。0 代表只看今天,30 代表看到未來第 30 天;超出範圍時,程式應立刻拒絕,也不能先查資料或寄信。

這些規則先說清楚,後面的 SetupVerify 和 Assert 才知道要檢查什麼。

讓 Service 依賴介面

工作項目放在 WorkItem.cs

namespace MockUnitTestSample;

public sealed class WorkItem
{
    public int Id { get; init; }

    public string Title { get; init; } = string.Empty;

    public string AssigneeEmail { get; init; } = string.Empty;

    public DateOnly DueDate { get; init; }

    public WorkItemStatus Status { get; init; }
}

狀態另外放在 WorkItemStatus.cs

namespace MockUnitTestSample;

public enum WorkItemStatus
{
    Pending,
    InProgress,
    Completed
}

接著替資料查詢與寄信各定義一個介面。

IWorkItemRepository.cs

namespace MockUnitTestSample;

public interface IWorkItemRepository
{
    IReadOnlyList<WorkItem> FindDueBetween(
        DateOnly startDate,
        DateOnly endDate);
}

INotificationService.cs

namespace MockUnitTestSample;

public interface INotificationService
{
    void SendDueDateReminder(
        string recipientEmail,
        string workItemTitle,
        DateOnly dueDate);
}

WorkItemReminderService 不知道資料來自 SQL Server、記憶體還是 API,也不知道 Email 最後交給哪一家服務。它只和介面約定工作:

WorkItemReminderService.cs

namespace MockUnitTestSample;

public sealed class WorkItemReminderService
{
    private const int MaximumDaysAhead = 30;
    private readonly IWorkItemRepository _repository;
    private readonly INotificationService _notificationService;

    public WorkItemReminderService(
        IWorkItemRepository repository,
        INotificationService notificationService)
    {
        ArgumentNullException.ThrowIfNull(repository);
        ArgumentNullException.ThrowIfNull(notificationService);

        _repository = repository;
        _notificationService = notificationService;
    }

    public int SendDueSoonReminders(DateOnly today, int daysAhead)
    {
        if (daysAhead is < 0 or > MaximumDaysAhead)
        {
            throw new ArgumentOutOfRangeException(
                nameof(daysAhead),
                $"提醒天數必須介於 0 到 {MaximumDaysAhead} 天之間。");
        }

        DateOnly endDate = today.AddDays(daysAhead);
        IReadOnlyList<WorkItem> dueItems =
            _repository.FindDueBetween(today, endDate);

        foreach (WorkItem item in dueItems)
        {
            _notificationService.SendDueDateReminder(
                item.AssigneeEmail,
                item.Title,
                item.DueDate);
        }

        return dueItems.Count;
    }
}

這種寫法把「提醒規則」和「外部服務怎麼實作」分開。正式環境可以注入真的 Repository 與通知服務;單元測試則注入 Moq 建立的替身。這就是前面 C4 圖提過的責任邊界,也是依賴反轉在小型範例裡最直接的樣子。

用 Bogus 準備道具

如果每個測試都手動輸入編號、標題與 Email,案例一多,很快就會看到整頁重複資料。下面把產生規則集中在 Factory:

WorkItemFakerFactory.cs

using Bogus;

namespace MockUnitTestSample.Tests;

public static class WorkItemFakerFactory
{
    public static Faker<WorkItem> Create(DateOnly today)
    {
        return new Faker<WorkItem>("zh_TW")
            .StrictMode(true)
            .UseSeed(2026)
            .RuleFor(item => item.Id, faker =>
                faker.Random.Int(1, 10_000))
            .RuleFor(item => item.Title, faker =>
                faker.Lorem.Sentence(5))
            .RuleFor(item => item.AssigneeEmail, faker =>
                faker.Internet.Email())
            .RuleFor(item => item.DueDate, faker =>
                today.AddDays(faker.Random.Int(0, 3)))
            .RuleFor(
                item => item.Status,
                WorkItemStatus.InProgress);
    }
}

StrictMode(true) 會要求每個可設定的屬性都有產生規則,模型日後新增欄位時比較不容易忘記測試資料。UseSeed(2026) 使用區域種子,讓同一版 Bogus 能重現相同序列。

不過,固定 seed 不代表可以把某次產生的完整句子寫進 Assert。Bogus 升級或規則順序改變後,內容仍可能不同。這裡把產生的資料直接帶入 Verify,確認 Service 把同一份內容交給通知服務,不硬背這次抽到哪一句標題。

用 Moq 安排臨時演員

先看「找到兩筆工作」的情境:

[Test]
public void SendDueSoonReminders_TwoDueItems_SendsOneReminderPerItemAndReturnsTwo()
{
    // Arrange
    DateOnly today = new(2026, 9, 14);
    IReadOnlyList<WorkItem> dueItems =
        WorkItemFakerFactory.Create(today).Generate(2);
    var repository = new Mock<IWorkItemRepository>();
    repository
        .Setup(instance => instance.FindDueBetween(
            today,
            today.AddDays(3)))
        .Returns(dueItems);
    var notificationService = new Mock<INotificationService>();
    var sut = new WorkItemReminderService(
        repository.Object,
        notificationService.Object);

    // Act
    int sentCount = sut.SendDueSoonReminders(today, daysAhead: 3);

    // Assert
    sentCount.Should().Be(2);

    foreach (WorkItem item in dueItems)
    {
        notificationService.Verify(
            instance => instance.SendDueDateReminder(
                item.AssigneeEmail,
                item.Title,
                item.DueDate),
            Times.Once);
    }

    notificationService.VerifyNoOtherCalls();
}

第一次讀 Moq 時,可以先抓住四個詞:

語法 白話意思
new Mock<T>() 找一位能扮演 T 的臨時演員
Setup(...).Returns(...) 告訴它聽到哪句台詞時,要怎麼回答
.Object 把演員交給真正接受測試的 Service
Verify(..., Times.Once) 演出結束後,核對某個動作是否剛好發生一次

VerifyNoOtherCalls() 會再檢查一次,確認沒有未驗證的額外互動。如果 Service 突然多查一次資料或多寄一封信,測試就會把它抓出來。

沒有工作時,不該假裝有信要寄

空資料也是正常情境。Repository 回傳空陣列後,Service 應回傳 0,而且通知服務一次都不能被呼叫:

[Test]
public void SendDueSoonReminders_NoDueItems_DoesNotSendReminderAndReturnsZero()
{
    // Arrange
    DateOnly today = new(2026, 9, 14);
    var repository = new Mock<IWorkItemRepository>();
    repository
        .Setup(instance => instance.FindDueBetween(
            today,
            today.AddDays(3)))
        .Returns(Array.Empty<WorkItem>());
    var notificationService = new Mock<INotificationService>();
    var sut = new WorkItemReminderService(
        repository.Object,
        notificationService.Object);

    // Act
    int sentCount = sut.SendDueSoonReminders(today, daysAhead: 3);

    // Assert
    sentCount.Should().Be(0);
    notificationService.VerifyNoOtherCalls();
}

VerifyNoOtherCalls() 在這裡驗證的不是「回傳值看起來沒事」,而是「寄信這個有副作用的動作確實沒有發生」。

錯誤輸入要在門口擋下來

提醒範圍小於 0 或大於 30 時,Service 應立刻丟出例外。NUnit 的 [TestCase] 可以讓同一套規則跑兩次:

[TestCase(
    -1,
    TestName =
        "SendDueSoonReminders_DaysAheadBelowMinimum_ThrowsBeforeCallingDependencies")]
[TestCase(
    31,
    TestName =
        "SendDueSoonReminders_DaysAheadAboveMaximum_ThrowsBeforeCallingDependencies")]
public void SendDueSoonReminders_InvalidDaysAhead_ThrowsBeforeCallingDependencies(
    int daysAhead)
{
    // Arrange
    var repository = new Mock<IWorkItemRepository>();
    var notificationService = new Mock<INotificationService>();
    var sut = new WorkItemReminderService(
        repository.Object,
        notificationService.Object);

    // Act
    Action act = () => sut.SendDueSoonReminders(
        new DateOnly(2026, 9, 14),
        daysAhead);

    // Assert
    act.Should().ThrowExactly<ArgumentOutOfRangeException>();
    repository.VerifyNoOtherCalls();
    notificationService.VerifyNoOtherCalls();
}

這裡除了檢查例外型別,也確認兩個外部依賴完全沒被碰到。如果驗證順序寫反,程式先查了資料庫才發現輸入錯誤,最後兩行就會把問題抓出來。

完整 repository 另外測了 0 與 30 兩個合法邊界,確認傳給 Repository 的結束日期分別是今天與第 30 天;也檢查建構子收到 null 時會立刻拒絕。邊界值常是最容易寫錯的地方,只測 3 這種中間數字還不夠。

我實際跑過的結果

我在 macOS 使用 .NET SDK 10.0.201 執行:

dotnet test MockUnitTestSample.slnx --configuration Release

實際結果為:

已通過! - 失敗: 0,通過: 8,略過: 0,總計: 8

另外執行 Release 非增量建置,結果為 0 個警告、0 個錯誤。這 8 個測試沒有連資料庫、沒有存取網路,也沒有真的寄信;它們只證明範例中的提醒協調邏輯在上述版本與環境通過。

什麼時候不該使用 Moq 或 Bogus?

Moq 能確認 Service 有沒有用正確參數呼叫介面,卻不能證明 SQL 查詢真的找對資料,也不能證明 SMTP 設定能成功寄信。這些問題要交給整合測試,必要時再加少量端對端測試。

這份教學也刻意縮小了 Day7 的完整提醒規格。正式的 ProjectManagementWeb 還要處理到期前與逾期後的提醒區間、帳號及 Email 驗證狀態、重複寄送防護、失敗重試與寄送紀錄。這些規則不能因為範例比較短就消失,實作時仍要回到 User Story 與流程圖逐項驗收。

如果一個測試需要十幾個 Setup 才能讓目標方法開始工作,我通常會先停下來看類別是不是扛了太多責任。Mock 太多有時不是測試技巧不夠,而是設計正在提醒我們該拆開了。

Bogus 適合補齊與這次規則無關的一般資料。空字串、最大長度、不存在的編號或月底日期等重要邊界,仍應由測試明確指定。讓亂數碰運氣找錯誤,測試有時通過、有時失敗,最後只會讓人不敢相信它。

小結

Day14 先學會替單純計算核對答案。這一篇把外部依賴帶進來,再用 Moq 讓資料庫與通知服務留在排練場外;Bogus 則負責準備足夠真實、又不含真實個資的測試資料。

單元測試確認的是 WorkItemReminderService 怎麼協調工作。下一篇會把同一類到期提醒交給 Hangfire 定時觸發。排程是否準時、資料庫是否連得上、Email 是否真的送達,則要用其他層級的測試繼續確認。

參考資料


上一篇
Day14_使用 Fluent Assertions 改善測試可讀性
系列文
Codex的規格驅動開發 :30 天打造 .NET 內部專案管理系統16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言