--- name: fsharp-testing description: F# testing patterns with xUnit, FsUnit, Unquote, FsCheck property-based testing, integration tests, and test organization best practices. origin: ECC --- # F# Testing Patterns Comprehensive testing patterns for F# applications using xUnit, FsUnit, Unquote, FsCheck, and modern .NET testing practices. ## When to Activate - Writing new tests for F# code - Reviewing test quality and coverage - Setting up test infrastructure for F# projects - Debugging flaky or slow tests ## Test Framework Stack | 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 | ## Unit Tests with xUnit + FsUnit ### Basic Test Structure ```fsharp module OrderServiceTests open Xunit open FsUnit.Xunit [] let ``create sets status to Pending`` () = let order = Order.create "cust-1" [ validItem ] order.Status |> should equal Pending [] let ``confirm changes status to Confirmed`` () = let order = Order.create "cust-1" [ validItem ] let confirmed = Order.confirm order confirmed.Status |> should be (ofCase <@ Confirmed @>) ``` ### Assertions with Unquote Unquote uses F# quotations so failure messages show the full expression that failed, not just "expected X got Y". ```fsharp module OrderValidationTests open Xunit open Swensen.Unquote [] let ``PlaceOrder returns success when request is valid`` () = let request = { CustomerId = "cust-123"; Items = [ validItem ] } let result = OrderService.placeOrder request test <@ Result.isOk result @> [] 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 @> [] let ``validated email rejects empty input`` () = let result = ValidatedEmail.create "" test <@ Result.isError result @> ``` ### Async Tests ```fsharp [] 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 @> } [] 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 @> } ``` ### Parameterized Tests with Theory ```fsharp [] [] [] let ``PlaceOrder rejects empty customer ID`` (customerId: string) = let request = { CustomerId = customerId; Items = [ validItem ] } let result = OrderService.placeOrder request result |> should be (ofCase <@ Error @>) [] [] [] [] [] let ``IsValidEmail returns expected result`` (email: string, expected: bool) = test <@ EmailValidator.isValid email = expected @> ``` ## Property-Based Testing with FsCheck ### Using FsCheck.xUnit ```fsharp open FsCheck open FsCheck.Xunit [] let ``order total is always non-negative`` (items: NonEmptyList) = let orderItems = items.Get |> List.map (fun (qty, price) -> { Sku = "SKU"; Quantity = qty.Get; Price = abs price }) let total = Order.calculateTotal orderItems total >= 0m [] let ``serialization roundtrips`` (order: Order) = let json = JsonSerializer.Serialize order let deserialized = JsonSerializer.Deserialize json deserialized = order ``` ### Custom Generators ```fsharp 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 [ |])>] let ``valid emails pass validation`` (email: string) = EmailValidator.isValid email ``` ## Mocking Dependencies ### Function Stubs (Preferred) ```fsharp 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 } [] 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 @> } ``` ### NSubstitute for .NET Interfaces ```fsharp open NSubstitute [] let ``calls repository with correct ID`` () = task { let repo = Substitute.For() repo.FindByIdAsync(Arg.Any(), Arg.Any()) .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()) } ``` ## ASP.NET Core Integration Tests ```fsharp type OrderApiTests (factory: WebApplicationFactory) = interface IClassFixture> let client = factory.WithWebHostBuilder(fun builder -> builder.ConfigureServices(fun services -> services.RemoveAll>() |> ignore services.AddDbContext(fun options -> options.UseInMemoryDatabase("TestDb") |> ignore) |> ignore)) .CreateClient() [] member _.``GET order returns 404 when not found`` () = task { let! response = client.GetAsync($"/api/orders/{Guid.NewGuid()}") test <@ response.StatusCode = HttpStatusCode.NotFound @> } ``` ## Test Organization ``` tests/ MyApp.Tests/ Unit/ OrderServiceTests.fs PaymentServiceTests.fs Integration/ OrderApiTests.fs OrderRepositoryTests.fs Properties/ OrderPropertyTests.fs Helpers/ TestData.fs TestDeps.fs ``` ## Common Anti-Patterns | 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 | ## Related Skills - `dotnet-patterns` - Idiomatic .NET patterns, dependency injection, and architecture - `csharp-testing` - C# testing patterns (shared infrastructure like WebApplicationFactory and Testcontainers applies to F# too) ## Running Tests ```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/ ```