F# Testing Patterns
Use these steps to write clear and safe tests for F# code.
When to Use This Skill
Use this skill when you:
- Write new F# tests
- Review test quality
- Set up an F# test project
- Fix slow or flaky tests
- Test async code
- Test an ASP.NET Core app
Pick the Right Tool
| Tool | Use |
|---|---|
| xUnit | Run tests |
| FsUnit.xUnit | Write F# style checks |
| Unquote | Show clear test errors |
| FsCheck.Xunit | Test many made-up inputs |
| NSubstitute | Fake .NET interfaces |
| WebApplicationFactory | Test an ASP.NET Core app |
| Testcontainers | Test with a real database or service |
Do not add every tool by default. Add only what the tests need.
Set Up a Test Project
Create a test project and add it to the solution:
dotnet new xunit -lang F# -n MyApp.Tests -o tests/MyApp.Tests
dotnet sln add tests/MyApp.Tests/MyApp.Tests.fsproj
dotnet add tests/MyApp.Tests/MyApp.Tests.fsproj reference src/MyApp/MyApp.fsprojAdd test packages as needed:
dotnet add tests/MyApp.Tests package FsUnit.xUnit
dotnet add tests/MyApp.Tests package Unquote
dotnet add tests/MyApp.Tests package FsCheck.Xunit
dotnet add tests/MyApp.Tests package NSubstituteF# files compile in the order shown in the .fsproj file. Put helper files before test files that use them.
<ItemGroup>
<Compile Include="Helpers\TestData.fs" />
<Compile Include="Unit\OrderTests.fs" />
</ItemGroup>Write Small Unit Tests
Use one clear action in each test. Check the result that a caller can see.
module OrderTests
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 equal ConfirmedUse fresh data in each test. Do not let tests share data that can change.
Use Unquote for Clear Errors
Unquote shows which part of a check failed.
module OrderValidationTests
open Xunit
open Swensen.Unquote
[<Fact>]
let ``placeOrder accepts a valid request`` () =
let request =
{ CustomerId = "cust-123"
Items = [ validItem ] }
let result = OrderService.placeOrder request
test <@ Result.isOk result @>
[<Fact>]
let ``order total adds all item prices`` () =
let items =
[ { Sku = "A"; Quantity = 2; Price = 10m }
{ Sku = "B"; Quantity = 1; Price = 5m } ]
let total = Order.calculateTotal items
test <@ total = 25m @>For Result, also check the value or error when it matters.
[<Fact>]
let ``empty email gives the right error`` () =
let result = ValidatedEmail.create ""
test <@ result = Error EmailIsEmpty @>A check for only Result.isError may hide the wrong error.
Test Async Code
Return the task from the test. Do not call .Result or .Wait().
open Xunit
open Swensen.Unquote
[<Fact>]
let ``placeOrder saves a valid order`` () = task {
let deps = createTestDeps ()
let request =
{ CustomerId = "cust-123"
Items = [ validItem ] }
let! result = OrderService.placeOrder deps request
test <@ Result.isOk result @>
}Pass a CancellationToken when the code accepts one. Add a time limit to code that may hang.
[<Fact>]
let ``work stops when canceled`` () = task {
use cts = new CancellationTokenSource()
cts.Cancel()
let work () = service.RunAsync(cts.Token)
do! Assert.ThrowsAnyAsync<OperationCanceledException>(Func<Task>(work))
:> Task
}Do not use Thread.Sleep. Use a signal, a short Task.Delay, or a polling helper with a time limit.
Test More Than One Input
Use [<Theory>] for a short list of known cases.
open Xunit
open Swensen.Unquote
[<Theory>]
[<InlineData("", false)>]
[<InlineData(" ", false)>]
[<InlineData("user@example.com", true)>]
let ``email check returns the right result``
(email: string)
(expected: bool)
=
test <@ EmailValidator.isValid email = expected @>InlineData can hold only simple constant values. Use MemberData, ClassData, or a normal [<Fact>] for records, lists, and other rich values.
Test Rules with FsCheck
Use FsCheck when a rule should hold for many inputs. Good rules include:
- A value never goes below zero
- Sorting keeps the same items
- Encoding and decoding gives back the same value
- Running an action twice has the same result as running it once
open FsCheck
open FsCheck.Xunit
[<Property>]
let ``order total is never negative``
(items: NonEmptyList<PositiveInt * NonNegativeInt>)
=
let orderItems =
items.Get
|> List.map (fun (qty, price) ->
{ Sku = "SKU"
Quantity = qty.Get
Price = decimal price.Get })
Order.calculateTotal orderItems >= 0mDo not use any random decimal as money without limits. Very large values may cause overflow and hide the rule you meant to test.
Custom Value Makers
Use a custom maker when most made-up values are not valid.
type EmailGenerators =
static member ValidEmail () =
gen {
let! user = Gen.elements [ "alice"; "bob"; "carol" ]
let! host = Gen.elements [ "example.com"; "test.org" ]
return $"{user}@{host}"
}
|> Arb.fromGen
[<Property(Arbitrary = [| typeof<EmailGenerators> |])>]
let ``valid emails pass the check`` (email: string) =
EmailValidator.isValid emailKeep a failing seed from the test output. Use it to run the same failed case again while fixing the bug.
A save-and-load test needs a safe type. Make sure the type can be encoded, has a useful equality check, and does not hold functions or open resources.
[<Property>]
let ``JSON save and load keeps the order`` (order: Order) =
let json = JsonSerializer.Serialize order
let copy = JsonSerializer.Deserialize<Order> json
copy = orderPrefer Function Stubs
F# code is often easy to test by passing functions as inputs.
type TestState =
{ mutable SavedOrders: Order list }
let createTestDeps state =
{ FindOrder = fun id ->
task { return Map.tryFind id testOrders }
SaveOrder = fun order ->
task { state.SavedOrders <- order :: state.SavedOrders }
SendNotification = fun _ ->
Task.CompletedTask }
[<Fact>]
let ``placeOrder saves one order`` () = task {
let state = { SavedOrders = [] }
let deps = createTestDeps state
let! _ = OrderService.placeOrder deps validRequest
test <@ state.SavedOrders.Length = 1 @>
}Create new state for each test. Shared mutable state can make tests fail based on run order.
Use NSubstitute for .NET Interfaces
Use a mock tool when the code must call a .NET interface.
open System
open System.Threading
open System.Threading.Tasks
open NSubstitute
open Xunit
[<Fact>]
let ``service asks for the right order ID`` () = task {
let repo = Substitute.For<IOrderRepository>()
repo
.FindByIdAsync(
Arg.Any<Guid>(),
Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Some testOrder))
|> ignore
let service = OrderService(repo)
let! _ =
service.GetOrder(
testOrder.Id,
CancellationToken.None)
do!
repo.Received(1).FindByIdAsync(
testOrder.Id,
Arg.Any<CancellationToken>())
}Check calls only when the call is part of the real rule. In most tests, check the returned value or saved state.
Be careful with overloads. Add type hints when F# cannot pick the right method.
Test an ASP.NET Core App
Use WebApplicationFactory to test the full HTTP path.
open System
open System.Net
open Microsoft.AspNetCore.Mvc.Testing
open Xunit
open Swensen.Unquote
type OrderApiTests(factory: WebApplicationFactory<Program>) =
interface IClassFixture<WebApplicationFactory<Program>>
let client = factory.CreateClient()
[<Fact>]
member _.``GET returns 404 for a missing order`` () = task {
let id = Guid.NewGuid()
let! response = client.GetAsync($"/api/orders/{id}")
test <@ response.StatusCode = HttpStatusCode.NotFound @>
}If Program cannot be seen by the test project, expose it from the app:
type Program = class endUse a unique database name for each test when using an in-memory database. A fixed name can leak data between tests.
let databaseName = $"TestDb-{Guid.NewGuid()}"An in-memory database may act unlike the real database. Use Testcontainers for tests that depend on SQL rules, indexes, locks, time zones, or real database types.
Keep unit tests fast. Keep service tests in a separate group if they need Docker, a network port, or a real database.
Concrete Example
Code under test:
module Price
type Line =
{ Count: int
UnitPrice: decimal }
let total lines =
lines
|> List.sumBy (fun line ->
decimal line.Count * line.UnitPrice)Tests:
module PriceTests
open Xunit
open Swensen.Unquote
open FsCheck
open FsCheck.Xunit
[<Fact>]
let ``total adds each line`` () =
let lines =
[ { Count = 2; UnitPrice = 4m }
{ Count = 1; UnitPrice = 3m } ]
test <@ Price.total lines = 11m @>
[<Fact>]
let ``empty list has a zero total`` () =
test <@ Price.total [] = 0m @>
[<Property>]
let ``total is not negative for safe values``
(values: list<NonNegativeInt * NonNegativeInt>)
=
let lines =
values
|> List.map (fun (count, price) ->
{ Count = count.Get
UnitPrice = decimal price.Get })
Price.total lines >= 0mThis example checks one known case, one edge case, and one broad rule.
File Layout
tests/
MyApp.Tests/
Helpers/
TestData.fs
TestDeps.fs
Unit/
OrderServiceTests.fs
PaymentServiceTests.fs
Properties/
OrderPropertyTests.fs
Integration/
OrderApiTests.fs
OrderRepositoryTests.fsList helper files first in the project file. Keep slow service tests apart from fast unit tests.
Edge Cases to Check
Check the cases that fit the code:
- Empty strings and white space
- Empty lists
- Zero and negative numbers
- The largest allowed number
- Duplicate items
- Missing values
- Wrong IDs
- Bad dates and time zones
- Failed tasks
- Canceled work
- Time limits
- Text with Unicode letters
- Database errors
- Two calls at the same time
- Save and load failures
Do not test every item in this list for every function. Pick cases tied to real rules and risks.
Common Problems
| Problem | Better choice |
|---|---|
| Test private steps | Test results callers can see |
| Share mutable state | Make fresh state for each test |
Use .Result or .Wait() |
Use let! in a task |
Use Thread.Sleep |
Use a signal or a time limit |
Check only Result.isError |
Check the exact error |
| Use random values with no limits | Make safe custom values |
| Use one database name | Use a new name per test |
| Depend on test run order | Make each test stand alone |
| Use a mock for pure code | Call the function at once |
| Hide a flaky test with retries | Find and fix the shared state or race |
| Use real clock time | Pass in a clock function |
| Use real random data | Pass in a fixed seed or value source |
Compare text made by sprintf |
Compare typed values when possible |
Run Tests
# Run all tests
dotnet test
# Run one project
dotnet test tests/MyApp.Tests/MyApp.Tests.fsproj
# Run tests whose full name has OrderService
dotnet test --filter "FullyQualifiedName~OrderService"
# Run tests while files change
dotnet watch test --project tests/MyApp.Tests/MyApp.Tests.fsproj
# Collect code coverage
dotnet test --collect:"XPlat Code Coverage"Coverage shows which code ran. It does not prove that the checks are good. Read the tests and test the key rules.
Final Check
Before you finish:
- Each test has one clear reason to fail
- Test names state the rule
- Tests do not depend on run order
- Async tests return a task
- Slow work has a time limit
- Errors are checked by kind and value
- FsCheck inputs stay in safe limits
- Integration tests clean up their data
- The full test command passes
Related Skills
dotnet-patterns: Common .NET code and app designcsharp-testing: C# tests that may share .NET test tools