Write comprehensive tests using Expecto for F# applications including domain logic, validation, async operations, and state transitions. Use when implementing tests, ensuring code quality, or verifying functionality. Creates test files in src/Tests/ with patterns for unit tests, property tests, and async tests. Tests domain logic (pure functions), validation rules, persistence operations, and Elmish state management.
Tests are not bureaucratic overhead—they're executable specifications that catch regressions and document behavior. Focus on testing the parts that matter: domain logic, validation, and state transitions.
Before writing tests, ask:
Core Principles:
Test Pure Functions Thoroughly: Domain logic is pure and trivial to test. No mocking needed. These tests are your best ROI.
Test Boundaries, Not Internals: Validate inputs at API boundaries, not deep inside implementation. Test the contract, not the implementation.
Property Tests for Invariants: When a property should always hold (commutativity, idempotence), property-based testing finds edge cases you won't think of.
Fast Tests Enable TDD: If tests are slow, you won't run them. Keep the fast path fast (pure functions), isolate slow tests (I/O).
src/Tests/
├── DomainTests.fs # Pure business logic
├── ValidationTests.fs # Input validation
├── StateTests.fs # Elmish state transitions
├── PersistenceTests.fs # Database/file I/O (integration)
├── Program.fs # Test runner entry
└── Tests.fsproj
module Program
open Expecto
[<EntryPoint>]
let main args =
runTestsInAssembly defaultConfig args
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup>
<ItemGroup>
<Compile Include="DomainTests.fs" />
<Compile Include="ValidationTests.fs" />
<Compile Include="StateTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Expecto" Version="10.2.1" />
<PackageReference Include="Expecto.FsCheck" Version="10.2.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Server/Server.fsproj" />
<ProjectReference Include="../Shared/Shared.fsproj" />
</ItemGroup>
</Project>
Pure functions are the easiest and most valuable to test. No setup, no mocking, just input → output.
module DomainTests
open Expecto
open Shared.Domain
// Test fixtures - reusable test data
module TestData =
let baseOrder = {
Id = 1
CustomerId = 100
Items = []
Status = Draft
CreatedAt = DateTime(2024, 1, 1)
}
let sampleItem = {
ProductId = 1
ProductName = "Widget"
Quantity = 2
UnitPrice = 10.0m
}
[<Tests>]
let domainTests =
testList "Domain Logic" [
testList "Order Creation" [
testCase "new order starts as Draft" <| fun () ->
let request = { CustomerId = 1; Items = []; Notes = None }
let order = Domain.createOrder request
Expect.equal order.Status Draft "Should start as draft"
testCase "order creation trims customer notes" <| fun () ->
let request = { CustomerId = 1; Items = []; Notes = Some " hello " }
let order = Domain.createOrder request
Expect.equal order.Notes (Some "hello") "Should trim notes"
]
testList "Order Calculations" [
testCase "total is sum of item prices times quantities" <| fun () ->
let items = [
{ ProductId = 1; ProductName = "A"; Quantity = 2; UnitPrice = 10.0m }
{ ProductId = 2; ProductName = "B"; Quantity = 3; UnitPrice = 5.0m }
]
let total = Domain.calculateTotal items
Expect.equal total 35.0m "2*10 + 3*5 = 35"
testCase "empty order has zero total" <| fun () ->
let total = Domain.calculateTotal []
Expect.equal total 0m "Empty order = $0"
]
testList "Status Transitions" [
testCase "can submit draft order" <| fun () ->
let draft = { TestData.baseOrder with Status = Draft }
let result = Domain.submitOrder draft
Expect.isOk result "Should succeed"
match result with
| Ok order -> Expect.equal order.Status Submitted "Should be submitted"
| Error _ -> failtest "Expected Ok"
testCase "cannot submit already submitted order" <| fun () ->
let submitted = { TestData.baseOrder with Status = Submitted }
let result = Domain.submitOrder submitted
Expect.isError result "Should fail"
testCase "cannot ship unsubmitted order" <| fun () ->
let draft = { TestData.baseOrder with Status = Draft }
let result = Domain.shipOrder draft "TRACK123"
Expect.isError result "Should fail"
]
]
Key patterns:
testListValidation tests verify boundary conditions and error messages.
module ValidationTests
open Expecto
open Validation
[<Tests>]
let validationTests =
testList "Validation" [
testList "Required Fields" [
testCase "empty title fails" <| fun () ->
let request = { Title = ""; Description = None }
let result = Validation.validateRequest request
Expect.isError result "Should reject empty title"
testCase "whitespace-only title fails" <| fun () ->
let request = { Title = " "; Description = None }
let result = Validation.validateRequest request
Expect.isError result "Should reject whitespace"
testCase "valid title passes" <| fun () ->
let request = { Title = "Valid Title"; Description = None }
let result = Validation.validateRequest request
Expect.isOk result "Should accept valid title"
]
testList "Length Constraints" [
testCase "title too long fails" <| fun () ->
let longTitle = String.replicate 201 "a"
let request = { Title = longTitle; Description = None }
let result = Validation.validateRequest request
Expect.isError result "Should reject >200 chars"
testCase "title at max length passes" <| fun () ->
let maxTitle = String.replicate 200 "a"
let request = { Title = maxTitle; Description = None }
let result = Validation.validateRequest request
Expect.isOk result "Should accept exactly 200 chars"
]
testList "Error Accumulation" [
testCase "multiple errors are collected" <| fun () ->
let request = { Title = ""; Email = "invalid"; Age = -5 }
match Validation.validateRequest request with
| Error errors ->
Expect.isGreaterThan errors.Length 1 "Should have multiple errors"
| Ok _ ->
failtest "Should have failed"
testCase "all error messages are specific" <| fun () ->
let request = { Title = ""; Email = "bad" }
match Validation.validateRequest request with
| Error errors ->
Expect.exists errors (fun e -> e.Contains("Title")) "Should mention title"
Expect.exists errors (fun e -> e.Contains("email")) "Should mention email"
| Ok _ ->
failtest "Should have failed"
]
]
Test the update function as a pure state machine.
module StateTests
open Expecto
open State
open Types
open Shared.Domain
module TestModels =
let initial = {
Items = NotAsked
SelectedItem = NotAsked
FormTitle = ""
IsSubmitting = false
}
let loading = { initial with Items = Loading }
let withItems items = { initial with Items = Success items }
[<Tests>]
let stateTests =
testList "State Management" [
testList "Loading States" [
testCase "LoadItems sets Loading state" <| fun () ->
let model, _ = update LoadItems TestModels.initial
Expect.equal model.Items Loading "Should be Loading"
testCase "LoadItems returns fetch command" <| fun () ->
let _, cmd = update LoadItems TestModels.initial
Expect.isFalse (Cmd.isEmpty cmd) "Should have a command"
]
testList "Data Loaded" [
testCase "ItemsLoaded Ok sets Success" <| fun () ->
let items = [{ Id = 1; Title = "Test" }]
let model, _ = update (ItemsLoaded (Ok items)) TestModels.loading
Expect.equal model.Items (Success items) "Should be Success"
testCase "ItemsLoaded Error sets Failure" <| fun () ->
let model, _ = update (ItemsLoaded (Error "Network error")) TestModels.loading
match model.Items with
| Failure msg -> Expect.stringContains msg "Network" "Should have error"
| _ -> failtest "Should be Failure"
]
testList "Form Submission" [
testCase "SubmitForm sets IsSubmitting" <| fun () ->
let model = { TestModels.initial with FormTitle = "Test" }
let result, _ = update SubmitForm model
Expect.isTrue result.IsSubmitting "Should be submitting"
testCase "FormSubmitted Ok clears form" <| fun () ->
let model = { TestModels.initial with FormTitle = "Test"; IsSubmitting = true }
let result, _ = update (FormSubmitted (Ok { Id = 1; Title = "Test" })) model
Expect.equal result.FormTitle "" "Should clear title"
Expect.isFalse result.IsSubmitting "Should stop submitting"
testCase "FormSubmitted Ok triggers reload" <| fun () ->
let model = { TestModels.initial with IsSubmitting = true }
let _, cmd = update (FormSubmitted (Ok { Id = 1; Title = "Test" })) model
Expect.isFalse (Cmd.isEmpty cmd) "Should have reload command"
]
testList "Form Updates" [
testCase "UpdateTitle updates model" <| fun () ->
let model, _ = update (UpdateTitle "New Title") TestModels.initial
Expect.equal model.FormTitle "New Title" "Should update title"
testCase "UpdateTitle returns no command" <| fun () ->
let _, cmd = update (UpdateTitle "New Title") TestModels.initial
Expect.isTrue (Cmd.isEmpty cmd) "Should have no command"
]
]
// Helper to check if command is empty
module Cmd =
let isEmpty cmd = List.isEmpty cmd
For invariants that should always hold, let FsCheck generate test cases.
open FsCheck
[<Tests>]
let propertyTests =
testList "Properties" [
testProperty "order total is never negative" <| fun (quantities: int list) (prices: decimal list) ->
let items =
List.zip quantities prices
|> List.map (fun (q, p) -> { Quantity = abs q; UnitPrice = abs p; ProductId = 1; ProductName = "" })
Domain.calculateTotal items >= 0m
testProperty "trimming is idempotent" <| fun (s: string) ->
let trimmed = s.Trim()
trimmed.Trim() = trimmed
testProperty "validation passes for all valid inputs" <| fun (NonEmptyString title) ->
let shortTitle = title.Substring(0, min title.Length 200)
let request = { Title = shortTitle; Description = None }
Result.isOk (Validation.validateRequest request)
]
When to use property tests:
For I/O operations, use testCaseAsync.
[<Tests>]
let asyncTests =
testList "Persistence" [
testCaseAsync "getAllItems returns list" <| async {
let! items = Persistence.getAllItems()
Expect.isNotNull items "Should return list"
}
testCaseAsync "getById returns None for nonexistent" <| async {
let! result = Persistence.getById 999999
Expect.isNone result "Should not find item"
}
testCaseAsync "insert then get returns same item" <| async {
let item = { Id = 0; Title = "Test" }
let! inserted = Persistence.insert item
let! retrieved = Persistence.getById inserted.Id
Expect.isSome retrieved "Should find item"
Expect.equal retrieved.Value.Title item.Title "Should have same title"
}
]
❌ Testing Implementation, Not Behavior
// BAD: Testing internal structure
testCase "uses List.map internally" <| fun () ->
// Don't test HOW it works, test WHAT it produces
Why bad: Breaks when you refactor, doesn't verify behavior. Better: Test inputs and outputs.
❌ Ignoring Edge Cases
// BAD: Only testing happy path
testCase "validates email" <| fun () ->
let result = validate "user@example.com"
Expect.isOk result
// What about empty? Whitespace? Missing @? Too long?
Why bad: Bugs hide in edge cases. Better: Test boundaries, empty, null, max length, invalid formats.
❌ Slow Tests in Main Suite
// BAD: Database test mixed with unit tests
testCase "saves to database" <| fun () ->
// Slow I/O operation
Why bad: Slows down feedback loop. Better: Separate integration tests, run them separately.
❌ Non-Deterministic Tests
// BAD: Depends on current time
testCase "item is recent" <| fun () ->
let item = Domain.create request
Expect.isLessThan (DateTime.Now - item.CreatedAt).TotalSeconds 1.0
Why bad: Flaky tests erode trust. Better: Inject time or test relative properties.
Adapt test depth to risk:
The goal is confidence, not coverage percentage.
# Run all tests
dotnet test
# Run with watch (re-runs on changes)
dotnet watch test
# Run specific tests
dotnet test --filter "FullyQualifiedName~Domain"
# Verbose output
dotnet test --logger "console;verbosity=detailed"
Tests are an investment. Focus that investment where it matters: pure domain logic, validation rules, state transitions. These are the places where bugs have real consequences and where tests are easy to write.
The goal: When a test fails, you should immediately know what broke and why—without debugging.
/docs/06-TESTING.md - Detailed testing guideSearch for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer