Install any skill in seconds. Free to start, no credit card required.
Get Started Free →F# testing patterns with xUnit, FsUnit, Unquote, FsCheck property-based testing, integration tests, and test organization best practices.
.claude/skills/fsharp-testing/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-10 | ✗→✓ | ▲ Improved | — | — |
| case-02 | ✗→✓ | ▲ Improved | — | — |
| case-14 | ✗→✓ | ▲ Improved | — | — |
| case-17 | ✗→✓ | ▲ Improved | — | — |
| case-20 | ✗→✓ | ▲ Improved | — | — |
Comprehensive testing patterns for F# applications using xUnit, FsUnit, Unquote, FsCheck, and modern .NET testing practices.
| Tool | Purpose | |---|---| | xUnit | Test framework (standard .NET ecosystem choice) | | FsUnit.xUnit | F#-friendly assertion syntax for xUnit | | Unquote | Assertion library using F# quotations for clear failure messages | | FsCheck.xUnit | Property-based testing integrated with xUnit | | NSubstitute | Mocking .NET dependencies | | Testcontainers | Real infrastructure in integration tests | | WebApplicationFactory | ASP.NET Core integration tests |
fsharpmodule 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 uses F# quotations so failure messages show the full expression that failed, not just "expected X got Y".
fsharpmodule 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 @>
fsharp[<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 @> }
fsharp[<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 @>
fsharpopen 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
fsharptype 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
fsharplet 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 @> }
fsharpopen 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>()) }
fsharptype 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| Anti-Pattern | Fix | |---|---| | Testing implementation details | Test behavior and outcomes | | Mutable shared test state | Fresh state per test | | Thread.Sleep in async tests | Use Task.Delay with timeout, or polling helpers | | Asserting on sprintf output | Assert on typed values and pattern matches | | Ignoring CancellationToken | Always pass and verify cancellation | | Skipping property-based tests | Use FsCheck for any function with clear invariants |
dotnet-patterns - Idiomatic .NET patterns, dependency injection, and architecturecsharp-testing - C# testing patterns (shared infrastructure like WebApplicationFactory and Testcontainers applies to F# too)bash# Run all tests dotnet test # Run with coverage dotnet test --collect:"XPlat Code Coverage" # Run specific project dotnet test tests/MyApp.Tests/ # Filter by test name dotnet test --filter "FullyQualifiedName~OrderService" # Watch mode during development dotnet watch test --project tests/MyApp.Tests/
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-10 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-21 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-15 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-11 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-03 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-18 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-16 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-17 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-06 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-13 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-07 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-08 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-01 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +27 percentage points is the difference between those two pass rates over the 22 comparable cases.
The per-case answers from this run were removed by the retention sweep, so the case table below shows the verdicts without the text either arm produced. The counts above were recorded at the time and are unaffected. Answers are now kept for 180 days.
Other measured skills in the registry, with their headline benchmark lift.