All skills

Test F# code with xUnit, FsUnit, Unquote, FsCheck, stubs, and .NET test tools.

  • 1 file
  • 12.5 KB
  • Updated 3 weeks ago
  • GitHub

Use this Skill: https://skilld.dev/gh/agenticluke/fsharp-test-kit-plus/skill

This session only. Nothing lands on disk.

SKILL.md

≈21 tokens always: the name and description. ≈3.2k when used: this file.

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.fsproj

Add 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 NSubstitute

F# 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 Confirmed

Use 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 >= 0m

Do 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 email

Keep 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 = order

Prefer 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 end

Use 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 >= 0m

This 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.fs

List 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 design
  • csharp-testing: C# tests that may share .NET test tools

Source: SKILL.md on GitHub

No third-party reports yet.

Signed by skilld at 0195516. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 3 weeks ago.

Activeupdated 3 weeks ago
origin
ECC

README badge

README badge for agenticluke/fsharp-test-kit-plus