fsharp-testing

fsharp-testing

热门

涵盖使用 xUnit、FsUnit、Unquote 进行 F# 测试的常见范式,包含 FsCheck 基于属性的测试(Property-based Testing)、集成测试以及测试组织最佳实践。

24万Star
3.6万Fork
更新于 2026/8/4
SKILL.md
只读
名称
fsharp-testing
描述

涵盖使用 xUnit、FsUnit、Unquote 进行 F# 测试的常见范式,包含 FsCheck 基于属性的测试(Property-based Testing)、集成测试以及测试组织最佳实践。

F# 测试范式与最佳实践

涵盖使用 xUnit、FsUnit、Unquote、FsCheck 以及现代 .NET 测试实践编写 F# 应用程序的全面测试模式。

适用场景

  • 为 F# 代码编写新测试
  • 审查测试质量与代码覆盖率
  • 为 F# 项目搭建测试基础设施
  • 排查不稳定(flaky)或运行缓慢的测试

测试框架技术栈

工具 用途
xUnit 测试框架(.NET 生态的标准首选)
FsUnit.xUnit 适用于 xUnit 的 F# 友好断言语法
Unquote 基于 F# 引用表达式(Quotations)的断言库,提供清晰的失败报错信息
FsCheck.xUnit 与 xUnit 无缝集成的基于属性的测试库(Property-based Testing)
NSubstitute .NET 依赖项的 Mock 框架
Testcontainers 在集成测试中启动真实的容器基础设施
WebApplicationFactory ASP.NET Core 集成测试工具

基于 xUnit + FsUnit 的单元测试

基础测试结构

module OrderServiceTests

open Xunit
open FsUnit.Xunit

[<Fact>]
let ``create sets status to Pending`` () =
    let order = Order.create "cust-1" [ validItem ]
    order.Status |> should equal Pending

[<Fact>]
let ``confirm changes status to Confirmed`` () =
    let order = Order.create "cust-1" [ validItem ]
    let confirmed = Order.confirm order
    confirmed.Status |> should be (ofCase <@ Confirmed @>)

使用 Unquote 进行断言

Unquote 借助 F# 的引用表达式(Quotations),让测试失败时的报错信息能展示出求值失败的完整表达式,而不是简单的“预期为 X,实际得到 Y”。

module OrderValidationTests

open Xunit
open Swensen.Unquote

[<Fact>]
let ``PlaceOrder returns success when request is valid`` () =
    let request = { CustomerId = "cust-123"; Items = [ validItem ] }
    let result = OrderService.placeOrder request
    test <@ Result.isOk result @>

[<Fact>]
let ``order total sums item prices`` () =
    let items = [ { Sku = "A"; Quantity = 2; Price = 10m }
                  { Sku = "B"; Quantity = 1; Price = 5m } ]
    let total = Order.calculateTotal items
    test <@ total = 25m @>

[<Fact>]
let ``validated email rejects empty input`` () =
    let result = ValidatedEmail.create ""
    test <@ Result.isError result @>

异步测试

[<Fact>]
let ``PlaceOrder returns success when request is valid`` () = task {
    let deps = createTestDeps ()
    let request = { CustomerId = "cust-123"; Items = [ validItem ] }

    let! result = OrderService.placeOrder deps request

    test <@ Result.isOk result @>
}

[<Fact>]
let ``PlaceOrder returns error when items are empty`` () = 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 rejects empty customer 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("user@example.com", true)>]
[<InlineData("user+tag@example.co.uk", true)>]
let ``IsValidEmail returns expected result`` (email: string, expected: bool) =
    test <@ EmailValidator.isValid email = expected @>

使用 FsCheck 进行基于属性的测试(Property-Based Testing)

使用 FsCheck.xUnit

open FsCheck
open FsCheck.Xunit

[<Property>]
let ``order total is always non-negative`` (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 ``serialization roundtrips`` (order: Order) =
    let json = JsonSerializer.Serialize order
    let deserialized = JsonSerializer.Deserialize<Order> json
    deserialized = order

自定义生成器(Generators)

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 ``valid emails pass validation`` (email: string) =
    EmailValidator.isValid email

模拟依赖项(Mocking)

函数存根(推荐用法)

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 saves the confirmed order`` () = 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 ``calls repository with correct 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 returns 404 when not found`` () = 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 始终传递操作指令并验证取消逻辑
跳过基于属性的测试 对具有明确不变性约束(Invariants)的函数积极使用 FsCheck

相关 Skill

  • 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"

# 开发过程中的监听模式(Watch Mode)
dotnet watch test --project tests/MyApp.Tests/