F# テストパターン
xUnit、FsUnit、Unquote、FsCheck、および最新の .NET テスト手法を活用した、F# アプリケーション向けの包括的なテストパターンです。
有効化するタイミング
- F# コードの新しいテストを作成する場合
- テストの品質とカバレッジをレビューする場合
- F# プロジェクトのテストインフラをセットアップする場合
- 不安定または遅いテストをデバッグする場合
テストフレームワーク構成
| ツール | 目的 |
|---|---|
| xUnit | テストフレームワーク(標準的な .NET エコシステムの選択肢) |
| FsUnit.xUnit | xUnit 向け F# フレンドリーなアサーション構文 |
| Unquote | F# クォーテーションを使用した明確な失敗メッセージを提供するアサーションライブラリ |
| FsCheck.xUnit | xUnit と統合されたプロパティベーステスト |
| NSubstitute | .NET 依存関係のモック |
| Testcontainers | 統合テストにおける実際のインフラストラクチャ |
| WebApplicationFactory | ASP.NET Core 統合テスト |
xUnit + FsUnit を使用した単体テスト
基本的なテスト構造
module OrderServiceTests
open Xunit
open FsUnit.Xunit
[<Fact>]
let ``create はステータスを Pending に設定する`` () =
let order = Order.create "cust-1" [ validItem ]
order.Status |> should equal Pending
[<Fact>]
let ``confirm はステータスを Confirmed に変更する`` () =
let order = Order.create "cust-1" [ validItem ]
let confirmed = Order.confirm order
confirmed.Status |> should be (ofCase <@ Confirmed @>)
Unquote を使用したアサーション
Unquote は F# クォーテーションを使用するため、失敗メッセージには「expected X got 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 ``validateEmail は空の入力を拒否する`` () =
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/