---
title: "skill by agenticluke · skilld"
canonical_url: "https://skilld.dev/gh/agenticluke/fsharp-test-kit-plus"
meta:
  description: "Test F# code with xUnit, FsUnit, Unquote, FsCheck, stubs, and .NET test tools. From agenticluke/fsharp-test-kit-plus."
  "og:description": "Test F# code with xUnit, FsUnit, Unquote, FsCheck, stubs, and .NET test tools. From agenticluke/fsharp-test-kit-plus."
  "og:title": "skill by agenticluke"
  "twitter:description": "Test F# code with xUnit, FsUnit, Unquote, FsCheck, stubs, and .NET test tools. From agenticluke/fsharp-test-kit-plus."
  "twitter:title": "skill by agenticluke"
---

`

[All skills](https://skilld.dev/skills)

[![agenticluke avatar](https://skilld.dev/_img/avatar?url=https%3A%2F%2Fgithub.com%2Fagenticluke.png%3Fsize%3D96)](https://skilld.dev/gh/agenticluke)

# **/skill**

[@0195516](https://github.com/agenticluke/fsharp-test-kit-plus/commit/0195516e392e3d242560134167c3c33023393311 "Your agent reads SKILL.md at commit 0195516")

by [agenticluke](https://skilld.dev/gh/agenticluke)· [agenticluke](https://skilld.dev/gh/agenticluke)/ [fsharp-test-kit-plus](https://skilld.dev/gh/agenticluke/fsharp-test-kit-plus)

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

- 1 file
- 12.5 KB
- Updated 3 weeks ago
- [GitHub](https://github.com/agenticluke/fsharp-test-kit-plus/blob/0195516e392e3d242560134167c3c33023393311/skill/SKILL.md "View SKILL.md on GitHub")

## SKILL.md

12.5 KB

**≈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](https://github.com/agenticluke/fsharp-test-kit-plus/blob/0195516e392e3d242560134167c3c33023393311/skill/SKILL.md)

## Third-party checks

No third-party reports yet.

## Provenance

[Signed by skilld at 0195516.](https://github.com/agenticluke/fsharp-test-kit-plus/commit/0195516e392e3d242560134167c3c33023393311 "0195516e392e3d242560134167c3c33023393311") 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

## Capability

<dl>

<dt>origin</dt>
<dd>ECC</dd>

</dl>

## README badge

![README badge for agenticluke/fsharp-test-kit-plus](https://skilld.dev/b/agenticluke/fsharp-test-kit-plus?theme=light&label=0)

## Related skills

-
-
-
-
-
-