F# 測試模式
涵蓋 F# 應用程式的完整測試模式,使用 xUnit、FsUnit、Unquote、FsCheck 及現代 .NET 測試實務。
何時啟用
- 為 F# 程式碼撰寫新的測試
- 審查測試品質與涵蓋率
- 設定 F# 專案的測試基礎設施
- 偵錯不穩定或執行緩慢的測試
測試框架堆疊
| 工具 | 用途 |
|---|---|
| xUnit | 測試框架(標準 .NET 生態系選擇) |
| FsUnit.xUnit | 提供 F# 友善的斷言語法(用於 xUnit) |
| Unquote | 使用 F# 引號讓失敗訊息更清楚的斷言程式庫 |
| FsCheck.xUnit | 與 xUnit 整合的屬性基礎測試 |
| NSubstitute | 模擬 .NET 相依關係 |
| Testcontainers | 整合測試中使用真實基礎設施 |
| WebApplicationFactory | ASP.NET Core 整合測試 |
使用 xUnit + FsUnit 的單元測試
基本測試結構
module OrderServiceTests
open Xunit
open FsUnit.Xunit
[<Fact>]
let ``建立時狀態設為 Pending`` () =
let order = Order.create "cust-1" [ validItem ]
order.Status |> should equal Pending
[<Fact>]
let ``確認後狀態變為 Confirmed`` () =
let order = Order.create "cust-1" [ validItem ]
let confirmed = Order.confirm order
confirmed.Status |> should be (ofCase <@ Confirmed @>)
使用 Unquote 的斷言
Unquote 使用 F# 引號,因此失敗訊息會顯示完整的失敗運算式,而不只是「預期 X 得到 Y」。
module OrderValidationTests
open Xunit
open Swensen.Unquote
[<Fact>]
let ``PlaceOrder 在請求有效時回傳成功`` () =
let request = { CustomerId = "cust-123"; Items = [ validItem ] }
let result = OrderService.placeOrder request
test <@ Result.isOk result @>
[<Fact>]
let ``訂單總額為各商品價格加總`` () =
let items = [ { Sku = "A"; Quantity = 2; Price = 10m }
{ Sku = "B"; Quantity = 1; Price = 5m } ]
let total = Order.calculateTotal items
test <@ total = 25m @>
[<Fact>]
let ``驗證過的電子郵件拒絕空輸入`` () =
let result = ValidatedEmail.create ""
test <@ Result.isError result @>
非同步測試
[<Fact>]
let ``PlaceOrder 在請求有效時回傳成功`` () = task {
let deps = createTestDeps ()
let request = { CustomerId = "cust-123"; Items = [ validItem ] }
let! result = OrderService.placeOrder deps request
test <@ Result.isOk result @>
}
[<Fact>]
let ``PlaceOrder 在項目為空時回傳錯誤`` () = task {
let deps = createTestDeps ()
let request = { CustomerId = "cust-123"; Items = [] }
let! result = OrderService.placeOrder deps request
test <@ Result.isError result @>
}
使用 Theory 的參數化測試
[<Theory>]
[<InlineData("")>]
[<InlineData(" ")>]
let ``PlaceOrder 拒絕空的客戶 ID`` (customerId: string) =
let request = { CustomerId = customerId; Items = [ validItem ] }
let result = OrderService.placeOrder request
result |> should be (ofCase <@ Error @>)
[<Theory>]
[<InlineData("", false)>]
[<InlineData("a", false)>]
[<InlineData("[email protected]", true)>]
[<InlineData("[email protected]", true)>]
let ``IsValidEmail 回傳預期結果`` (email: string, expected: bool) =
test <@ EmailValidator.isValid email = expected @>
使用 FsCheck 的屬性基礎測試
使用 FsCheck.xUnit
open FsCheck
open FsCheck.Xunit
[<Property>]
let ``訂單總額永遠為非負值`` (items: NonEmptyList<PositiveInt * decimal>) =
let orderItems =
items.Get
|> List.map (fun (qty, price) ->
{ Sku = "SKU"; Quantity = qty.Get; Price = abs price })
let total = Order.calculateTotal orderItems
total >= 0m
[<Property>]
let ``序列化可以來回轉換`` (order: Order) =
let json = JsonSerializer.Serialize order
let deserialized = JsonSerializer.Deserialize<Order> json
deserialized = order
自訂產生器
type OrderGenerators =
static member ValidEmail () =
gen {
let! user = Gen.elements [ "alice"; "bob"; "carol" ]
let! domain = Gen.elements [ "example.com"; "test.org" ]
return $"{user}@{domain}"
}
|> Arb.fromGen
[<Property(Arbitrary = [| typeof<OrderGenerators> |])>]
let ``有效的電子郵件通過驗證`` (email: string) =
EmailValidator.isValid email
模擬相依關係
函數樁(建議使用)
let createTestDeps () =
let mutable savedOrders = []
{ FindOrder = fun id -> task { return Map.tryFind id testData }
SaveOrder = fun order -> task { savedOrders <- order :: savedOrders }
SendNotification = fun _ -> Task.CompletedTask }
[<Fact>]
let ``PlaceOrder 儲存已確認的訂單`` () = task {
let mutable saved = []
let deps =
{ createTestDeps () with
SaveOrder = fun order -> task { saved <- order :: saved } }
let! _ = OrderService.placeOrder deps validRequest
test <@ saved.Length = 1 @>
}
用於 .NET 介面的 NSubstitute
open NSubstitute
[<Fact>]
let ``以正確的 ID 呼叫儲存庫`` () = task {
let repo = Substitute.For<IOrderRepository>()
repo.FindByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Some testOrder))
let service = OrderService(repo)
let! _ = service.GetOrder(testOrder.Id, CancellationToken.None)
do! repo.Received(1).FindByIdAsync(testOrder.Id, Arg.Any<CancellationToken>())
}
ASP.NET Core 整合測試
type OrderApiTests (factory: WebApplicationFactory<Program>) =
interface IClassFixture<WebApplicationFactory<Program>>
let client =
factory.WithWebHostBuilder(fun builder ->
builder.ConfigureServices(fun services ->
services.RemoveAll<DbContextOptions<AppDbContext>>() |> ignore
services.AddDbContext<AppDbContext>(fun options ->
options.UseInMemoryDatabase("TestDb") |> ignore) |> ignore))
.CreateClient()
[<Fact>]
member _.``GET order 在找不到時回傳 404`` () = task {
let! response = client.GetAsync($"/api/orders/{Guid.NewGuid()}")
test <@ response.StatusCode = HttpStatusCode.NotFound @>
}
測試組織
tests/
MyApp.Tests/
Unit/
OrderServiceTests.fs
PaymentServiceTests.fs
Integration/
OrderApiTests.fs
OrderRepositoryTests.fs
Properties/
OrderPropertyTests.fs
Helpers/
TestData.fs
TestDeps.fs
常見反模式
| 反模式 | 修正方法 |
|---|---|
| 測試實作細節 | 測試行為與結果 |
| 可變的共用測試狀態 | 每個測試使用全新狀態 |
在非同步測試中使用 Thread.Sleep | 使用 Task.Delay 搭配逾時,或輪詢輔助函式 |
對 sprintf 輸出進行斷言 | 對具型別值與模式比對進行斷言 |
忽略 CancellationToken | 一律傳遞並驗證取消 |
| 跳過屬性基礎測試 | 對任何有明確不變量的函式使用 FsCheck |
相關技能
dotnet-patterns- 慣用的 .NET 模式、依賴注入與架構csharp-testing- C# 測試模式(WebApplicationFactory 和 Testcontainers 等共用基礎設施同樣適用於 F#)
執行測試
# 執行所有測試
dotnet test
# 執行並收集涵蓋率
dotnet test --collect:"XPlat Code Coverage"
# 執行特定專案
dotnet test tests/MyApp.Tests/
# 依測試名稱篩選
dotnet test --filter "FullyQualifiedName~OrderService"
# 開發期間的監控模式
dotnet watch test --project tests/MyApp.Tests/