F# Testing with Expecto and FsUnit
F# testing has two solid options: you can use the C# testing frameworks (NUnit, xUnit) via FsUnit, or you can use Expecto — a framework built specifically for F# that embraces functional design with first-class functions, composable test lists, and native async support. Both approaches work; Expecto tends to feel more natural in idiomatic F# code, while FsUnit is a better fit if your team already uses NUnit or xUnit across C# and F# projects.
This guide covers both, plus FsCheck for property-based testing, with practical examples testing an F# domain model.
Expecto Overview
Expecto's core design decision is that tests are values — functions of type Test. This makes test composition, parameterization, and filtering all regular F# operations:
open Expecto
let myTests =
testList "my module" [
test "addition works" {
Expect.equal (2 + 2) 4 "2 + 2 should be 4"
}
test "string concat" {
Expect.equal ("hello " + "world") "hello world" "string concat"
}
]Because myTests is just a value, you can compose it with other test lists, filter it, run subsets of it — all without any framework magic.
Installation
Create a test project and add Expecto:
dotnet new console -lang F# -o MyProject.Tests
cd MyProject.Tests
dotnet add package Expecto
dotnet add package Expecto.FsCheck <span class="hljs-comment"># for property testing
dotnet add package FsCheck <span class="hljs-comment"># property testing engine
dotnet add package Microsoft.NET.Test.Sdk <span class="hljs-comment"># for dotnet test integration
dotnet add package YoloDev.Expecto.TestSdk <span class="hljs-comment"># for dotnet test integrationYour .fsproj file will look like:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Tests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Expecto" Version="10.*" />
<PackageReference Include="Expecto.FsCheck" Version="10.*" />
<PackageReference Include="FsCheck" Version="2.*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="YoloDev.Expecto.TestSdk" Version="0.*" />
</ItemGroup>
</Project>Writing Tests
test and testCase
The test computation expression is Expecto's primary way to write tests:
open Expecto
let calculatorTests =
testList "Calculator" [
test "add returns correct sum" {
Expect.equal (Calculator.add 2 3) 5 "2 + 3 should be 5"
}
test "subtract returns correct difference" {
Expect.equal (Calculator.subtract 10 3) 7 "10 - 3 should be 7"
}
test "divide returns error for zero denominator" {
let result = Calculator.divide 10 0
Expect.equal result (Error "Division by zero") "should return error"
}
test "divide returns correct quotient" {
Expect.equal (Calculator.divide 10 2) (Ok 5) "10 / 2 = 5"
}
]testCase is the equivalent without computation expression syntax:
let tests =
testList "Parsing" [
testCase "parses integer" <| fun () ->
Expect.equal (parseInt "42") (Ok 42) "should parse 42"
testCase "fails on non-numeric" <| fun () ->
Expect.isError (parseInt "abc") "should fail on abc"
]Nested Test Lists
testList composes into hierarchies:
let allTests =
testList "MyApp" [
testList "Calculator" [
test "add" { Expect.equal (add 1 2) 3 "" }
test "subtract" { Expect.equal (subtract 5 2) 3 "" }
]
testList "Parser" [
test "parse int" { Expect.equal (parseInt "10") (Ok 10) "" }
test "parse float" { Expect.equal (parseFloat "3.14") (Ok 3.14) "" }
]
testList "Validation" [
test "valid email" { Expect.isTrue (isValidEmail "a@b.com") "" }
test "invalid email" { Expect.isFalse (isValidEmail "notanemail") "" }
]
]Async Tests
Expecto handles async natively with testAsync and testCaseAsync:
open System.Net.Http
let httpTests =
testList "HTTP" [
testAsync "fetches user from API" {
use client = new HttpClient()
let! response = client.GetAsync("https://api.example.com/users/1") |> Async.AwaitTask
Expect.equal (int response.StatusCode) 200 "should return 200"
}
testAsync "creates user via POST" {
use client = new HttpClient()
let body = new StringContent("""{"name":"Alice"}""",
System.Text.Encoding.UTF8,
"application/json")
let! response = client.PostAsync("https://api.example.com/users", body)
|> Async.AwaitTask
Expect.equal (int response.StatusCode) 201 "should return 201"
}
]For Task-based async (common when calling .NET APIs):
let taskTests =
testList "Database" [
testTask "inserts and retrieves record" {
let! id = Db.insertAsync { Name = "Alice"; Email = "alice@example.com" }
let! user = Db.findAsync id
Expect.isSome user "user should exist"
Expect.equal user.Value.Name "Alice" "name should match"
}
]Expect Functions
Expecto's Expect module has functions for all common assertions:
// Equality
Expect.equal actual expected message
Expect.notEqual actual unexpected message
// Boolean
Expect.isTrue condition message
Expect.isFalse condition message
// Options
Expect.isSome optValue message
Expect.isNone optValue message
Expect.wantSome optValue message // returns the unwrapped value
// Results
Expect.isOk result message
Expect.isError result message
Expect.wantOk result message // returns the Ok value
// Collections
Expect.isEmpty sequence message
Expect.isNonEmpty sequence message
Expect.contains sequence item message
Expect.sequenceEqual actual expected message
// Strings
Expect.stringContains actual substring message
Expect.stringStarts actual prefix message
Expect.stringEnds actual suffix message
// Exceptions
Expect.throws (fun () -> failwith "oops") "should throw"
Expect.throwsT<ArgumentException> (fun () -> methodThatThrows()) "should throw ArgumentException"
// Comparison
Expect.isLessThan actual expected message
Expect.isGreaterThan actual expected message
Expect.isGreaterThanOrEqual actual expected messageParallel Test Execution
Expecto runs tests in parallel by default. To control this behavior:
// Mark a test as sequential (cannot run in parallel)
let sequentialTest =
testSequenced <| test "writes to shared file" {
// This won't run in parallel with other tests
File.WriteAllText("shared.txt", "data")
Expect.equal (File.ReadAllText("shared.txt")) "data" ""
}
// Run a test list in parallel explicitly
let parallelTests =
testList "pure computations" [
test "fib 10" { Expect.equal (fib 10) 55 "" }
test "fib 20" { Expect.equal (fib 20) 6765 "" }
test "fib 30" { Expect.equal (fib 30) 832040 "" }
]Run with parallel flag:
dotnet run --project MyProject.Tests -- --parallelRunning Tests
As a standalone executable
// Program.fs
[<EntryPoint>]
let main argv =
runTestsWithCLIArgs [] argv allTestsdotnet run # run all tests
dotnet run -- --filter <span class="hljs-string">"Calculator" <span class="hljs-comment"># filter by name
dotnet run -- --summary <span class="hljs-comment"># show summary only
dotnet run -- --parallel <span class="hljs-comment"># run in parallel
dotnet run -- --no-spinner <span class="hljs-comment"># disable progress spinner (good for CI)
dotnet run -- --version <span class="hljs-comment"># show Expecto versionWith dotnet test
With the YoloDev.Expecto.TestSdk package installed, standard dotnet test works:
dotnet test <span class="hljs-comment"># run all tests
dotnet <span class="hljs-built_in">test --filter <span class="hljs-string">"Calculator" <span class="hljs-comment"># filter tests
dotnet <span class="hljs-built_in">test --logger <span class="hljs-string">"console;verbosity=detailed"Test Filtering
# Run tests whose name contains "Parser"
dotnet run -- --filter-test-case Parser
<span class="hljs-comment"># Run tests whose full path matches a pattern
dotnet run -- --filter-test-list Calculator
<span class="hljs-comment"># Run a specific test by name (exact match)
dotnet run -- --run-single <span class="hljs-string">"Calculator/add returns correct sum"FsUnit: NUnit-Style Assertions for F#
FsUnit wraps NUnit or xUnit with F#-friendly syntax using should:
dotnet add package FsUnit
dotnet add package NUnit
dotnet add package NUnit3TestAdapter
dotnet add package Microsoft.NET.Test.SdkFsUnit works with NUnit's [<Test>] attribute:
open NUnit.Framework
open FsUnit
[<TestFixture>]
type CalculatorTests() =
[<Test>]
member _.``add returns correct sum``() =
Calculator.add 2 3 |> should equal 5
[<Test>]
member _.``subtract returns difference``() =
Calculator.subtract 10 3 |> should equal 7
[<Test>]
member _.``divide by zero returns error``() =
Calculator.divide 10 0 |> should equal (Error "Division by zero")
[<Test>]
member _.``string should contain substring``() =
"hello world" |> should contain "world"
[<Test>]
member _.``list should have length``() =
[1; 2; 3] |> should haveLength 3
[<Test>]
member _.``value should be greater than``() =
42 |> should be (greaterThan 10)
[<Test>]
member _.``option should be None``() =
None |> should be (ofCase <@ None @>)FsUnit matchers:
value |> should equal expected
value |> should not' (equal unexpected)
value |> should be (greaterThan 10)
value |> should be (lessThan 100)
value |> should be (greaterThanOrEqualTo 10)
value |> should be True
value |> should be False
value |> should be Null
value |> should not' be Null
value |> should contain item
value |> should haveLength 5
"str" |> should startWith "st"
"str" |> should endWith "tr"
(fun () -> failwith "oops") |> should throw typeof<Exception>FsCheck Integration with Expecto
Expecto.FsCheck integrates property-based testing into Expecto via testProperty:
open Expecto
open FsCheck
let sortingProperties =
testList "List.sort properties" [
testProperty "sort produces sorted output" <| fun (xs: int list) ->
let sorted = List.sort xs
sorted = (sorted |> List.pairwise |> List.forall (fun (a, b) -> a <= b) |> fun _ -> sorted)
// Simpler version:
List.pairwise sorted |> List.forall (fun (a, b) -> a <= b)
testProperty "sort preserves length" <| fun (xs: int list) ->
List.length (List.sort xs) = List.length xs
testProperty "sort is idempotent" <| fun (xs: int list) ->
let sorted = List.sort xs
List.sort sorted = sorted
testProperty "sort contains all original elements" <| fun (xs: int list) ->
let sorted = List.sort xs
xs |> List.forall (fun x -> List.contains x sorted)
]Arbitrary Instances
For custom types, implement Arbitrary<T>:
type Priority = Low | Medium | High
type Generators =
static member Priority() =
Arb.fromGen (Gen.elements [Low; Medium; High])
type Task = {
Id: int
Title: string
Priority: Priority
Done: bool
}
type TaskGenerators =
static member Task() =
let genTitle = Gen.elements ["Buy milk"; "Write tests"; "Deploy app"; "Fix bug"]
Arb.fromGen (gen {
let! id = Arb.generate<int>
let! title = genTitle
let! priority = Arb.generate<Priority>
let! isDone = Arb.generate<bool>
return { Id = id; Title = title; Priority = priority; Done = isDone }
})Register with FsCheck:
let config = { FsCheckConfig.defaultConfig with arbitrary = [typeof<TaskGenerators>] }
let taskProperties =
testList "Task properties" [
testPropertyWithConfig config "task title is never empty" <| fun (t: Task) ->
t.Title.Length > 0
testPropertyWithConfig config "done tasks stay done after toggle" <| fun (t: Task) ->
// This is a made-up invariant for demonstration
let toggled = { t with Done = not t.Done }
toggled.Done <> t.Done
]Real Example: Testing an F# Domain Model
Here's a complete example testing an order processing domain model:
// Domain/Order.fs
module Domain.Order
type OrderLine = {
ProductId: int
Quantity: int
UnitPrice: decimal
}
type OrderStatus = Pending | Confirmed | Shipped | Cancelled
type Order = {
Id: int
Lines: OrderLine list
Status: OrderStatus
}
let total (order: Order) =
order.Lines |> List.sumBy (fun line -> decimal line.Quantity * line.UnitPrice)
let addLine (line: OrderLine) (order: Order) =
if line.Quantity <= 0 then
Error "Quantity must be positive"
elif line.UnitPrice < 0m then
Error "Unit price cannot be negative"
else
Ok { order with Lines = order.Lines @ [line] }
let confirm (order: Order) =
match order.Status with
| Pending when order.Lines.IsEmpty -> Error "Cannot confirm empty order"
| Pending -> Ok { order with Status = Confirmed }
| status -> Error (sprintf "Cannot confirm order in %A status" status)
let cancel (order: Order) =
match order.Status with
| Shipped -> Error "Cannot cancel shipped order"
| _ -> Ok { order with Status = Cancelled }// Tests/OrderTests.fs
module Tests.OrderTests
open Expecto
open FsCheck
open Domain.Order
let emptyOrder = { Id = 1; Lines = []; Status = Pending }
let validLine = { ProductId = 1; Quantity = 2; UnitPrice = 9.99m }
let orderTests =
testList "Order domain" [
testList "total" [
test "empty order has zero total" {
Expect.equal (total emptyOrder) 0m "empty order total"
}
test "calculates total from lines" {
let order = { emptyOrder with
Lines = [
{ ProductId = 1; Quantity = 2; UnitPrice = 10.00m }
{ ProductId = 2; Quantity = 1; UnitPrice = 5.50m }
] }
Expect.equal (total order) 25.50m "sum of line totals"
}
testProperty "total is non-negative for valid orders" <| fun (lines: (int * decimal) list) ->
let orderLines =
lines
|> List.map (fun (qty, price) ->
{ ProductId = 1
Quantity = abs qty + 1 // ensure positive
UnitPrice = abs price }) // ensure non-negative
let order = { emptyOrder with Lines = orderLines }
total order >= 0m
]
testList "addLine" [
test "adds valid line" {
let result = addLine validLine emptyOrder
Expect.isOk result "should succeed"
let order = result |> Result.defaultWith (fun _ -> emptyOrder)
Expect.equal order.Lines.Length 1 "should have one line"
}
test "rejects zero quantity" {
let line = { validLine with Quantity = 0 }
Expect.isError (addLine line emptyOrder) "should reject zero quantity"
}
test "rejects negative quantity" {
let line = { validLine with Quantity = -1 }
Expect.isError (addLine line emptyOrder) "should reject negative quantity"
}
test "rejects negative price" {
let line = { validLine with UnitPrice = -1m }
Expect.isError (addLine line emptyOrder) "should reject negative price"
}
test "accumulates multiple lines" {
let result =
emptyOrder
|> addLine { ProductId = 1; Quantity = 1; UnitPrice = 10m }
|> Result.bind (addLine { ProductId = 2; Quantity = 2; UnitPrice = 5m })
Expect.isOk result "should succeed"
let order = result |> Result.defaultWith (fun _ -> emptyOrder)
Expect.equal order.Lines.Length 2 "should have two lines"
}
]
testList "confirm" [
test "confirms pending order with lines" {
let order = { emptyOrder with Lines = [validLine] }
let result = confirm order
Expect.isOk result "should succeed"
let confirmed = result |> Result.defaultWith (fun _ -> order)
Expect.equal confirmed.Status Confirmed "status should be Confirmed"
}
test "rejects empty order" {
Expect.isError (confirm emptyOrder) "empty order cannot be confirmed"
}
test "rejects already confirmed order" {
let order = { emptyOrder with Lines = [validLine]; Status = Confirmed }
Expect.isError (confirm order) "confirmed order cannot be confirmed again"
}
test "rejects cancelled order" {
let order = { emptyOrder with Lines = [validLine]; Status = Cancelled }
Expect.isError (confirm order) "cancelled order cannot be confirmed"
}
]
testList "cancel" [
test "cancels pending order" {
let result = cancel emptyOrder
Expect.isOk result "pending order can be cancelled"
}
test "cancels confirmed order" {
let order = { emptyOrder with Status = Confirmed }
Expect.isOk (cancel order) "confirmed order can be cancelled"
}
test "cannot cancel shipped order" {
let order = { emptyOrder with Status = Shipped }
Expect.isError (cancel order) "shipped order cannot be cancelled"
}
]
]// Program.fs
[<EntryPoint>]
let main argv =
runTestsWithCLIArgs [] argv Tests.OrderTests.orderTestsLogging in Tests
Expecto integrates with standard .NET logging:
open Expecto.Logging
let test "logs during test execution" {
let logger = Log.create "MyTest"
logger.info (Message.eventX "Starting database operation")
let result = Db.insert testRecord
logger.info (Message.eventX "Insert complete" >> Message.setField "result" result)
Expect.isOk result "insert should succeed"
}For simpler debug output during development:
test "debug output" {
let value = computeSomething ()
printfn "Debug: value = %A" value // shown when test runs
Expect.equal value expected "should match"
}CI with GitHub Actions
# .github/workflows/test.yml
name: F# Tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore --configuration Release
- name: Run tests
run: dotnet test --no-build --configuration Release --logger "console;verbosity=normal"
- name: Run property tests with more iterations
run: dotnet run --project MyProject.Tests -- --no-spinner
env:
FSCHECK_MAX_TEST: 500Choosing Between Expecto and FsUnit
Use Expecto when:
- Your project is primarily F#
- You want first-class test composition (tests as values)
- You need native async test support
- You prefer functional design in your test framework
- You want fine-grained control over test execution and filtering
Use FsUnit when:
- Your team uses NUnit or xUnit across C# and F# projects
- You need Visual Studio Test Explorer integration (works better with xUnit/NUnit)
- You're migrating an existing C# project to F# and want familiar patterns
- You want mature IDE support for running individual tests
Both integrate with FsCheck for property-based testing, both work with dotnet test, and both run in CI identically. The choice is largely stylistic.
Common Pitfalls
Test ordering matters for state. Expecto runs tests in parallel by default. Tests that share mutable state (databases, files, global variables) must be marked testSequenced or isolated with per-test setup and teardown.
Property tests need shrinking to be useful. FsCheck's built-in types shrink automatically, but custom Arbitrary instances need explicit Shrink implementations to produce useful minimal failing cases.
testAsync vs testTask. Use testAsync for F# async workflows and testTask for C# Task-based async. Mixing them without conversion causes type errors.
Expecto's Expect.equal uses structural equality. For reference types that override Equals, this works correctly. For F# records and discriminated unions, structural equality is the default — no special setup needed.
HelpMeTest extends F# testing with 24/7 monitoring and AI-powered test generation — start free at helpmetest.com