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 天;超出範圍時,程式應立刻拒絕,也不能先查資料或寄信。
這些規則先說清楚,後面的 Setup、Verify 和 Assert 才知道要檢查什麼。
工作項目放在 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 圖提過的責任邊界,也是依賴反轉在小型範例裡最直接的樣子。
如果每個測試都手動輸入編號、標題與 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 把同一份內容交給通知服務,不硬背這次抽到哪一句標題。
先看「找到兩筆工作」的情境:
[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 能確認 Service 有沒有用正確參數呼叫介面,卻不能證明 SQL 查詢真的找對資料,也不能證明 SMTP 設定能成功寄信。這些問題要交給整合測試,必要時再加少量端對端測試。
這份教學也刻意縮小了 Day7 的完整提醒規格。正式的 ProjectManagementWeb 還要處理到期前與逾期後的提醒區間、帳號及 Email 驗證狀態、重複寄送防護、失敗重試與寄送紀錄。這些規則不能因為範例比較短就消失,實作時仍要回到 User Story 與流程圖逐項驗收。
如果一個測試需要十幾個 Setup 才能讓目標方法開始工作,我通常會先停下來看類別是不是扛了太多責任。Mock 太多有時不是測試技巧不夠,而是設計正在提醒我們該拆開了。
Bogus 適合補齊與這次規則無關的一般資料。空字串、最大長度、不存在的編號或月底日期等重要邊界,仍應由測試明確指定。讓亂數碰運氣找錯誤,測試有時通過、有時失敗,最後只會讓人不敢相信它。
Day14 先學會替單純計算核對答案。這一篇把外部依賴帶進來,再用 Moq 讓資料庫與通知服務留在排練場外;Bogus 則負責準備足夠真實、又不含真實個資的測試資料。
單元測試確認的是 WorkItemReminderService 怎麼協調工作。下一篇會把同一類到期提醒交給 Hangfire 定時觸發。排程是否準時、資料庫是否連得上、Email 是否真的送達,則要用其他層級的測試繼續確認。