2023年12月5日
單元測試怎麼寫才好讀?用 3A 模式組織測試,用「方法_情境_預期結果」命名
寫單元測試時,最常遇到的問題通常不是「不會寫」,而是三個月後回來看,看不懂當初在測什麼。測試失敗時,紅燈只告訴你一個方法名稱;如果名稱是 Test1、CreateOrderTest2,還得把整段測試讀完、甚至 debug,才知道是哪個業務規則壞了。
好讀的測試靠兩件事撐起來:
- 結構:每個測試都用同樣的骨架寫——這就是 3A 模式。
- 命名:方法名稱本身就是一句規格說明——「這個方法,在這個情境下,應該得到這個結果」。
以下用我在電商專案(Clean Architecture 的訂單服務)裡實際寫的測試當例子。
3A 模式:Arrange、Act、Assert
3A 把一個測試切成三段,每段只做一件事:
| 階段 | 做什麼 | 常見內容 |
|---|---|---|
| Arrange(準備) | 建立被測物件與它需要的環境 | 建立測試資料、設定 Stub 回傳值、固定時間 |
| Act(執行) | 呼叫一次被測的方法 | sut.HandleAsync(...)、order.Complete() |
| Assert(驗證) | 檢查結果是否符合預期 | 回傳值、物件狀態、是否拋出例外、相依物件是否被呼叫 |
範例一:Entity 的狀態變化
先看最單純的情況——驗證 Order 這個 Entity 完成訂單後狀態是否正確:
[Fact]
public void Complete_訂單已完成_應更新訂單狀態為已完成()
{
// Arrange
var id = OrderId.New(Guid.NewGuid());
var memberId = MemberId.New(Guid.NewGuid());
var orderItems = new List<OrderItem>
{
new(ProductId.New(Guid.NewGuid()), 1, 100),
new(ProductId.New(Guid.NewGuid()), 2, 200),
};
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(new DateTimeOffset(2023, 11, 01, 0, 0, 0, TimeSpan.Zero));
var shipmentAddress = new ShipmentAddress("台灣", "台北市", "大安區敦化南路二段100號", "106");
var order = Order.New(id, memberId, orderItems, shipmentAddress, timeProvider);
// Act
order.Complete();
// Assert
order.Status.Should().Be(Status.Completed);
}
三段的分界非常清楚:Arrange 準備好一張「待付款」的訂單;Act 只有一行 order.Complete();Assert 只驗證狀態。讀的人不用理解訂單怎麼建立,只要看 Act 跟 Assert 就知道這個測試的重點。
這裡有個值得注意的細節:時間用 FakeTimeProvider 固定住。訂單的 CreateTime 取決於「現在」,如果直接用 DateTime.Now,測試結果就會隨執行時間改變。上個月剛發布的 .NET 8 新增了 TimeProvider 抽象,讓時間也能像其他相依一樣被注入、被替換。
範例二:Use Case 與相依物件
UseCase 層的測試會牽涉到外部相依(Repository、Event Bus、會員服務……),這時 Arrange 的主要工作就變成設定替身(Test Double)的行為:
[Fact]
public async Task HandleAsyncTest_輸入符合預期的參數_傳回True()
{
// Arrange
_generatorIdOutPort.GenerateAsync().Returns(Guid.NewGuid());
_orderOutPort.SaveAsync(Arg.Any<Order>()).Returns(true);
_timeProvider.SetUtcNow(new DateTimeOffset(new DateTime(2023, 11, 20)));
var memberId = MemberId.New(new Guid("1AC0AE5E-E07E-4782-9E0B-F391FDFF26B3"));
_memberOutPort.IsExistAsync(memberId).Returns(true);
var shipmentAddress = new ShipmentAddress("台灣", "台北市", "大安區敦化南路二段100號", "106");
var orderItems = new List<OrderItem>
{
new(ProductId.New(new Guid("072AD23F-396B-445B-8627-1A560755ADAB")), 1, 10),
};
var sut = GetSystemUnderTest();
// Act
var parameter = new CreatOrderParameter
{
MemberId = memberId,
OrderItems = orderItems,
ShipmentAddress = shipmentAddress
};
var actual = await sut.HandleAsync(parameter);
// Assert
actual.Should().BeTrue();
await _domainEventBus.Received(1).DispatchDomainEventsAsync(Arg.Any<Order>());
}
Assert 裡驗證了兩件事,分別對應兩種驗證方式:
actual.Should().BeTrue():狀態驗證——看回傳值。_domainEventBus.Received(1)...:行為驗證——看相依物件有沒有被正確呼叫。訂單建立成功後「應該發出 Domain Event」是業務規則的一部分,只看回傳值驗證不到。
反過來,失敗的情境就驗證「不應該發生什麼」:
[Fact]
public async Task HandleAsyncTest_儲存訂單失敗_應回傳False()
{
// Arrange
_orderOutPort.SaveAsync(Arg.Any<Order>()).Returns(false);
// ...其餘準備省略
// Act
var actual = await sut.HandleAsync(parameter);
// Assert
actual.Should().BeFalse();
await _domainEventBus.DidNotReceive().DispatchDomainEventsAsync(Arg.Any<Order>());
}
存檔失敗卻還發出「訂單已建立」的事件,下游就會處理一張根本不存在的訂單——DidNotReceive() 守住的正是這種 bug。
範例三:驗證例外
預期會拋例外的測試,Act 階段要注意:不能直接呼叫方法,否則例外在 Act 就炸掉了,根本走不到 Assert。做法是把呼叫包成委派(delegate),交給 Assert 去執行:
[Fact]
public async Task HandleAsyncTest_輸入的會員Id不存在_拋出MemberNotFoundException()
{
// Arrange
var memberId = MemberId.New(new Guid("1AC0AE5E-E07E-4782-9E0B-F391FDFF26B3"));
_memberOutPort.IsExistAsync(memberId).Returns(false);
var sut = GetSystemUnderTest();
// Act
var actual = () => sut.HandleAsync(new CreatOrderParameter
{
MemberId = memberId,
OrderItems = orderItems,
});
// Assert
await actual.Should().ThrowAsync<MemberNotFoundException>();
}
驗證的是具體的例外型別(MemberNotFoundException),而不是籠統的 Exception。這樣「會員不存在」跟「訂單項目為空」兩條規則才不會互相掩護——如果都只驗 Exception,其中一條邏輯被拿掉、改由另一個錯誤拋出,測試依然會綠燈。
把 Arrange 的重複部分抽出來
同一個測試類別裡,每個測試都要建立一樣的 Mock 跟被測物件。我習慣這樣分工:
public class CreateOrderServiceTest
{
private readonly IGeneratorIdOutPort _generatorIdOutPort;
private readonly IOrderOutPort _orderOutPort;
private readonly IMemberOutPort _memberOutPort;
private readonly IDomainEventBus _domainEventBus;
private readonly FakeTimeProvider _timeProvider;
// xUnit 每個測試都會 new 一次測試類別,所以每個測試拿到的都是全新的替身
public CreateOrderServiceTest()
{
_timeProvider = new FakeTimeProvider();
_generatorIdOutPort = Substitute.For<IGeneratorIdOutPort>();
_orderOutPort = Substitute.For<IOrderOutPort>();
_memberOutPort = Substitute.For<IMemberOutPort>();
_domainEventBus = Substitute.For<IDomainEventBus>();
}
private ICreateOrderService GetSystemUnderTest()
{
return new CreateOrderService(
_generatorIdOutPort, _timeProvider, _orderOutPort, _domainEventBus, _memberOutPort);
}
}
- 建構子:只負責「建立」替身,不設定行為。
- 每個測試的 Arrange:才設定這個情境需要的回傳值(
Returns(true)或Returns(false))。 GetSystemUnderTest():集中建立被測物件。之後建構子多了一個參數,只需要改這一個地方,不用每個測試都改。
「情境差異」留在每個測試裡,讀測試時才看得出這個案例跟其他案例差在哪;如果把 Returns(...) 也搬進建構子,每個測試看起來都一樣,差異反而被藏起來了。
3A 的幾條守則
- Act 只有一個動作。 如果 Act 裡呼叫了兩個方法,通常代表這其實是兩個測試。
- Assert 驗證同一件事。 可以有多行 Assert(例如同時檢查回傳值與事件),但它們應該都在描述「同一個行為的結果」。
- 三段不交錯。 不要 Arrange → Act → Assert → 再 Act → 再 Assert,那是一個流程測試,不是單元測試。
- 變數命名表達角色。 被測物件叫
sut(System Under Test),結果叫actual,預期值叫expected——讀的人一看就知道哪個是哪個。
測試方法的命名:方法_情境_預期結果
我採用的命名格式是:
被測方法_情境(輸入或前置狀態)_預期結果
這也是 Roy Osherove 在《The Art of Unit Testing》提出的 UnitOfWork_StateUnderTest_ExpectedBehavior 格式。套到專案裡的實際例子:
| 測試方法名稱 | 被測方法 | 情境 | 預期結果 |
|---|---|---|---|
OrderCreate_輸入符合預期的參數_傳回訂單 | Order.New | 參數正確 | 回傳訂單 |
OrderCreate_輸入訂單項目為空_應拋出OrderDomainException | Order.New | 沒有訂單項目 | 拋出領域例外 |
ConfirmPayment_訂單已付款_應更新訂單狀態為已付款 | ConfirmPayment | 付款確認 | 狀態變為已付款 |
HandleAsyncTest_輸入的會員Id不存在_拋出MemberNotFoundException | HandleAsync | 會員不存在 | 拋出例外 |
HandleAsyncTest_儲存訂單失敗_應回傳False | HandleAsync | 存檔失敗 | 回傳 False |
把同一個類別的測試名稱列在一起,其實就是這個類別的規格書。在 Test Explorer 裡看到紅燈,例如 HandleAsyncTest_儲存訂單失敗_應回傳False 失敗,不用打開程式碼就知道:存檔失敗時的處理邏輯被改壞了。
為什麼用中文命名?
C# 的識別字支援 Unicode,測試方法用中文命名完全合法。我在情境與預期結果使用中文,理由是:
- 業務規則本來就是用中文討論的。 「會員不存在」「訂單項目為空」直接對應需求文件與跟 PM 的對話,不需要在腦中翻譯一次。
- 測試方法不會被其他程式碼呼叫。 它不是 API,不用顧慮呼叫端的打字體驗。
- 被測方法名稱保留英文。 第一段是程式碼裡的方法名稱,保持原樣,方便搜尋與對照。
如果團隊有非中文使用者,或 CI 環境對 Unicode 支援不佳,改用英文也完全沒問題,例如 HandleAsyncTest_MemberNotFound_ThrowsMemberNotFoundException——重點是格式一致,語言是團隊的選擇。
命名的幾個細節
1. 預期結果要具體。 傳回True、拋出MemberNotFoundException 比 成功、失敗 好。「失敗」可能是回傳 false、拋例外、還是回傳 null?名稱要能回答這個問題。
2. 情境描述「條件」,不是描述「測試步驟」。 輸入的會員Id不存在 是條件;呼叫IsExistAsync回傳false後再呼叫HandleAsync 是步驟,後者把實作細節寫進名稱,實作一改名稱就過時。
3. 類別名稱對應被測類別。 CreateOrderService 的測試放在 CreateOrderServiceTest,Order 的測試放在 OrderTest。專案結構也對稱:OrderService.UseCase 對應 OrderService.UseCase.Tests。找測試的時候不用猜。
其他語言的命名慣例
不同語言的測試框架有各自的限制與慣例,但「方法、情境、預期」三個要素是共通的:
Go:測試函式必須以 Test 開頭,常見寫法是 Test方法_情境,並在註解補充說明:
// TestCreateRedis_Success 測試成功建立 Redis 申請
func TestCreateRedis_Success(t *testing.T) { ... }
func TestCreateProject_ProjectNameTooLong(t *testing.T) { ... }
情境比較多時,Go 社群更常用 table-driven test,把情境寫在 name 欄位,用 t.Run 跑子測試:
tests := []struct {
name string
input string
wantErr bool
}{
{name: "專案名稱超過長度上限_回傳錯誤", input: strings.Repeat("a", maxNameLength+1), wantErr: true},
{name: "專案名稱正常_不回傳錯誤", input: "order", wantErr: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { ... })
}
JavaScript / TypeScript(Jest、Vitest):用字串描述,天生適合寫成句子:
describe('CreateOrderService.handle', () => {
it('會員不存在時,應拋出 MemberNotFoundError', async () => { ... })
})
小結
- 3A 給測試一個固定的閱讀順序:先看 Act 知道測哪個方法,再看 Assert 知道預期什麼,最後才需要看 Arrange 的細節。
- Act 只做一件事,例外要包成委派再交給 Assert 驗證。
- Assert 同時考慮狀態與行為:回傳值對了,該發的事件有沒有發、不該發的有沒有擋住。
- 命名用「方法_情境_預期結果」,整個測試類別的方法名稱列出來就是一份規格書。
- 名稱要具體、描述條件而非步驟,讓紅燈本身就能說明是哪條業務規則壞了。
測試程式碼也是程式碼,而且是最常被「讀」的那一種:每次紅燈、每次重構、每次新人接手,都會有人回來讀它。花一點力氣讓它好讀,回報會比想像中大。