fsharp-testing
F# testing patterns with xUnit, FsUnit, Unquote, FsCheck property-based testing, integration tests, and test organization best practices.
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
module OrderServiceTests
open Xunitopen 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 @>)Assertions with Unquote
Unquote uses F# quotations so failure messages show the full expression that failed, not just “expected X got Y”.
module OrderValidationTests
open Xunitopen 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 @>Async Tests
[<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 @>}Parameterized Tests with 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 @>Property-Based Testing with FsCheck
Using FsCheck.xUnit
open FsCheckopen 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 = orderCustom 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 emailMocking Dependencies
Function Stubs (Preferred)
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 @>}NSubstitute for .NET Interfaces
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 Integration Tests
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 @> }Test Organization
tests/ MyApp.Tests/ Unit/ OrderServiceTests.fs PaymentServiceTests.fs Integration/ OrderApiTests.fs OrderRepositoryTests.fs Properties/ OrderPropertyTests.fs Helpers/ TestData.fs TestDeps.fsCommon 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 architecturecsharp-testing- C# testing patterns (shared infrastructure like WebApplicationFactory and Testcontainers applies to F# too)
Running Tests
# Run all testsdotnet test
# Run with coveragedotnet test --collect:"XPlat Code Coverage"
# Run specific projectdotnet test tests/MyApp.Tests/
# Filter by test namedotnet test --filter "FullyQualifiedName~OrderService"
# Watch mode during developmentdotnet watch test --project tests/MyApp.Tests/