Why F#'s Style Makes Testing Easier
F#'s emphasis on immutability, pure functions, and explicit data flow — records and discriminated unions instead of mutable object graphs, dependencies passed as function parameters instead of hidden behind fields — means a large fraction of typical business logic can be tested as plain input-in, output-out functions with no test framework ceremony beyond calling the function and asserting on its result. Standard .NET test runners like xUnit and NUnit work fine with F# and are the natural choice for teams already using them in a mixed C#/F# codebase, but F#-native frameworks like Expecto are popular specifically because they lean into this functional style rather than working against it.
Cricket analogy: Checking whether a batter's technique is sound by watching them face balls in a simple net session — no live match pressure, no fielders, no crowd — is far easier than judging them mid-World-Cup-final, just as testing a pure F# function is as simple as calling it with known inputs, with no hidden mutable state to account for.
Expecto: F#-Native Testing
Expecto represents tests as ordinary F# values built with testCase "should add two numbers" <| fun () -> Expect.equal (add 2 3) 5 "sum should be 5" and groups them with testList, so an entire test suite is just a tree of F# values that a main function passes to runTestsWithArgs — no attributes, no reflection-based test discovery, and the whole suite compiles and runs as an ordinary console application, which also makes it fast to run and easy to filter or parameterize with plain F# code rather than framework-specific configuration.
Cricket analogy: A club's training ledger listing every net session as a simple line item — Tuesday: catching drills, Thursday: batting practice — that the coach reads straight down the page mirrors how Expecto represents an entire test suite as an ordinary F# list of testCase values passed to runTestsWithArgs, with no special registration mechanism.
Property-Based Testing with FsCheck
Where example-based tests like Expecto's testCase check specific input/output pairs, FsCheck generates hundreds of randomized inputs and checks that a property holds for all of them — for instance, Prop.forAll can assert that sorting is idempotent, or that reversing a list twice returns the original list, and when a property fails, FsCheck automatically shrinks the failing input down to the smallest counterexample that still reproduces the bug, which is often far more useful for debugging than a single hand-picked failing example.
Cricket analogy: Instead of checking a bowling machine's accuracy with one single ball, a coach fires a hundred balls at random speeds and angles and checks that every one lands somewhere in the crease zone; if one lands outside, they narrow down the exact speed setting that caused it — exactly like FsCheck generating hundreds of random inputs and shrinking a failure to the minimal reproducing case.
Test Doubles and Dependency Injection in F#
Because F# encourages passing dependencies as ordinary function parameters — a saveOrder: Order -> Async<unit> function rather than an IOrderRepository interface bound through a DI container — many tests substitute a fake implementation simply by passing a different function or lambda at the call site, with no mocking framework required at all. When a genuine interface-based abstraction is unavoidable, such as when interoperating with a C# library that expects an interface, F# can still implement that interface with an object expression inline in a test, which is often lighter weight than reaching for a mocking library like Moq or Foq.
Cricket analogy: A net-practice session swaps in a local club bowler instead of the first-choice international bowler to simulate similar deliveries for batting practice, just as an F# test swaps in a fake saveOrder function instead of the real database-backed one, simply by passing a different function at the call site.
module OrderTests
open Expecto
open FsCheck
let applyDiscount (percent: float) (total: float) =
total * (1.0 - percent / 100.0)
[<Tests>]
let tests =
testList "Order pricing" [
testCase "10% discount reduces total correctly" <| fun () ->
Expect.equal (applyDiscount 10.0 100.0) 90.0 "should apply a 10% discount"
testProperty "discount never produces a negative total for 0-100% range" <|
fun (percent: float) (total: NonNegativeInt) ->
let p = abs percent % 100.0
let t = float total.Get
applyDiscount p t >= 0.0
]
[<EntryPoint>]
let main argv = runTestsWithArgs defaultConfig argv testsBecause F# tests are ordinary functions and lists of test cases are ordinary F# values, you can build a test suite programmatically — for example generating one testCase per row of a CSV of expected input/output pairs with List.map — something that requires much more ceremony with attribute-driven frameworks.
FsCheck's default generators for types like float include NaN, infinity, and extreme magnitudes, which can make a property fail for reasons unrelated to the logic being tested. Constrain generators explicitly (as with NonNegativeInt above, or a custom Arbitrary) rather than assuming the default generator matches your domain's real-world value ranges.
- F#'s pure functions, records, and DUs make a large share of business logic testable as plain input-output calls with minimal test-framework ceremony.
- Expecto represents tests as ordinary F# values (testCase, testList) run from a console app entry point via runTestsWithArgs.
- FsCheck performs property-based testing, generating many randomized inputs to check that a property holds universally.
- When a property-based test fails, FsCheck automatically shrinks the input to the smallest reproducing counterexample.
- Passing dependencies as plain function parameters (e.g., saveOrder: Order -> Async<unit>) often eliminates the need for a mocking framework in tests.
- Object expressions let F# implement a C#-style interface inline for a single test without a full mock class.
- FsCheck's default generators can include edge cases like NaN or infinity, so generators should be constrained to match the domain's real value ranges.
Practice what you learned
1. Why does F#'s style tend to make business logic easier to unit test?
2. How does Expecto structure a test suite?
3. What does FsCheck do when a property-based test fails?
4. Why do many F# tests avoid needing a mocking framework?
5. What risk does the warning about FsCheck's default float generator describe?
Was this page helpful?
You May Also Like
F# and .NET Interop
F# runs on .NET and can freely call into C# libraries and the Base Class Library, and be consumed from C# projects — understanding the interop rules is essential for real-world F# work.
Asynchronous Programming in F#
F#'s Async<'T> type and async { } computation expression provide a composable, cold-start model for asynchronous work that predates and interoperates with .NET's Task-based async/await.
F# and Data Science
F# supports typed, exploratory data science through F# Interactive and .NET Interactive notebooks, FSharp.Data's type providers, Deedle/Math.NET Numerics for analysis, and ML.NET for machine learning.
Related Reading
Related Study Notes in Programming
Browse all study notesApache Spark Study Notes
Programming · 30 topics
ProgrammingApache Flink Study Notes
Programming · 30 topics
ProgrammingHadoop Study Notes
Programming · 30 topics
ProgrammingSnowflake Study Notes
Programming · 30 topics
ProgrammingApache Airflow Study Notes
Programming · 30 topics
Programmingdbt (Data Build Tool) Study Notes
Programming · 30 topics