F# Testing with Expecto and FsUnit

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 integration

Your .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 message

Parallel 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 -- --parallel

Running Tests

As a standalone executable

// Program.fs
[<EntryPoint>]
let main argv =
  runTestsWithCLIArgs [] argv allTests
dotnet 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 version

With 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.Sdk

FsUnit 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.orderTests

Logging 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: 500

Choosing 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

Read more