← 回文章列表

2023年12月5日

單元測試怎麼寫才好讀?用 3A 模式組織測試,用「方法_情境_預期結果」命名

Unit TestTDDC#xUnit測試

寫單元測試時,最常遇到的問題通常不是「不會寫」,而是三個月後回來看,看不懂當初在測什麼。測試失敗時,紅燈只告訴你一個方法名稱;如果名稱是 Test1、CreateOrderTest2,還得把整段測試讀完、甚至 debug,才知道是哪個業務規則壞了。

好讀的測試靠兩件事撐起來:

  1. 結構:每個測試都用同樣的骨架寫——這就是 3A 模式。
  2. 命名:方法名稱本身就是一句規格說明——「這個方法,在這個情境下,應該得到這個結果」。

以下用我在電商專案(Clean Architecture 的訂單服務)裡實際寫的測試當例子。

3A 模式:Arrange、Act、Assert

3A 把一個測試切成三段,每段只做一件事:

階段做什麼常見內容
Arrange(準備)建立被測物件與它需要的環境建立測試資料、設定 Stub 回傳值、固定時間
Act(執行)呼叫一次被測的方法sut.HandleAsync(...)、order.Complete()
Assert(驗證)檢查結果是否符合預期回傳值、物件狀態、是否拋出例外、相依物件是否被呼叫

範例一:Entity 的狀態變化

先看最單純的情況——驗證 Order 這個 Entity 完成訂單後狀態是否正確:

C#
[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)的行為:

C#
[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」是業務規則的一部分,只看回傳值驗證不到。

反過來,失敗的情境就驗證「不應該發生什麼」:

C#
[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 去執行:

C#
[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 跟被測物件。我習慣這樣分工:

C#
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 的幾條守則

  1. Act 只有一個動作。 如果 Act 裡呼叫了兩個方法,通常代表這其實是兩個測試。
  2. Assert 驗證同一件事。 可以有多行 Assert(例如同時檢查回傳值與事件),但它們應該都在描述「同一個行為的結果」。
  3. 三段不交錯。 不要 Arrange → Act → Assert → 再 Act → 再 Assert,那是一個流程測試,不是單元測試。
  4. 變數命名表達角色。 被測物件叫 sut(System Under Test),結果叫 actual,預期值叫 expected——讀的人一看就知道哪個是哪個。

測試方法的命名:方法_情境_預期結果

我採用的命名格式是:

text
被測方法_情境(輸入或前置狀態)_預期結果

這也是 Roy Osherove 在《The Art of Unit Testing》提出的 UnitOfWork_StateUnderTest_ExpectedBehavior 格式。套到專案裡的實際例子:

測試方法名稱被測方法情境預期結果
OrderCreate_輸入符合預期的參數_傳回訂單Order.New參數正確回傳訂單
OrderCreate_輸入訂單項目為空_應拋出OrderDomainExceptionOrder.New沒有訂單項目拋出領域例外
ConfirmPayment_訂單已付款_應更新訂單狀態為已付款ConfirmPayment付款確認狀態變為已付款
HandleAsyncTest_輸入的會員Id不存在_拋出MemberNotFoundExceptionHandleAsync會員不存在拋出例外
HandleAsyncTest_儲存訂單失敗_應回傳FalseHandleAsync存檔失敗回傳 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方法_情境,並在註解補充說明:

Go
// TestCreateRedis_Success 測試成功建立 Redis 申請
func TestCreateRedis_Success(t *testing.T) { ... }

func TestCreateProject_ProjectNameTooLong(t *testing.T) { ... }

情境比較多時,Go 社群更常用 table-driven test,把情境寫在 name 欄位,用 t.Run 跑子測試:

Go
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):用字串描述,天生適合寫成句子:

TypeScript
describe('CreateOrderService.handle', () => {
  it('會員不存在時,應拋出 MemberNotFoundError', async () => { ... })
})

小結

  • 3A 給測試一個固定的閱讀順序:先看 Act 知道測哪個方法,再看 Assert 知道預期什麼,最後才需要看 Arrange 的細節。
  • Act 只做一件事,例外要包成委派再交給 Assert 驗證。
  • Assert 同時考慮狀態與行為:回傳值對了,該發的事件有沒有發、不該發的有沒有擋住。
  • 命名用「方法_情境_預期結果」,整個測試類別的方法名稱列出來就是一份規格書。
  • 名稱要具體、描述條件而非步驟,讓紅燈本身就能說明是哪條業務規則壞了。

測試程式碼也是程式碼,而且是最常被「讀」的那一種:每次紅燈、每次重構、每次新人接手,都會有人回來讀它。花一點力氣讓它好讀,回報會比想像中大。